diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 7ed98a1..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
@@ -29,6 +28,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
@@ -103,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'
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
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..b337e2e 100644
--- a/README.md
+++ b/README.md
@@ -109,15 +109,24 @@ 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 (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.
+
+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
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 +137,55 @@ 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 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 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:
+
+```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. 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.
@@ -137,9 +195,21 @@ changelog.
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
+ 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 +220,33 @@ 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
new file mode 100644
index 0000000..11c4f0e
--- /dev/null
+++ b/lib/src/utils/parse_changelog.dart
@@ -0,0 +1,134 @@
+/// 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;
+}
+
+/// 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
+/// 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/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/support/flutter.mk b/support/flutter.mk
index 196a824..8bdfba5 100644
--- a/support/flutter.mk
+++ b/support/flutter.mk
@@ -49,6 +49,8 @@ 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.
itest Run flutter interation testing.
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
new file mode 100644
index 0000000..fe24830
--- /dev/null
+++ b/test/parse_changelog_test.dart
@@ -0,0 +1,176 @@
+/// 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('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);
+
+ 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');
+ });
+ });
+}
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);
+ });
+}