From 7654bf88a6e7b128fd907a17f928b8aa2b130ce5 Mon Sep 17 00:00:00 2001 From: Jess Moore Date: Wed, 9 Sep 2026 10:20:59 +1000 Subject: [PATCH 1/9] use latest version not first listed --- lib/src/utils/parse_changelog.dart | 125 +++++++++++++++++++++++ test/parse_changelog_test.dart | 158 +++++++++++++++++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 lib/src/utils/parse_changelog.dart create mode 100644 test/parse_changelog_test.dart diff --git a/lib/src/utils/parse_changelog.dart b/lib/src/utils/parse_changelog.dart new file mode 100644 index 0000000..cf6b44d --- /dev/null +++ b/lib/src/utils/parse_changelog.dart @@ -0,0 +1,125 @@ +/// Parse version entries out of a CHANGELOG. +/// +// Time-stamp: +/// +/// Copyright (C) 2024-2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Jess Moore + +library; + +import 'package:version_widget/src/utils/compare_versions.dart'; + +/// A single version entry read from a CHANGELOG. + +class ChangelogEntry { + /// Creates an entry pairing a [version] with its release [date]. + + const ChangelogEntry({required this.version, required this.date}); + + /// The version string, e.g. `1.0.10`. + + final String version; + + /// The release date as it appeared in the CHANGELOG, e.g. `20260512`. + + final String date; +} + +/// Matches one `[version date]` entry, tolerating an author between the two. +/// +/// Group 1 is the version, group 2 is the eight digit date. Both of the +/// orderings in use across our apps are accepted: +/// +/// - `[1.0.10 20260512 tonypioneer]` — the documented convention. +/// - `[0.1.13 jesscmoore 20260908]` — author first, as podmail writes it. +/// +/// The negative lookahead on the optional author group is what makes this +/// work. Without it the author group would happily consume the date in the +/// first form, leaving nothing for group 2 to match. +/// +/// The closing `]` is deliberately not required, matching the behaviour of +/// earlier releases. Requiring it would be worse than it looks: `[^\]]` +/// matches newlines, so an unterminated entry could otherwise scan across +/// lines and swallow the entry below it. + +final RegExp _entryPattern = RegExp( + // Group 1: a dotted version, which must start with a digit. + + r'\[(\d+(?:\.\d+)*)' + r'\s+' + + // An optional author token, matched but discarded. The lookahead stops it + // consuming the date itself. + + r'(?:(?!\d{8}(?!\d))[^\s\]]+\s+)?' + + // Group 2: the date, rejected if it is part of a longer run of digits. + + r'(\d{8})(?!\d)', +); + +/// Every parsable version entry in [content], in the order they appear. +/// +/// Never throws. Malformed entries are skipped rather than guessed at, so +/// content with no recognisable entries yields an empty list — which the +/// caller should treat as a failed check, not as an up to date app. + +List parseChangelogEntries(String content) => [ + for (final match in _entryPattern.allMatches(content)) + ChangelogEntry(version: match.group(1)!, date: match.group(2)!), + ]; + +/// The latest version among [entries], or null when there are none. +/// +/// The highest version wins, not the first one listed. Our changelogs are +/// written newest first, so the two usually agree — but taking the maximum +/// means a file that is out of order, or has had an entry appended at the +/// bottom, still reports the right answer. Ties go to the first occurrence. + +String? latestVersionOf(List entries) { + String? latest; + + for (final entry in entries) { + if (latest == null || compareVersions(entry.version, latest) > 0) { + latest = entry.version; + } + } + + return latest; +} + +/// The date recorded against [version], or null when it is not listed. +/// +/// An app running a version that predates the CHANGELOG, or a development +/// build ahead of it, will not be found — hence the null rather than a +/// fabricated fallback. + +String? dateForVersion(List entries, String version) { + for (final entry in entries) { + if (entry.version == version) return entry.date; + } + return null; +} diff --git a/test/parse_changelog_test.dart b/test/parse_changelog_test.dart new file mode 100644 index 0000000..55c3351 --- /dev/null +++ b/test/parse_changelog_test.dart @@ -0,0 +1,158 @@ +/// Tests for CHANGELOG parsing. + +library; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:version_widget/src/utils/parse_changelog.dart'; + +import 'fixtures/changelogs.dart'; + +void main() { + group('parseChangelogEntries', () { + test('reads the documented `[version date author]` order', () { + final entries = parseChangelogEntries(canonicalChangelog); + + expect(entries.length, 3); + expect(entries.first.version, '1.0.10'); + expect(entries.first.date, '20260512'); + expect(entries.last.version, '1.0.8'); + }); + + test('reads the `[version author date]` order podmail writes', () { + // The regression. Before 1.1.0 this yielded zero entries, so podmail + // reported itself up to date on every launch for 13 releases. + + final entries = parseChangelogEntries(authorFirstChangelog); + + expect(entries.length, 3); + expect(entries.first.version, '0.1.13'); + expect(entries.first.date, '20260908'); + expect(entries[1].version, '0.1.12'); + expect(entries[1].date, '20260907'); + }); + + test('reads both orders from the one file', () { + final entries = parseChangelogEntries(mixedChangelog); + + expect( + entries.map((e) => e.version).toList(), + ['2.0.1', '2.0.0', '1.9.9'], + ); + expect( + entries.map((e) => e.date).toList(), + ['20260601', '20260530', '20260501'], + ); + }); + + test('skips entries it cannot read rather than guessing', () { + expect(parseChangelogEntries(unparsableChangelog), isEmpty); + }); + + test('skips each malformed form individually', () { + expect(parseChangelogEntries('[1.0.0]'), isEmpty); + expect(parseChangelogEntries('[1.0.0 jess]'), isEmpty); + expect(parseChangelogEntries('[abc 20250101]'), isEmpty); + expect(parseChangelogEntries('[1.0.0 2026051]'), isEmpty); + + // A nine digit run is rejected outright rather than silently + // truncated to a plausible looking eight digit date. + + expect(parseChangelogEntries('[1.0.0 202605123]'), isEmpty); + }); + + test('accepts an unterminated entry, as earlier releases did', () { + final entries = parseChangelogEntries('+ Something [1.0.0 20250101'); + + expect(entries.length, 1); + expect(entries.first.version, '1.0.0'); + }); + + test('does not run past a closing bracket into the next entry', () { + final entries = parseChangelogEntries('[1.0.0 nodate]\n[2.0.0 20260101]'); + + expect(entries.length, 1); + expect(entries.first.version, '2.0.0'); + }); + + test('finds entries embedded in rendered HTML', () { + final entries = parseChangelogEntries(htmlChangelog); + + expect(entries.length, 2); + expect(entries.first.version, '0.1.11'); + }); + + test('returns nothing for empty or blank content', () { + expect(parseChangelogEntries(''), isEmpty); + expect(parseChangelogEntries(' \n\n '), isEmpty); + expect(parseChangelogEntries('# A changelog with no entries'), isEmpty); + }); + }); + + group('latestVersionOf', () { + test('returns null when there are no entries', () { + expect(latestVersionOf([]), isNull); + }); + + test('returns the highest version, not the first listed', () { + // A changelog written out of order, or with an entry appended at the + // bottom, still reports the right answer. + + final entries = parseChangelogEntries( + '[1.0.2 20260101 gjw]\n[1.0.9 20260301 gjw]\n[1.0.5 20260201 gjw]', + ); + + expect(latestVersionOf(entries), '1.0.9'); + }); + + test('compares numerically rather than lexically', () { + final entries = parseChangelogEntries( + '[1.0.9 20260101 gjw]\n[1.0.10 20260301 gjw]', + ); + + expect(latestVersionOf(entries), '1.0.10'); + }); + + test('resolves a tie to the first occurrence', () { + final entries = parseChangelogEntries( + '[1.0.0 20260101 gjw]\n[1.0.0 20250101 gjw]', + ); + + expect(latestVersionOf(entries), '1.0.0'); + }); + + test('returns the newest entry of a newest first changelog', () { + expect( + latestVersionOf(parseChangelogEntries(canonicalChangelog)), + '1.0.10', + ); + expect( + latestVersionOf(parseChangelogEntries(authorFirstChangelog)), + '0.1.13', + ); + }); + }); + + group('dateForVersion', () { + final entries = parseChangelogEntries(canonicalChangelog); + + test('finds the date recorded against a listed version', () { + expect(dateForVersion(entries, '1.0.9'), '20260510'); + }); + + test('returns null for a version the changelog does not list', () { + // A development build ahead of the changelog, or one older than it. + + expect(dateForVersion(entries, '9.9.9'), isNull); + expect(dateForVersion(entries, '1.0.0'), isNull); + }); + + test('takes the first date when a version appears twice', () { + final duplicated = parseChangelogEntries( + '[1.0.0 20260101 gjw]\n[1.0.0 20250101 gjw]', + ); + + expect(dateForVersion(duplicated, '1.0.0'), '20260101'); + }); + }); +} From d51c0dc6a882e23044e63d03961fecc216a7cc0b Mon Sep 17 00:00:00 2001 From: Jess Moore Date: Wed, 9 Sep 2026 14:18:02 +1000 Subject: [PATCH 2/9] add public webpage as alt changelog source and report failed version checks --- .github/workflows/ci.yaml | 12 + CHANGELOG.md | 5 +- README.md | 105 ++- analysis_options.yaml | 1 + example/analysis_options.yaml | 3 + example/lib/main.dart | 29 + lib/src/models/version_status.dart | 111 +++ lib/src/utils/fetch_changelog.dart | 94 +++ lib/src/utils/format_date.dart | 67 ++ lib/src/utils/parse_changelog.dart | 9 + lib/src/widgets/version_changelog_dialog.dart | 177 +++++ lib/src/widgets/version_widget.dart | 634 +++++++----------- lib/version_widget.dart | 1 + pubspec.yaml | 4 +- test/compare_versions_test.dart | 50 ++ test/fixtures/changelogs.dart | 62 ++ test/parse_changelog_test.dart | 18 + test/version_widget_status_test.dart | 412 ++++++++++++ 18 files changed, 1398 insertions(+), 396 deletions(-) create mode 100644 lib/src/models/version_status.dart create mode 100644 lib/src/utils/fetch_changelog.dart create mode 100644 lib/src/utils/format_date.dart create mode 100644 lib/src/widgets/version_changelog_dialog.dart create mode 100644 test/compare_versions_test.dart create mode 100644 test/fixtures/changelogs.dart create mode 100644 test/version_widget_status_test.dart diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7ed98a1..d44905e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -29,6 +29,18 @@ jobs: - run: flutter pub get - run: flutter analyze --fatal-infos + test: + runs-on: ubuntu-latest + if: github.event.repository.private == false + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + channel: 'stable' + flutter-version: ${{env.FLUTTER_VERSION}} + - run: flutter pub get + - run: flutter test + format: runs-on: ubuntu-latest if: github.event.repository.private == false diff --git a/CHANGELOG.md b/CHANGELOG.md index c18b1ec..2ba9a0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,12 @@ utilised by the flutter version_widget package. ## 1.1 Review and Consolidate ++ Report failed checks rather than assuming latest [1.1.0 20260909 jesscmoore] + Restore version string colours for status [1.0.10 20260512 tonypioneer] + Add an UPDATE button [1.0.9 20260510 tonypioneer] + Better tooltip formatting [1.0.8 20260429 gjw] + Tooltip headline rather than paragraph [1.0.7 20260429 gjw] -+ Better CHANGELOG. Rosolve CORS issue [1.0.6 20251027 tonypioneer] ++ Better CHANGELOG. Resolve CORS issue [1.0.6 20251027 tonypioneer] + Update tooltip text [1.0.5 20250928 gjw] + Support user text style for Version string [1.0.4 20250722 jesscmoore] + Support VersionWidget fontSize [1.0.3 20250717 gjw] @@ -34,7 +35,7 @@ utilised by the flutter version_widget package. + Fixed version date display [0.0.7 20250429 kev] + Improved parsing extract all version-date pairs [0.0.7 20250429 kev] + Find the correct date for each version [0.0.7 20250429 kev] -+ Updated doc to reflect the new date matching behavior [0.0.7 20250429 kev] ++ Updated doc to reflect the new date matching behaviour [0.0.7 20250429 kev] + Made version parameter required in VersionWidget [0.0.6 20250428 kev] + Removed version extraction from changelog [0.0.6 20250428 kev] + Simplified changelog fetching logic [0.0.6 20250428 kev] diff --git a/README.md b/README.md index 022a206..ca61333 100644 --- a/README.md +++ b/README.md @@ -109,15 +109,32 @@ VersionWidget( - Grey text: Version is being checked - Blue text: Version is up to date - Red bold text: Newer version is available -- No date shown: Internet connection unavailable +- Amber text: The version could not be checked + +The amber state matters. Before 1.1.0 a check that failed — a moved or +private CHANGELOG, a CORS block, a file the widget could not parse — was +reported as though the app were up to date, so an app could claim to be +current indefinitely while never once succeeding at the check. A failed +check now says so, and does not offer an update button, since no update +is known to exist. Pass `assumeLatestOnCheckFailure: true` to restore +the older, quieter behaviour. + +The same applies when the app cannot report its own version — an empty +or non-numeric `version`, which on Apple platforms usually means the +build carries no `CFBundleShortVersionString`. That compares as older +than every release, so without the guard the widget would announce an +update on the strength of no information at all. It reports the version +as unknown instead. ## CHANGELOG.md Format The widget expects the CHANGELOG.md file to have dates in the following format. The important part is `[1.0.5 20250101` and the first such text found is interpreted as the latest version and -timestamp. This allows, for example, the string to be `[1.0.5 20250514 -fred]` as a common format to attribute changes to users. +timestamp. An author may sit on either side of the date, so both +`[1.0.5 20250514 fred]` and `[1.0.5 fred 20250514]` are read correctly. +The first form is the convention across our apps; the second is +tolerated so an app is not silently unversioned for writing it. ```markdown ## [1.0.5 20250101] @@ -128,6 +145,59 @@ The widget will automatically find the correct release date for the current version by matching against all version entries in the changelog. +## Private repositories + +The widget fetches the CHANGELOG with a plain, unauthenticated GET, so +the file must be reachable without credentials. The repository being +private is not itself a problem — publishing the CHANGELOG somewhere +public is usually the simplest answer, and for a web app, serving it +from the same origin as the app avoids CORS entirely: + +```make +flutter build web --release +cp CHANGELOG.md build/web/CHANGELOG.md # after the build, not in web/ +``` + +Copy it after the build rather than committing it to `web/`. That keeps +one source of truth, and keeps the file out of anything Flutter +generates from `web/` — older Flutter versions pre-cached everything +there into a service worker, which would have served users a cached +copy of the changelog they already had. + +When the changelog genuinely cannot be made public, supply a +`changelogLoader` and fetch it yourself: + +```dart +// From an authenticated backend, using a token the app already holds +// from the signed in session. + +VersionWidget( + version: '1.0.5', + changelogUrl: 'https://api.example.com/changelog', + changelogLoader: (url) async { + final response = await http.get( + Uri.parse(url), + headers: {'Authorization': 'Bearer ${session.accessToken}'}, + ); + return response.body; + }, +) + +// Or bundled with the build. This populates the changelog dialogue but +// can never detect an update, since it is frozen at build time. + +VersionWidget( + version: '1.0.5', + changelogUrl: 'asset', + changelogLoader: (_) => rootBundle.loadString('assets/CHANGELOG.md'), +) +``` + +Never compile a long lived credential such as a GitHub personal access +token into the app to do this. A shipped binary is readable by anyone +who has it, and a web build most of all. Use a token the user's own +session already provides, or make the changelog public. + ## Properties - `version` (required): The version string to display. Must be provided. @@ -140,6 +210,18 @@ changelog. be fetched (format: YYYYMMDD) - `isLatestTooltip` (optional): Custom message to show when version is latest - `notLatestTooltip` (optional): Custom message to show when newer version is available +- `unknownTooltip` (optional): Custom message to show when the check could + not be completed +- `unknownColor` (optional): Colour of the version label when the check + could not be completed (defaults to a muted amber). Applied on top of + `userTextStyle` too, so pick one that stays legible on your background. +- `assumeLatestOnCheckFailure` (optional): Report a failed check as up to + date, as releases before 1.1.0 did (defaults to false) +- `changelogLoader` (optional): Supplies the CHANGELOG text instead of the + built-in HTTP GET. See Private repositories above. +- `onUpdatePressed` (optional): Called instead of launching `downloadUrl` + when the update button is tapped. Useful for a web app, where the update + is a reload rather than an installer. - `showUpdateButton` (optional): Whether to show the discover-and-download button when a newer version is detected (defaults to false). The button is only rendered when this flag is enabled, a newer version @@ -150,6 +232,23 @@ changelog. - `updateButtonLabel` (optional): Text label shown next to the icon on the update button (defaults to `Update`). +## Platform setup + +### MacOS/iOS + +MacOS and iOS builds of apps using version widget require these settings to pick up the app version, which is used to compare against the changelog + +In `Runner/Info.plist` within `macos` and `ios` folders, set: +```xml + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) +``` +If using `xcodegen` to generate XCode files, your `macos` and `ios` `project.yml` files must contain: +```yml +MARKETING_VERSION: '$(FLUTTER_BUILD_NAME)' +CURRENT_PROJECT_VERSION: '$(FLUTTER_BUILD_NUMBER)' +``` + ## Contributing Contributions are welcome! Please feel free to submit a Pull Request. diff --git a/analysis_options.yaml b/analysis_options.yaml index 42239ef..3f6e284 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -28,3 +28,4 @@ analyzer: exclude: - ignore/** - ignore/ + - build/** diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml index 0bd999b..c16065a 100644 --- a/example/analysis_options.yaml +++ b/example/analysis_options.yaml @@ -1,3 +1,6 @@ +analyzer: + exclude: + - build/** include: package:flutter_lints/flutter.yaml linter: diff --git a/example/lib/main.dart b/example/lib/main.dart index 3402b0d..c8f7c0c 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -135,6 +135,35 @@ class MyHomePage extends StatelessWidget { ), ), ), + + // Example 2b: A CHANGELOG that cannot be reached. Before + // 1.1.0 this rendered exactly like Example 1 — blue, and + // silently wrong. It now says that nothing is known. + Card( + margin: EdgeInsets.all(8), + child: Padding( + padding: EdgeInsets.all(16), + child: Column( + children: [ + Text( + 'A changelog URL that 404s.\n' + 'Expect an amber version and no date.\n' + 'No Update button: no update is known of.\n' + 'Hover to see why the check did not complete.', + ), + SizedBox(height: 8), + VersionWidget( + version: '1.0.2', + changelogUrl: + 'https://raw.githubusercontent.com/anusii/version_widget/refs/heads/main/NO_SUCH_FILE.md', + showUpdateButton: true, + downloadUrl: + 'https://github.com/anusii/version_widget/releases/latest', + ), + ], + ), + ), + ), ], ), ), diff --git a/lib/src/models/version_status.dart b/lib/src/models/version_status.dart new file mode 100644 index 0000000..31d0500 --- /dev/null +++ b/lib/src/models/version_status.dart @@ -0,0 +1,111 @@ +/// The outcome of a version check. +/// +// Time-stamp: +/// +/// Copyright (C) 2024-2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Jess Moore + +library; + +import 'package:flutter/material.dart'; + +/// The outcome of checking the installed version against a CHANGELOG. +/// +/// The distinction that matters is between [current] and [unknown]. Before +/// version 1.1.0 a failed or unparsable check was reported as though the +/// installed version were the latest, so an app whose CHANGELOG had moved, +/// gone private, or changed format claimed to be up to date indefinitely. + +enum VersionStatus { + /// The CHANGELOG request is still in flight. + + checking, + + /// No changelog URL was configured, so no check was attempted. This is a + /// legitimate configuration, not a failure, and renders exactly as it did + /// before the status model was introduced. + + unchecked, + + /// The installed version matches or exceeds the newest CHANGELOG entry. + + current, + + /// The CHANGELOG advertises a release newer than the installed version. + + outdated, + + /// The check could not be completed: the transport failed, the response + /// was not usable, or the body held no parsable version entries. Nothing + /// is known about whether a newer release exists. + + unknown, +} + +/// How each [VersionStatus] presents itself in the version label. + +extension VersionStatusDisplay on VersionStatus { + /// The colour of the version label when the host supplies no text style. + /// + /// [unknownColour] is passed in rather than hard-coded so the host can + /// choose a shade that stays legible against its own background. + + Color colourWith(Color unknownColour) { + switch (this) { + case VersionStatus.checking: + return Colors.grey; + case VersionStatus.unchecked: + case VersionStatus.current: + return Colors.blue; + case VersionStatus.outdated: + return Colors.red; + case VersionStatus.unknown: + return unknownColour; + } + } + + /// The weight of the version label when the host supplies no text style. + /// + /// Only [VersionStatus.outdated] is bold. An unknown status is an absence + /// of information rather than an alarm, so it is not escalated to bold. + + FontWeight get weight => + this == VersionStatus.outdated ? FontWeight.bold : FontWeight.normal; + + /// Whether a release date may accompany the version for this status. + /// + /// A date is only meaningful once the CHANGELOG has actually been read. + + bool get showsDate => + this == VersionStatus.current || this == VersionStatus.outdated; + + /// Whether the discover-and-download button may be rendered. + /// + /// Only when a newer release is known to exist. Offering the button on an + /// [VersionStatus.unknown] check would assert an update we cannot see. + + bool get allowsUpdateButton => this == VersionStatus.outdated; +} diff --git a/lib/src/utils/fetch_changelog.dart b/lib/src/utils/fetch_changelog.dart new file mode 100644 index 0000000..936058d --- /dev/null +++ b/lib/src/utils/fetch_changelog.dart @@ -0,0 +1,94 @@ +/// Fetch the CHANGELOG that a version check reads. +/// +// Time-stamp: +/// +/// Copyright (C) 2024-2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Jess Moore + +library; + +import 'package:http/http.dart' as http; + +/// Supplies the raw CHANGELOG text for [url]. +/// +/// Supply one to read the CHANGELOG from somewhere the built-in +/// unauthenticated GET cannot reach — an authenticated backend, or an asset +/// bundled with the build. The [url] handed over is the configured changelog +/// URL after GitHub blob to raw normalisation, so a loader still gets that +/// rewrite for free and may ignore the argument entirely. +/// +/// Throw, or return an empty string, to report failure. The widget then +/// shows that the version could not be checked rather than claiming the app +/// is up to date. +/// +/// ```dart +/// // Bundled with the build. Cannot ever detect an update, since its +/// // CHANGELOG is frozen at build time, but it does populate the dialogue. +/// changelogLoader: (_) => rootBundle.loadString('assets/CHANGELOG.md'), +/// +/// // Authenticated, using a token the app already holds from the signed in +/// // session. Never a token compiled into the binary: a shipped app is +/// // readable by anyone who has it, web bundles most of all. +/// changelogLoader: (url) async { +/// final response = await http.get( +/// Uri.parse(url), +/// headers: {'Authorization': 'Bearer ${session.accessToken}'}, +/// ); +/// return response.body; +/// }, +/// ``` + +typedef ChangelogLoader = Future Function(String url); + +/// Converts GitHub blob URLs to raw content URLs. +/// +/// Necessary for CORS compatibility in web environments. Converts +/// `https://github.com/gjwgit/geopod/blob/dev/CHANGELOG.md` to +/// `https://raw.githubusercontent.com/gjwgit/geopod/dev/CHANGELOG.md`. + +String convertToRawUrl(String url) { + if (url.contains('github.com') && url.contains('/blob/')) { + return url + .replaceFirst('github.com', 'raw.githubusercontent.com') + .replaceFirst('/blob/', '/'); + } + return url; +} + +/// The default loader: a plain, unauthenticated GET. +/// +/// Throws on any non-200 response so the caller reports the check as failed +/// rather than parsing an error page for version strings. + +Future fetchChangelogOverHttp(String url) async { + final response = await http.get(Uri.parse(url)); + + if (response.statusCode != 200) { + throw Exception('Failed to load changelog: HTTP ${response.statusCode}'); + } + + return response.body; +} diff --git a/lib/src/utils/format_date.dart b/lib/src/utils/format_date.dart new file mode 100644 index 0000000..4198b93 --- /dev/null +++ b/lib/src/utils/format_date.dart @@ -0,0 +1,67 @@ +/// Format CHANGELOG dates for display. +/// +// Time-stamp: +/// +/// Copyright (C) 2024-2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Kevin Wang, Graham Williams, Jess Moore + +library; + +const Map _months = { + '01': 'Jan', + '02': 'Feb', + '03': 'Mar', + '04': 'Apr', + '05': 'May', + '06': 'Jun', + '07': 'Jul', + '08': 'Aug', + '09': 'Sep', + '10': 'Oct', + '11': 'Nov', + '12': 'Dec', +}; + +/// Renders a `YYYYMMDD` CHANGELOG date as `D Mmm YYYY`. +/// +/// Returns [dateStr] unchanged if it is not in the expected form, so a +/// surprising date is shown as written rather than swallowed. + +String formatChangelogDate(String dateStr) { + try { + final year = dateStr.substring(0, 4); + final month = dateStr.substring(4, 6); + String day = dateStr.substring(6, 8); + + // Remove leading zero for the day. (gjw 20250501) + + if (day.startsWith('0') && day.length > 1) day = day.substring(1); + + return '$day ${_months[month] ?? month} $year'; + } catch (e) { + return dateStr; + } +} diff --git a/lib/src/utils/parse_changelog.dart b/lib/src/utils/parse_changelog.dart index cf6b44d..11c4f0e 100644 --- a/lib/src/utils/parse_changelog.dart +++ b/lib/src/utils/parse_changelog.dart @@ -111,6 +111,15 @@ String? latestVersionOf(List entries) { return latest; } +/// Whether [version] can be meaningfully compared against another. +/// +/// Requires at least one digit. An app that does not know its own version +/// hands us an empty string — a misconfigured Info.plist will do it — and +/// comparing that against a real release would rank it below everything, +/// reporting a confident 'outdated' from no information at all. + +bool isComparableVersion(String version) => version.contains(RegExp(r'\d')); + /// The date recorded against [version], or null when it is not listed. /// /// An app running a version that predates the CHANGELOG, or a development diff --git a/lib/src/widgets/version_changelog_dialog.dart b/lib/src/widgets/version_changelog_dialog.dart new file mode 100644 index 0000000..7cf458f --- /dev/null +++ b/lib/src/widgets/version_changelog_dialog.dart @@ -0,0 +1,177 @@ +/// The in-app CHANGELOG dialogue. +/// +// Time-stamp: +/// +/// Copyright (C) 2024-2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Kevin Wang, Tony Chen, Jess Moore + +library; + +import 'package:flutter/material.dart'; + +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// Opens [url] in the platform's default handler, if it can be launched. + +Future _launch(Uri url) async { + if (await canLaunchUrl(url)) { + await launchUrl(url); + } +} + +/// The label for the button that opens the changelog in a browser. +/// +/// The changelog does not always live on GitHub — podmail, for one, serves +/// its own — so only claim GitHub when that is where it is going. + +String _openLabel(String changelogUrl) { + final host = Uri.tryParse(changelogUrl)?.host ?? ''; + + return host.contains('github') ? 'View on GitHub' : 'Open changelog'; +} + +/// Displays [content] as rendered markdown in a dialogue. +/// +/// Called when the user taps the version text. When [content] is empty — +/// the check failed, so there is nothing to show — a short notice is shown +/// in its place. [changelogUrl], when given, adds a button that opens the +/// changelog in a browser. + +void showChangelogDialog( + BuildContext context, { + required String content, + String? changelogUrl, +}) { + if (content.isEmpty) { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Changelog'), + content: const Text('Changelog content is not available.'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ); + }, + ); + + return; + } + + showDialog( + context: context, + builder: (BuildContext context) { + return Dialog( + child: Container( + constraints: BoxConstraints( + maxWidth: 800, + maxHeight: MediaQuery.of(context).size.height * 0.8, + ), + child: Column( + children: [ + // Title bar with close button. + + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).primaryColor, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(4), + topRight: Radius.circular(4), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Changelog', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: Colors.white, + ), + ), + IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => Navigator.of(context).pop(), + tooltip: 'Close', + ), + ], + ), + ), + + // Markdown content. + + Expanded( + child: Markdown( + data: content, + selectable: true, + onTapLink: (text, href, title) async { + if (href != null) await _launch(Uri.parse(href)); + }, + ), + ), + + // Bottom action bar. + + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + border: Border( + top: BorderSide( + color: Theme.of(context).dividerColor, + width: 1, + ), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (changelogUrl != null) + TextButton.icon( + icon: const Icon(Icons.open_in_new), + label: Text(_openLabel(changelogUrl)), + onPressed: () => _launch(Uri.parse(changelogUrl)), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); +} diff --git a/lib/src/widgets/version_widget.dart b/lib/src/widgets/version_widget.dart index 6d48c44..a39ffbd 100644 --- a/lib/src/widgets/version_widget.dart +++ b/lib/src/widgets/version_widget.dart @@ -1,6 +1,6 @@ /// Version widget for the app. /// -// Time-stamp: +// Time-stamp: /// /// Copyright (C) 2024-2026, Software Innovation Institute, ANU. /// @@ -24,49 +24,50 @@ // You should have received a copy of the GNU General Public License along with // this program. If not, see . /// -/// Authors: Kevin Wang, Tony Chen. +/// Authors: Kevin Wang, Tony Chen, Jess Moore import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:http/http.dart' as http; import 'package:markdown_tooltip/markdown_tooltip.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:version_widget/src/models/version_status.dart'; import 'package:version_widget/src/utils/compare_versions.dart'; +import 'package:version_widget/src/utils/fetch_changelog.dart'; +import 'package:version_widget/src/utils/format_date.dart'; +import 'package:version_widget/src/utils/parse_changelog.dart'; +import 'package:version_widget/src/widgets/version_changelog_dialog.dart'; -/// A widget that displays version information with optional changelog date and link. +/// A widget that displays version information with optional changelog date +/// and link. /// -/// This widget can be used to show the current version of an app, optionally -/// including the release date from a CHANGELOG file and providing a link to -/// view the full changelog. +/// Shows the current version of an app, optionally including the release +/// date read from a CHANGELOG, and offering a link to the full changelog. /// -/// The widget supports three modes of operation: +/// The widget reports one of five outcomes, and the distinction that matters +/// most is between being up to date and not knowing: /// -/// 1. Automatic mode: Fetches both version and date from a CHANGELOG.md file -/// 2. Semi-automatic mode: Uses provided version but fetches date from CHANGELOG -/// 3. Manual mode: Uses provided version and default date +/// 1. Still checking — grey. +/// 2. No [changelogUrl] configured, so nothing was checked — as before. +/// 3. Up to date — blue. +/// 4. A newer release exists — red, bold, with an optional update button. +/// 5. The check failed — amber, and it says so rather than claiming the app +/// is current. Set [assumeLatestOnCheckFailure] to restore the older, +/// quieter behaviour. /// /// Styling of the version string is offered in two modes: /// -/// 1. Automatic mode: when no [userTextStyle] is supplied, the version is -/// styled with colour denoting package status (blue: up to date, red: -/// newer version available, grey: version being checked). -/// 2. Custom mode: when a [userTextStyle] is supplied the host style is -/// used verbatim while the version is up to date or still being -/// checked. As soon as a newer release is detected the host style is -/// preserved for every other field (font family, size, letter -/// spacing, decoration, etc.) but `color` and `fontWeight` are -/// escalated to red and bold so the upgrade warning remains visible. -/// This is fully backward compatible: existing hosts keep their -/// chosen styling for the up-to-date case and only see the warning -/// palette appear when an update is genuinely available. -/// -/// When a newer version is detected and [showUpdateButton] is enabled, an -/// inline action button is rendered to the right of the version text. Tapping -/// the button launches [downloadUrl] in the default external handler so the -/// user can fetch the latest installer or release page. +/// 1. Automatic: when no [userTextStyle] is supplied, the version is styled +/// with a colour denoting status. +/// 2. Custom: when a [userTextStyle] is supplied the host style is used +/// verbatim while the version is up to date or still being checked. When +/// a newer release is detected the host style is preserved for every +/// other field (font family, size, letter spacing, decoration) but +/// `color` and `fontWeight` are escalated to red and bold so the upgrade +/// warning remains visible. A failed check likewise escalates `color` +/// alone, since a host style on a coloured background would otherwise +/// hide the fact that nothing is known. /// /// Example usage: /// ```dart @@ -74,11 +75,11 @@ import 'package:version_widget/src/utils/compare_versions.dart'; /// version: '1.0.5', /// changelogUrl: 'https://github.com/anusii/version_widget/raw/main/CHANGELOG.md', /// showDate: true, -/// defaultDate: '20240101', /// showUpdateButton: true, /// downloadUrl: 'https://example.com/downloads/myapp-latest.exe', /// ) /// ``` + class VersionWidget extends StatefulWidget { /// The version string to display (e.g., '1.0.0'). /// The version should follow semantic versioning (e.g., '0.0.9'). @@ -86,8 +87,9 @@ class VersionWidget extends StatefulWidget { final String version; /// The URL to the CHANGELOG.md file. - /// If provided, the widget will attempt to extract the release date and version from it. - /// The changelog should follow the format: [x.x.x YYYYMMDD] for version entries. + /// If provided, the widget will attempt to extract the release date and + /// version from it. Entries are recognised as `[x.x.x YYYYMMDD]`, with an + /// optional author either side of the date. final String? changelogUrl; @@ -105,11 +107,10 @@ class VersionWidget extends StatefulWidget { final bool showDate; - /// The default date to show if the changelog cannot be fetched. - /// Format should be 'YYYYMMDD'. - /// Defaults to '20250101'. - /// This is used as a fallback when the changelog is unavailable or invalid. + /// Unused. The date shown is always the one read from the CHANGELOG, and + /// no date is shown when the check does not produce one. + @Deprecated('Never read; will be removed in 2.0.0.') final String? defaultDate; /// Custom tooltip message to show when the version is the latest. @@ -122,47 +123,71 @@ class VersionWidget extends StatefulWidget { final String? notLatestTooltip; + /// Custom tooltip message to show when the check could not be completed. + /// If not provided, uses a default message naming the likely causes. + + final String? unknownTooltip; + + /// The colour of the version label when the check could not be completed. + /// Defaults to a muted amber. Applied both in the built-in palette and on + /// top of a supplied [userTextStyle], so choose a shade that stays legible + /// against the background the version sits on. + + final Color? unknownColor; + + /// Whether a failed or unparsable check should be reported as up to date. + /// Defaults to false, which is almost always what you want: a silent and + /// false 'up to date' leaves users on stale builds indefinitely. Provided + /// to restore the behaviour of releases before 1.1.0. + + final bool assumeLatestOnCheckFailure; + /// Allow the user to override the [fontSize] to suit the app. final double? fontSize; /// Allow the host to specify a custom [userTextStyle] that the version - /// label should adopt. The provided style is used verbatim while the - /// installed version is up to date or the changelog check is still in - /// flight. When the changelog reports a newer release available the - /// supplied style is preserved for every field except `color` and - /// `fontWeight`, which are escalated to red and bold so the upgrade - /// warning stays visible regardless of the host's theming choices. + /// label should adopt. See the class documentation for when the style is + /// used verbatim and when `color` and `fontWeight` are escalated. final TextStyle? userTextStyle; + /// Supplies the CHANGELOG text instead of the built-in HTTP GET. + /// Use for a changelog the default fetch cannot reach: one behind + /// authentication, or one bundled with the build. See [ChangelogLoader]. + + final ChangelogLoader? changelogLoader; + /// Whether to show the discover-and-download button when a newer version is /// detected. /// Defaults to false (hidden). /// The button is only rendered when all of the following are true: /// 1. [showUpdateButton] is true /// 2. A newer version has been detected from the CHANGELOG - /// 3. [downloadUrl] is non-null and non-empty - /// Tapping the button launches [downloadUrl] using the platform's default - /// external handler (typically the system browser) so the user can fetch - /// the latest release. + /// 3. Either [downloadUrl] or [onUpdatePressed] is supplied + /// It is deliberately not offered when the check failed, since no update is + /// known to exist. final bool showUpdateButton; /// The URL to launch when the user taps the discover-and-download button. /// Typically points at an installer (.exe, .apk, .dmg) or a release page. - /// Required for the update button to be rendered. final String? downloadUrl; + /// Called instead of launching [downloadUrl] when the update button is + /// tapped. Lets a web host reload in place rather than open an installer. + + final VoidCallback? onUpdatePressed; + /// Optional label shown next to the download icon on the update button. /// Defaults to 'Update' when null. final String? updateButtonLabel; /// Creates a new [VersionWidget]. - /// The [version] parameter is required and should be the current version of the app. - /// All other parameters are optional. + /// The [version] parameter is required and should be the current version of + /// the app. All other parameters are optional. const VersionWidget({ super.key, @@ -170,13 +195,19 @@ class VersionWidget extends StatefulWidget { this.changelogUrl, this.showVersion = true, this.showDate = true, + @Deprecated('Never read; will be removed in 2.0.0.') this.defaultDate = '20260101', this.isLatestTooltip, this.notLatestTooltip, + this.unknownTooltip, + this.unknownColor, + this.assumeLatestOnCheckFailure = false, this.fontSize = 16.0, this.userTextStyle, + this.changelogLoader, this.showUpdateButton = false, this.downloadUrl, + this.onUpdatePressed, this.updateButtonLabel, }); @@ -192,29 +223,24 @@ class VersionWidget extends StatefulWidget { /// - Handling user interactions class _VersionWidgetState extends State { - /// Indicates whether the current version is the latest version. - /// Used to determine the colour of the version text (blue for latest, red for outdated). + /// The outcome of the version check, driving colour, date and button. - bool _isLatest = true; + VersionStatus _status = VersionStatus.checking; - /// The latest version available from the changelog. - /// Used to compare with the current version to determine if an update is available. + /// The latest version available from the changelog. Empty until a check + /// succeeds, so it is never quoted at the user on a guess. String _latestVersion = ''; - /// The current release date in YYYYMMDD format. - /// Either fetched from the changelog or using the default date. + /// The release date of the current version, in YYYYMMDD format, when the + /// changelog lists one for it. String _currentDate = ''; - /// The current version string (e.g., '0.0.9'). - /// Either provided through the widget or extracted from the changelog. + /// The current version string (e.g., '0.0.9'), as supplied by the host. String _currentVersion = ''; - bool _isChecking = true; - bool _hasInternet = true; - /// The full CHANGELOG content for display in the dialogue. String _changelogContent = ''; @@ -224,303 +250,118 @@ class _VersionWidgetState extends State { super.initState(); _currentVersion = widget.version; - // We still want to check the changelog whenever the changelog URL is - // provided so that the update button can be surfaced even when the - // version date is intentionally hidden by the host app. + // Check whenever a changelog URL is provided, even when the date is + // hidden, so the update button can still be surfaced. - if (widget.showDate || widget.changelogUrl != null) { - _fetchChangelog(); + if (widget.changelogUrl != null) { + _checkVersion(); } else { - _isChecking = false; + _status = VersionStatus.unchecked; } } - /// Converts GitHub blob URLs to raw content URLs. - /// This is necessary for CORS compatibility in web environments. + /// Fetches and parses the changelog to determine the latest version. /// - /// Converts: - /// - https://github.com/gjwgit/geopod/blob/dev/CHANGELOG.md - /// to: - /// - https://raw.githubusercontent.com/gjwgit/geopod/dev/CHANGELOG.md - - String _convertToRawUrl(String url) { - if (url.contains('github.com') && url.contains('/blob/')) { - return url - .replaceFirst('github.com', 'raw.githubusercontent.com') - .replaceFirst('/blob/', '/'); - } - return url; - } + /// Every failure — transport, an unusable response, or a body with no + /// recognisable version entries — lands in [_reportCheckFailure]. That is + /// the point of this method: an incomplete check must not be reported as a + /// successful one. - String _formatDate(String dateStr) { - try { - final year = dateStr.substring(0, 4); - final month = dateStr.substring(4, 6); - String day = dateStr.substring(6, 8); - - // Remove leading zero for the day. (gjw 20250501) - - if (day.startsWith('0') && day.length > 1) day = day.substring(1); - - final months = { - '01': 'Jan', - '02': 'Feb', - '03': 'Mar', - '04': 'Apr', - '05': 'May', - '06': 'Jun', - '07': 'Jul', - '08': 'Aug', - '09': 'Sep', - '10': 'Oct', - '11': 'Nov', - '12': 'Dec', - }; - - return '$day ${months[month] ?? month} $year'; - } catch (e) { - return dateStr; - } - } + Future _checkVersion() async { + final url = convertToRawUrl(widget.changelogUrl!); - /// Displays the CHANGELOG content in a dialogue with markdown rendering. - /// This method is called when the user taps on the version text. - - void _showChangelogDialog(BuildContext context) { - if (_changelogContent.isEmpty) { - // Show a message if CHANGELOG content is not available. - - showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: const Text('Changelog'), - content: const Text('Changelog content is not available.'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Close'), - ), - ], - ); - }, - ); - return; + if (kIsWeb && url != widget.changelogUrl) { + debugPrint('Web platform detected: Converting URL from ' + '${widget.changelogUrl} to $url'); } - showDialog( - context: context, - builder: (BuildContext context) { - return Dialog( - child: Container( - constraints: BoxConstraints( - maxWidth: 800, - maxHeight: MediaQuery.of(context).size.height * 0.8, - ), - child: Column( - children: [ - // Title bar with close button. - - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Theme.of(context).primaryColor, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(4), - topRight: Radius.circular(4), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Changelog', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: Colors.white, - ), - ), - IconButton( - icon: const Icon(Icons.close, color: Colors.white), - onPressed: () => Navigator.of(context).pop(), - tooltip: 'Close', - ), - ], - ), - ), - - // Markdown content. - - Expanded( - child: Markdown( - data: _changelogContent, - selectable: true, - onTapLink: (text, href, title) async { - if (href != null) { - final Uri url = Uri.parse(href); - if (await canLaunchUrl(url)) { - await launchUrl(url); - } - } - }, - ), - ), - - // Bottom action bar. - - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Theme.of(context).cardColor, - border: Border( - top: BorderSide( - color: Theme.of(context).dividerColor, - width: 1, - ), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (widget.changelogUrl != null) - TextButton.icon( - icon: const Icon(Icons.open_in_new), - label: const Text('View on GitHub'), - onPressed: () async { - final Uri url = Uri.parse(widget.changelogUrl!); - if (await canLaunchUrl(url)) { - await launchUrl(url); - } - }, - ), - const SizedBox(width: 8), - FilledButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Close'), - ), - ], - ), - ), - ], - ), - ), - ); - }, - ); - } + try { + // An app that does not know its own version cannot be compared + // against one that does. Saying so beats ranking it below every + // release and telling the user to update. - /// Fetches and parses the changelog file to extract version and date information. - /// The method handles several scenarios: - /// 1. No changelog URL provided: Uses default values - /// 2. Changelog fetch successful: Extracts version and date - /// 3. Changelog fetch failed: Falls back to default values - /// - /// For web environments, this method automatically converts GitHub blob URLs - /// to raw.githubusercontent.com URLs to avoid CORS issues. - - Future _fetchChangelog() async { - if (widget.changelogUrl == null) { - if (mounted) { - setState(() { - _currentDate = ''; - _latestVersion = _currentVersion; - _isLatest = true; - _isChecking = false; - }); + if (!isComparableVersion(_currentVersion)) { + throw Exception('The app reported no usable version: ' + '"$_currentVersion"'); } - return; - } - - try { - // Convert GitHub blob URLs to raw URLs for CORS compatibility. - final url = _convertToRawUrl(widget.changelogUrl!); + final content = await _load(url); - if (kIsWeb && url != widget.changelogUrl) { - debugPrint( - 'Web platform detected: Converting URL from ${widget.changelogUrl} ' - 'to $url'); - } + if (content.isEmpty) throw Exception('The changelog was empty'); - final response = await http.get(Uri.parse(url)); + final entries = parseChangelogEntries(content); + final latest = latestVersionOf(entries); - if (response.statusCode != 200) { - throw Exception('Failed to load changelog: ' - 'HTTP ${response.statusCode}'); + if (latest == null) { + throw Exception('No `[version date]` entries found in the changelog'); } - final content = response.body; + if (!mounted) return; - // Store the full CHANGELOG content for display in dialogue. + setState(() { + _changelogContent = content; + _latestVersion = latest; + _currentDate = dateForVersion(entries, _currentVersion) ?? ''; + _status = compareVersions(_currentVersion, latest) >= 0 + ? VersionStatus.current + : VersionStatus.outdated; + }); + } catch (e) { + _reportCheckFailure(e); + } + } - _changelogContent = content; + /// Loads the changelog, retrying once after a short pause. + /// + /// Only transport failures are retried. A response that arrives but cannot + /// be parsed will not be helped by asking again, and is not retried. - // Extract all version and date pairs from CHANGELOG.md. + Future _load(String url) async { + final loader = widget.changelogLoader ?? fetchChangelogOverHttp; - final matches = RegExp(r'\[([\d.]+) (\d{8})').allMatches(content); + try { + return await loader(url); + } catch (e) { + debugPrint('Changelog fetch failed, retrying once in 2s: $e'); + await Future.delayed(const Duration(seconds: 2)); - if (matches.isNotEmpty) { - // First match is the latest version. + return loader(url); + } + } - final latestMatch = matches.first; - _latestVersion = latestMatch.group(1)!; + /// Records that nothing is known about the latest version. - // Find the date for the current version. + void _reportCheckFailure(Object error) { + debugPrint('version_widget: could not check the latest version: $error'); - String? currentVersionDate; - for (final match in matches) { - if (match.group(1) == _currentVersion) { - currentVersionDate = match.group(2); - break; - } - } + if (kIsWeb) { + debugPrint('On web the changelog must be served with CORS headers that ' + 'permit this origin, or from the same origin as the app. For ' + 'GitHub files use raw.githubusercontent.com.'); + } - if (mounted) { - setState(() { - // Don't use default date if version not found. + if (!mounted) return; - _currentDate = currentVersionDate ?? ''; - _isLatest = compareVersions(_currentVersion, _latestVersion) >= 0; - _isChecking = false; - _hasInternet = true; - }); - } - } else { - if (mounted) { - setState(() { - _currentDate = ''; - _latestVersion = _currentVersion; - _isLatest = true; - _isChecking = false; - _hasInternet = true; - }); - } - } - } catch (e) { - if (kIsWeb) { - debugPrint('Error fetching changelog on web platform: $e'); - debugPrint('Make sure the CHANGELOG URL uses ' - 'raw.githubusercontent.com for GitHub files'); - debugPrint('Original URL: ${widget.changelogUrl}'); - debugPrint('Converted URL: ${_convertToRawUrl(widget.changelogUrl!)}'); - } else { - debugPrint('Error fetching changelog: $e'); - } - if (mounted) { - setState(() { - _currentDate = ''; - _latestVersion = _currentVersion; - _isLatest = true; - _isChecking = false; - _hasInternet = false; - }); - } - } + setState(() { + _currentDate = ''; + _latestVersion = ''; + _status = widget.assumeLatestOnCheckFailure + ? VersionStatus.current + : VersionStatus.unknown; + }); } - /// Launches the configured [VersionWidget.downloadUrl] in the default - /// external handler so the user can fetch the new release. + /// Launches [VersionWidget.downloadUrl], or defers to the host's handler. + + Future _handleUpdatePressed() async { + final onPressed = widget.onUpdatePressed; + + if (onPressed != null) { + onPressed(); + + return; + } - Future _launchDownload() async { final downloadUrl = widget.downloadUrl; if (downloadUrl == null || downloadUrl.isEmpty) return; @@ -533,66 +374,90 @@ class _VersionWidgetState extends State { } /// The [TextStyle] applied to the version label. - /// - /// Selected from three cases, in order: - /// - /// 1. When [VersionWidget.userTextStyle] is null, the built-in palette - /// is used: grey while still checking, blue when the installed - /// version matches the CHANGELOG, and red plus bold when a newer - /// release has been detected. - /// 2. When [VersionWidget.userTextStyle] is provided and the installed - /// version is up to date (or the check has not yet completed) the - /// host-supplied style is used verbatim, so the version label - /// integrates with the surrounding theme. - /// 3. When [VersionWidget.userTextStyle] is provided and a newer - /// release has been detected, the host-supplied style is preserved - /// for every field except `color` and `fontWeight`, which are - /// set to red and bold respectively so the upgrade warning remains - /// visible. TextStyle _versionLabelStyle() { - final autoColour = - _isChecking ? Colors.grey : (_isLatest ? Colors.blue : Colors.red); - final autoWeight = - (_isChecking || _isLatest) ? FontWeight.normal : FontWeight.bold; - + final unknownColour = widget.unknownColor ?? Colors.orange.shade800; final userStyle = widget.userTextStyle; + if (userStyle == null) { return TextStyle( - color: autoColour, + color: _status.colourWith(unknownColour), fontSize: widget.fontSize, - fontWeight: autoWeight, + fontWeight: _status.weight, ); } - final isOutdated = !_isChecking && !_isLatest; - if (isOutdated) { - // Outdated: escalate to the warning palette while preserving every - // other style field provided by the host (font family, size, - // letter spacing, decoration, etc.). + // Outdated escalates colour and weight; an unresolved check escalates + // colour alone. Everything else keeps the host's style untouched. - return userStyle.copyWith( - color: Colors.red, - fontWeight: FontWeight.bold, - ); + switch (_status) { + case VersionStatus.outdated: + return userStyle.copyWith( + color: Colors.red, + fontWeight: FontWeight.bold, + ); + case VersionStatus.unknown: + return userStyle.copyWith(color: unknownColour); + case VersionStatus.checking: + case VersionStatus.unchecked: + case VersionStatus.current: + return userStyle; } + } + + /// The markdown tooltip describing the current status. + + String _tooltipMessage() { + const closing = '**Tap** on the **Version** string to view the ' + "app's CHANGELOG."; - // Up to date or still checking: hand back the host's style verbatim - // for full visual parity with the previous behaviour. + if (_status == VersionStatus.unknown) { + const defaultUnknown = 'The CHANGELOG could not be checked, so it is ' + 'not known whether a newer version is available. Check your ' + 'network connection, or the changelog location this app is ' + 'configured with.'; - return userStyle; + return ''' + + **Version $_currentVersion** + + ${widget.unknownTooltip ?? defaultUnknown} $closing + + '''; + } + + const defaultLatest = 'this is the latest version available.'; + + final defaultNotLatest = 'there is a new version available ' + '$_latestVersion. You should consider ' + 'updating to the latest version.'; + + final body = _status == VersionStatus.outdated + ? widget.notLatestTooltip ?? defaultNotLatest + : widget.isLatestTooltip ?? defaultLatest; + + return ''' + + **Version $_currentVersion** + + According to the CHANGELOG from the app + repository $body $closing + + '''; } /// Builds the inline discover-and-download action button surfaced when a /// newer release is detected. Returns null when the button should not be /// rendered for the current state. - Widget? _buildUpdateButton(BuildContext context) { + Widget? _buildUpdateButton() { final downloadUrl = widget.downloadUrl; + final hasTarget = widget.onUpdatePressed != null || + (downloadUrl != null && downloadUrl.isNotEmpty); + if (!widget.showUpdateButton) return null; - if (_isChecking) return null; - if (_isLatest) return null; - if (downloadUrl == null || downloadUrl.isEmpty) return null; + if (!_status.allowsUpdateButton) return null; + if (!hasTarget) return null; final label = widget.updateButtonLabel ?? 'Update'; final tooltipMessage = ''' @@ -612,7 +477,7 @@ class _VersionWidgetState extends State { child: Material( color: Colors.transparent, child: InkWell( - onTap: _launchDownload, + onTap: _handleUpdatePressed, borderRadius: BorderRadius.circular(16), child: Container( padding: const EdgeInsets.symmetric( @@ -652,38 +517,27 @@ class _VersionWidgetState extends State { @override Widget build(BuildContext context) { - final displayText = _isChecking - ? 'Version $_currentVersion' - : widget.showDate && _hasInternet && _currentDate.isNotEmpty - ? 'Version $_currentVersion - ${_formatDate(_currentDate)}' - : 'Version $_currentVersion'; + final showDate = + widget.showDate && _status.showsDate && _currentDate.isNotEmpty; - const defaultLatestTooltip = 'this is the latest version available.'; - - final defaultNotLatestTooltip = 'there is a new version available ' - '$_latestVersion. You should consider ' - 'updating to the latest version.'; - - final tooltipMessage = ''' - - **Version $_currentVersion** - - According to the CHANGELOG from the app - repository ${_isLatest ? widget.isLatestTooltip ?? defaultLatestTooltip : widget.notLatestTooltip ?? defaultNotLatestTooltip} **Tap** on the - **Version** string to view the app's CHANGELOG. - - '''; + final displayText = showDate + ? 'Version $_currentVersion - ${formatChangelogDate(_currentDate)}' + : 'Version $_currentVersion'; final versionLabel = GestureDetector( onTap: widget.changelogUrl == null ? null - : () => _showChangelogDialog(context), + : () => showChangelogDialog( + context, + content: _changelogContent, + changelogUrl: widget.changelogUrl, + ), child: MouseRegion( cursor: widget.changelogUrl == null ? SystemMouseCursors.basic : SystemMouseCursors.click, child: MarkdownTooltip( - message: tooltipMessage, + message: _tooltipMessage(), child: Text( displayText, style: _versionLabelStyle(), @@ -692,7 +546,7 @@ class _VersionWidgetState extends State { ), ); - final updateButton = _buildUpdateButton(context); + final updateButton = _buildUpdateButton(); // Short-circuit when neither the version label nor the update button is // visible to keep the widget completely transparent in the host layout. diff --git a/lib/version_widget.dart b/lib/version_widget.dart index 275949b..17227fc 100644 --- a/lib/version_widget.dart +++ b/lib/version_widget.dart @@ -28,4 +28,5 @@ library; +export 'src/utils/fetch_changelog.dart' show ChangelogLoader; export 'src/widgets/version_widget.dart' show VersionWidget; diff --git a/pubspec.yaml b/pubspec.yaml index 3778ddf..efb849b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: version_widget description: A Flutter widget that displays version information with optional changelog date and link. -version: 1.0.10 +version: 1.1.0 repository: https://github.com/anusii/version_widget homepage: https://github.com/anusii/version_widget @@ -18,6 +18,8 @@ dependencies: dev_dependencies: flutter_lints: ^2.0.0 + flutter_test: + sdk: flutter flutter: uses-material-design: true diff --git a/test/compare_versions_test.dart b/test/compare_versions_test.dart new file mode 100644 index 0000000..11b9587 --- /dev/null +++ b/test/compare_versions_test.dart @@ -0,0 +1,50 @@ +/// Tests for the version comparator. + +library; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:version_widget/src/utils/compare_versions.dart'; + +void main() { + group('compareVersions', () { + test('reports equal versions as equal', () { + expect(compareVersions('1.0.0', '1.0.0'), 0); + }); + + test('compares segments numerically, not lexically', () { + // The trap: as strings, '1.0.10' sorts before '1.0.9'. This is + // exactly the range podmail's versions live in. + + expect(compareVersions('1.0.10', '1.0.9'), greaterThan(0)); + expect(compareVersions('1.0.9', '1.0.10'), lessThan(0)); + expect(compareVersions('0.1.13', '0.1.9'), greaterThan(0)); + }); + + test('treats a missing trailing segment as zero', () { + expect(compareVersions('1.0', '1.0.0'), 0); + expect(compareVersions('1.0', '1.0.1'), lessThan(0)); + expect(compareVersions('1.0.1', '1.0'), greaterThan(0)); + }); + + test('orders by the most significant differing segment', () { + expect(compareVersions('2.0.0', '1.9.9'), greaterThan(0)); + expect(compareVersions('1.2.0', '1.10.0'), lessThan(0)); + }); + + test('is deliberately not semver aware', () { + // Build metadata and pre-release suffixes parse as zero rather than + // being interpreted. Pinned so a future change to make the + // comparator semver aware is a deliberate one. + + expect(compareVersions('1.0.0+1', '1.0.0'), 0); + expect(compareVersions('1.0.0-beta', '1.0.0'), 0); + }); + + test('handles empty and malformed input without throwing', () { + expect(compareVersions('', '0'), 0); + expect(compareVersions('1.02', '1.2'), 0); + expect(compareVersions('abc', '0.0.0'), 0); + }); + }); +} diff --git a/test/fixtures/changelogs.dart b/test/fixtures/changelogs.dart new file mode 100644 index 0000000..e2a8d37 --- /dev/null +++ b/test/fixtures/changelogs.dart @@ -0,0 +1,62 @@ +/// CHANGELOG fixtures shared by the tests. + +library; + +/// The convention documented across our apps: `[version date author]`. + +const String canonicalChangelog = ''' +# Test App Changelog + +Guide: The `[version timestamp user]` string is utilised by the flutter +version_widget package. + +## 1.1 Review and Consolidate + ++ Restore version string colours for status [1.0.10 20260512 tonypioneer] ++ Add an UPDATE button [1.0.9 20260510 tonypioneer] ++ Better tooltip formatting [1.0.8 20260429 gjw] +'''; + +/// Author before the date, as podmail writes it. Every entry here is +/// invisible to the pattern used before version 1.1.0. + +const String authorFirstChangelog = ''' +# Podmail Change Log + +## 0.1 Initial concept + ++ Add sent by podmail signature [0.1.13 jesscmoore 20260908] ++ Fix send and receive in web app [0.1.12 anushkavidanage 20260907] ++ Configure for podmail.me hosting [0.1.11 jesscmoore 20260907] +'''; + +/// Both orderings in the one file, plus an entry with no author at all. + +const String mixedChangelog = ''' ++ Author last [2.0.1 20260601 gjw] ++ Author first [2.0.0 jesscmoore 20260530] ++ No author [1.9.9 20260501] +'''; + +/// Entries that look plausible but carry no usable date. Parsing this is +/// the failure podmail hit on every launch: content arrives, nothing in it +/// can be read, and the app must not conclude it is up to date. + +const String unparsableChangelog = ''' +# Change Log + ++ Missing the date entirely [1.0.0 jesscmoore] ++ Date too short [1.0.1 2026051] ++ Date too long [1.0.2 202605123] ++ No version at all [nightly 20260512] +'''; + +/// The rendered HTML a Hugo site serves, rather than raw markdown. + +const String htmlChangelog = ''' +

Change Log

+
    +
  • Configure for podmail.me hosting [0.1.11 jesscmoore 20260907]
  • +
  • Support email attachments [0.1.9 anushkavidanage 20260904]
  • +
+'''; diff --git a/test/parse_changelog_test.dart b/test/parse_changelog_test.dart index 55c3351..fe24830 100644 --- a/test/parse_changelog_test.dart +++ b/test/parse_changelog_test.dart @@ -133,6 +133,24 @@ void main() { }); }); + group('isComparableVersion', () { + test('accepts anything carrying a digit', () { + expect(isComparableVersion('0.1.14'), isTrue); + expect(isComparableVersion('1'), isTrue); + expect(isComparableVersion('2.0.0-beta'), isTrue); + }); + + test('rejects a version the app could not supply', () { + // An empty string is what package_info_plus returns when the build + // carries no CFBundleShortVersionString. Comparing it would rank the + // app below every release. + + expect(isComparableVersion(''), isFalse); + expect(isComparableVersion(' '), isFalse); + expect(isComparableVersion('unknown'), isFalse); + }); + }); + group('dateForVersion', () { final entries = parseChangelogEntries(canonicalChangelog); diff --git a/test/version_widget_status_test.dart b/test/version_widget_status_test.dart new file mode 100644 index 0000000..5076f96 --- /dev/null +++ b/test/version_widget_status_test.dart @@ -0,0 +1,412 @@ +/// Tests for how VersionWidget reports each check outcome. +/// +/// Every case injects a changelogLoader, so nothing here touches the +/// network and each outcome — including the failures — is reachable. + +library; + +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:version_widget/version_widget.dart'; + +import 'fixtures/changelogs.dart'; + +const String _url = 'https://example.com/CHANGELOG.md'; + +/// A loader that always succeeds with [content]. + +ChangelogLoader _serving(String content) => (_) async => content; + +/// A loader that always fails. + +Future _failing(String url) async => throw Exception('offline'); + +/// A minimal host for the widget under test. + +Widget _host(Widget child) => MaterialApp( + home: Scaffold(body: Center(child: child)), + ); + +/// Advances past the loader's single retry pause so a failing check +/// settles. Only the transport is retried, so this matters for a throwing +/// loader rather than for unparsable content. + +Future _settle(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(seconds: 3)); + await tester.pump(); +} + +/// The resolved style of the rendered version label. + +TextStyle _styleOf(WidgetTester tester, String text) => + tester.widget(find.text(text)).style!; + +/// The update button, identified by its icon. + +Finder get _updateButton => find.byIcon(Icons.system_update_alt); + +void main() { + group('a successful check', () { + testWidgets('shows blue with the release date when up to date', + (tester) async { + await tester.pumpWidget( + _host( + VersionWidget( + version: '1.0.10', + changelogUrl: _url, + showUpdateButton: true, + downloadUrl: 'https://example.com/install', + changelogLoader: _serving(canonicalChangelog), + ), + ), + ); + await _settle(tester); + + const text = 'Version 1.0.10 - 12 May 2026'; + expect(find.text(text), findsOneWidget); + expect(_styleOf(tester, text).color, Colors.blue); + expect(_updateButton, findsNothing); + }); + + testWidgets('shows red, bold and an update button when outdated', + (tester) async { + await tester.pumpWidget( + _host( + VersionWidget( + version: '1.0.8', + changelogUrl: _url, + showUpdateButton: true, + downloadUrl: 'https://example.com/install', + changelogLoader: _serving(canonicalChangelog), + ), + ), + ); + await _settle(tester); + + const text = 'Version 1.0.8 - 29 Apr 2026'; + expect(_styleOf(tester, text).color, Colors.red); + expect(_styleOf(tester, text).fontWeight, FontWeight.bold); + expect(_updateButton, findsOneWidget); + }); + + testWidgets('reads a changelog written author first', (tester) async { + // Podmail end to end: the app is current, and says so with a date. + + await tester.pumpWidget( + _host( + VersionWidget( + version: '0.1.13', + changelogUrl: _url, + changelogLoader: _serving(authorFirstChangelog), + ), + ), + ); + await _settle(tester); + + const text = 'Version 0.1.13 - 8 Sep 2026'; + expect(find.text(text), findsOneWidget); + expect(_styleOf(tester, text).color, Colors.blue); + }); + }); + + group('a failed check', () { + testWidgets('reports unknown when the loader throws', (tester) async { + await tester.pumpWidget( + _host( + const VersionWidget( + version: '1.0.0', + changelogUrl: _url, + showUpdateButton: true, + downloadUrl: 'https://example.com/install', + changelogLoader: _failing, + ), + ), + ); + await _settle(tester); + + expect(_styleOf(tester, 'Version 1.0.0').color, isNot(Colors.blue)); + expect(_styleOf(tester, 'Version 1.0.0').color, Colors.orange.shade800); + + // No update is known to exist, so none is offered. + + expect(_updateButton, findsNothing); + }); + + testWidgets('reports unknown when nothing in the body can be read', + (tester) async { + // The podmail failure exactly: the fetch succeeds, the content is + // unreadable, and the old code called that up to date. + + await tester.pumpWidget( + _host( + VersionWidget( + version: '1.0.0', + changelogUrl: _url, + changelogLoader: _serving(unparsableChangelog), + ), + ), + ); + await _settle(tester); + + expect(_styleOf(tester, 'Version 1.0.0').color, Colors.orange.shade800); + }); + + testWidgets('reports unknown on an empty body', (tester) async { + await tester.pumpWidget( + _host( + VersionWidget( + version: '1.0.0', + changelogUrl: _url, + changelogLoader: _serving(''), + ), + ), + ); + await _settle(tester); + + expect(_styleOf(tester, 'Version 1.0.0').color, Colors.orange.shade800); + }); + + testWidgets('reports unknown when the app has no version of its own', + (tester) async { + // A misconfigured Info.plist leaves package_info_plus returning ''. + // compareVersions('', '1.0.10') is negative, so before this guard + // the widget announced 'outdated' and offered an update button on + // the strength of no information at all. + + await tester.pumpWidget( + _host( + VersionWidget( + version: '', + changelogUrl: _url, + showUpdateButton: true, + downloadUrl: 'https://example.com/install', + changelogLoader: _serving(canonicalChangelog), + ), + ), + ); + await _settle(tester); + + expect(_styleOf(tester, 'Version ').color, Colors.orange.shade800); + expect(_styleOf(tester, 'Version ').fontWeight, isNot(FontWeight.bold)); + expect(_updateButton, findsNothing); + }); + + testWidgets('honours a supplied unknownColor', (tester) async { + await tester.pumpWidget( + _host( + VersionWidget( + version: '1.0.0', + changelogUrl: _url, + unknownColor: Colors.purple, + changelogLoader: _serving(unparsableChangelog), + ), + ), + ); + await _settle(tester); + + expect(_styleOf(tester, 'Version 1.0.0').color, Colors.purple); + }); + + testWidgets('restores the pre 1.1.0 behaviour when asked', (tester) async { + // The opt out. Pinned so the old, quiet behaviour stays available. + + await tester.pumpWidget( + _host( + const VersionWidget( + version: '1.0.0', + changelogUrl: _url, + assumeLatestOnCheckFailure: true, + showUpdateButton: true, + downloadUrl: 'https://example.com/install', + changelogLoader: _failing, + ), + ), + ); + await _settle(tester); + + expect(_styleOf(tester, 'Version 1.0.0').color, Colors.blue); + expect(_updateButton, findsNothing); + }); + }); + + group('checking and unchecked', () { + testWidgets('is grey while the check is in flight', (tester) async { + final gate = Completer(); + + await tester.pumpWidget( + _host( + VersionWidget( + version: '1.0.0', + changelogUrl: _url, + changelogLoader: (_) => gate.future, + ), + ), + ); + await tester.pump(); + + expect(_styleOf(tester, 'Version 1.0.0').color, Colors.grey); + + gate.complete(canonicalChangelog); + await _settle(tester); + + expect(_styleOf(tester, 'Version 1.0.0').color, Colors.red); + }); + + testWidgets('never loads when no changelog URL is configured', + (tester) async { + var loaderCalls = 0; + + await tester.pumpWidget( + _host( + VersionWidget( + version: '1.0.0', + changelogLoader: (_) async { + loaderCalls++; + + return canonicalChangelog; + }, + ), + ), + ); + await _settle(tester); + + expect(loaderCalls, 0); + + // Unchanged from before the status model existed: plain blue. + + expect(_styleOf(tester, 'Version 1.0.0').color, Colors.blue); + }); + }); + + group('a host supplied text style', () { + const hostStyle = TextStyle( + color: Colors.white, + fontFamily: 'Courier', + letterSpacing: 2.0, + ); + + testWidgets('is used verbatim when up to date', (tester) async { + await tester.pumpWidget( + _host( + VersionWidget( + version: '1.0.10', + changelogUrl: _url, + userTextStyle: hostStyle, + changelogLoader: _serving(canonicalChangelog), + ), + ), + ); + await _settle(tester); + + expect(_styleOf(tester, 'Version 1.0.10 - 12 May 2026'), hostStyle); + }); + + testWidgets('escalates colour and weight when outdated', (tester) async { + await tester.pumpWidget( + _host( + VersionWidget( + version: '1.0.8', + changelogUrl: _url, + userTextStyle: hostStyle, + changelogLoader: _serving(canonicalChangelog), + ), + ), + ); + await _settle(tester); + + final style = _styleOf(tester, 'Version 1.0.8 - 29 Apr 2026'); + expect(style.color, Colors.red); + expect(style.fontWeight, FontWeight.bold); + + // Everything else the host chose survives. + + expect(style.fontFamily, 'Courier'); + expect(style.letterSpacing, 2.0); + }); + + testWidgets('escalates colour but not weight when unknown', (tester) async { + await tester.pumpWidget( + _host( + VersionWidget( + version: '1.0.0', + changelogUrl: _url, + userTextStyle: hostStyle, + changelogLoader: _serving(unparsableChangelog), + ), + ), + ); + await _settle(tester); + + final style = _styleOf(tester, 'Version 1.0.0'); + expect(style.color, Colors.orange.shade800); + expect(style.fontWeight, isNot(FontWeight.bold)); + expect(style.fontFamily, 'Courier'); + }); + }); + + group('the update button', () { + testWidgets('defers to onUpdatePressed when supplied', (tester) async { + var pressed = 0; + + await tester.pumpWidget( + _host( + VersionWidget( + version: '1.0.8', + changelogUrl: _url, + showUpdateButton: true, + onUpdatePressed: () => pressed++, + changelogLoader: _serving(canonicalChangelog), + ), + ), + ); + await _settle(tester); + + // Rendered without a downloadUrl, because a handler is target enough. + + expect(_updateButton, findsOneWidget); + + await tester.tap(_updateButton); + await tester.pump(); + + expect(pressed, 1); + }); + + testWidgets('is not rendered without a target', (tester) async { + await tester.pumpWidget( + _host( + VersionWidget( + version: '1.0.8', + changelogUrl: _url, + showUpdateButton: true, + changelogLoader: _serving(canonicalChangelog), + ), + ), + ); + await _settle(tester); + + expect(_updateButton, findsNothing); + }); + }); + + testWidgets('collapses when the version is hidden and no button shows', + (tester) async { + await tester.pumpWidget( + _host( + VersionWidget( + version: '1.0.10', + changelogUrl: _url, + showVersion: false, + changelogLoader: _serving(canonicalChangelog), + ), + ), + ); + await _settle(tester); + + expect(find.textContaining('Version'), findsNothing); + }); +} From 3e83c115cc627e7372536d254f2900a2326a5cbf Mon Sep 17 00:00:00 2001 From: Jess Moore Date: Wed, 9 Sep 2026 15:43:04 +1000 Subject: [PATCH 3/9] improve clarity of README --- README.md | 39 ++++++++++++--------------------------- 1 file changed, 12 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index ca61333..34cc7e5 100644 --- a/README.md +++ b/README.md @@ -109,22 +109,14 @@ VersionWidget( - Grey text: Version is being checked - Blue text: Version is up to date - Red bold text: Newer version is available -- Amber text: The version could not be checked - -The amber state matters. Before 1.1.0 a check that failed — a moved or -private CHANGELOG, a CORS block, a file the widget could not parse — was -reported as though the app were up to date, so an app could claim to be -current indefinitely while never once succeeding at the check. A failed -check now says so, and does not offer an update button, since no update -is known to exist. Pass `assumeLatestOnCheckFailure: true` to restore +- Amber text: The version could not be checked (eg unpublished changelog, CORS block to changelog, app with no version) + +A failed +check is reported and does not offer an app update button. Pass `assumeLatestOnCheckFailure: true` to restore the older, quieter behaviour. -The same applies when the app cannot report its own version — an empty -or non-numeric `version`, which on Apple platforms usually means the -build carries no `CFBundleShortVersionString`. That compares as older -than every release, so without the guard the widget would announce an -update on the strength of no information at all. It reports the version -as unknown instead. +Apps that do not report their own version — an empty +or non-numeric `version` are also now reported as a failed check. ## CHANGELOG.md Format @@ -147,22 +139,17 @@ changelog. ## Private repositories -The widget fetches the CHANGELOG with a plain, unauthenticated GET, so -the file must be reachable without credentials. The repository being -private is not itself a problem — publishing the CHANGELOG somewhere -public is usually the simplest answer, and for a web app, serving it -from the same origin as the app avoids CORS entirely: +The CHANGELOG must be published, as the widget fetches the CHANGELOG with a plain, unauthenticated GET. + +Developers with private app repositories are recommended to publish their changelog to the same origin as the web app, to avoid CORS block issues. Ie build your web app and then add publish CHANGELOG file. ```make flutter build web --release cp CHANGELOG.md build/web/CHANGELOG.md # after the build, not in web/ ``` -Copy it after the build rather than committing it to `web/`. That keeps -one source of truth, and keeps the file out of anything Flutter -generates from `web/` — older Flutter versions pre-cached everything -there into a service worker, which would have served users a cached -copy of the changelog they already had. +Copy it after the build keeps the file out of anything Flutter +generates from `web/` to prevent caching issues. When the changelog genuinely cannot be made public, supply a `changelogLoader` and fetch it yourself: @@ -193,9 +180,7 @@ VersionWidget( ) ``` -Never compile a long lived credential such as a GitHub personal access -token into the app to do this. A shipped binary is readable by anyone -who has it, and a web build most of all. Use a token the user's own +Never compile a long lived credential. Use a token the user's own session already provides, or make the changelog public. ## Properties From d53b2a3d1c1bfa14b8bc857c61fcd8ab34215556 Mon Sep 17 00:00:00 2001 From: Jess Moore Date: Wed, 9 Sep 2026 15:50:11 +1000 Subject: [PATCH 4/9] format fix --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 34cc7e5..4c58348 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ session already provides, or make the changelog public. is still consulted so the optional update button can still appear. - `showDate` (optional): Whether to show the release date (defaults to true) - `defaultDate` (optional): Default date to show if changelog cannot - be fetched (format: YYYYMMDD) + be fetched (format: `YYYYMMDD`) - `isLatestTooltip` (optional): Custom message to show when version is latest - `notLatestTooltip` (optional): Custom message to show when newer version is available - `unknownTooltip` (optional): Custom message to show when the check could From 86a726530380bce8eefb5b7baf59370cf4e022e0 Mon Sep 17 00:00:00 2001 From: Jess Moore Date: Wed, 9 Sep 2026 15:51:43 +1000 Subject: [PATCH 5/9] linted --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 4c58348..f0e280f 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,7 @@ In `Runner/Info.plist` within `macos` and `ios` folders, set: $(FLUTTER_BUILD_NAME) ``` If using `xcodegen` to generate XCode files, your `macos` and `ios` `project.yml` files must contain: + ```yml MARKETING_VERSION: '$(FLUTTER_BUILD_NAME)' CURRENT_PROJECT_VERSION: '$(FLUTTER_BUILD_NUMBER)' From 3ac49e018724f8bc059b94eff21f6d04f0cd55af Mon Sep 17 00:00:00 2001 From: Jess Moore Date: Wed, 9 Sep 2026 16:01:22 +1000 Subject: [PATCH 6/9] add template changelog url to ignore links --- .lycheeignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.lycheeignore b/.lycheeignore index 1111b79..1e2ad9e 100644 --- a/.lycheeignore +++ b/.lycheeignore @@ -50,3 +50,7 @@ https://opensource.org/license/gpl-3-0 http://xmlns.com/foaf/0.1/ http://purl.org/dc/terms/ https://solidcommunity.au/predicates/* + +# Example app changelog + +https://raw.githubusercontent.com/anusii/version_widget/refs/heads/main/NO_SUCH_FILE.md From 6140e22b9537a42a05553a486b4018a28534523b Mon Sep 17 00:00:00 2001 From: Jess Moore Date: Wed, 9 Sep 2026 16:01:38 +1000 Subject: [PATCH 7/9] add lychee to help list --- support/flutter.mk | 1 + 1 file changed, 1 insertion(+) diff --git a/support/flutter.mk b/support/flutter.mk index 196a824..4791405 100644 --- a/support/flutter.mk +++ b/support/flutter.mk @@ -49,6 +49,7 @@ flutter: depend Run `dart run dependency_validator`. ignore Look for usage of ignore directives. license Look for missing top license in source code. + lychee Look for broken links test Run flutter testing. itest Run flutter interation testing. From 75196b44d37f13d531417c73082aa704e3b3ad26 Mon Sep 17 00:00:00 2001 From: Jess Moore Date: Wed, 9 Sep 2026 16:02:01 +1000 Subject: [PATCH 8/9] remove deprecated --exclude-file in link checker --- .github/workflows/ci.yaml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d44905e..bc578cf 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -16,7 +16,6 @@ env: FLUTTER_VERSION: '3.41.7' jobs: - analyze: runs-on: ubuntu-latest if: github.event.repository.private == false @@ -115,9 +114,8 @@ jobs: id: lychee uses: lycheeverse/lychee-action@v2 with: # Don't fail for now but then create an issue - useful? - args: - --exclude-file .lycheeignore - --no-progress + # 20260909 jesscmoore .lycheeignore excluded by default + args: --no-progress '*.md' './**/*.dart' 'assets/**/*.md' From 0f776fc02a923649dcbe3de2a399b1a609bb5d15 Mon Sep 17 00:00:00 2001 From: Jess Moore Date: Wed, 9 Sep 2026 16:21:32 +1000 Subject: [PATCH 9/9] linted the README and added markdownlint to make help --- README.md | 28 ++++++++++++++++++++-------- support/flutter.mk | 1 + 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index f0e280f..b337e2e 100644 --- a/README.md +++ b/README.md @@ -109,11 +109,11 @@ VersionWidget( - Grey text: Version is being checked - Blue text: Version is up to date - Red bold text: Newer version is available -- Amber text: The version could not be checked (eg unpublished changelog, CORS block to changelog, app with no version) +- Amber text: The version could not be checked (eg unpublished changelog, CORS + block to changelog, app with no version) -A failed -check is reported and does not offer an app update button. Pass `assumeLatestOnCheckFailure: true` to restore -the older, quieter behaviour. +A failed check is reported and does not offer an app update button. Pass +`assumeLatestOnCheckFailure: true` to restore the older, quieter behaviour. Apps that do not report their own version — an empty or non-numeric `version` are also now reported as a failed check. @@ -139,9 +139,12 @@ changelog. ## Private repositories -The CHANGELOG must be published, as the widget fetches the CHANGELOG with a plain, unauthenticated GET. +The CHANGELOG must be published, as the widget fetches the CHANGELOG with a +plain, unauthenticated GET. -Developers with private app repositories are recommended to publish their changelog to the same origin as the web app, to avoid CORS block issues. Ie build your web app and then add publish CHANGELOG file. +Developers with private app repositories are recommended to publish their +changelog to the same origin as the web app, to avoid CORS block issues. Ie +build your web app and then add publish CHANGELOG file. ```make flutter build web --release @@ -221,14 +224,23 @@ session already provides, or make the changelog public. ### MacOS/iOS -MacOS and iOS builds of apps using version widget require these settings to pick up the app version, which is used to compare against the changelog +MacOS and iOS builds of apps using version widget require these settings to pick +up the app version, which is used to compare against the changelog In `Runner/Info.plist` within `macos` and `ios` folders, set: + + + + ```xml CFBundleShortVersionString $(FLUTTER_BUILD_NAME) ``` -If using `xcodegen` to generate XCode files, your `macos` and `ios` `project.yml` files must contain: + + + +If using `xcodegen` to generate XCode files, your `macos` and `ios` +`project.yml` files must contain: ```yml MARKETING_VERSION: '$(FLUTTER_BUILD_NAME)' diff --git a/support/flutter.mk b/support/flutter.mk index 4791405..8bdfba5 100644 --- a/support/flutter.mk +++ b/support/flutter.mk @@ -49,6 +49,7 @@ flutter: depend Run `dart run dependency_validator`. ignore Look for usage of ignore directives. license Look for missing top license in source code. + markdown Lint check the markdown files lychee Look for broken links test Run flutter testing.