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); + }); +}