From 4fc6a6b45d52786f065a2199ef0c48dfa45532f3 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 13 Jul 2026 09:44:59 +0700 Subject: [PATCH] feat(update): force update when 3+ stable releases behind UpdateChecker now derives the latest release and a releasesBehind count from one releases-list call. When the installed version is forceUpdateThreshold (3) or more stable releases behind, the update dialog becomes mandatory: barrier and back-press are blocked, the later/don't-remind actions are hidden, backing out of the installer returns to the dialog, and the check bypasses the user's check-for-updates opt-out. Prereleases never count toward the threshold. --- lib/l10n/app_localizations.dart | 12 + lib/l10n/app_localizations_ar.dart | 8 + lib/l10n/app_localizations_de.dart | 8 + lib/l10n/app_localizations_en.dart | 8 + lib/l10n/app_localizations_es.dart | 8 + lib/l10n/app_localizations_fr.dart | 8 + lib/l10n/app_localizations_hi.dart | 8 + lib/l10n/app_localizations_id.dart | 8 + lib/l10n/app_localizations_ja.dart | 8 + lib/l10n/app_localizations_ko.dart | 8 + lib/l10n/app_localizations_nl.dart | 8 + lib/l10n/app_localizations_pt.dart | 8 + lib/l10n/app_localizations_ru.dart | 8 + lib/l10n/app_localizations_tr.dart | 8 + lib/l10n/app_localizations_uk.dart | 8 + lib/l10n/app_localizations_zh.dart | 8 + lib/l10n/arb/app_en.arb | 13 + lib/screens/main_shell.dart | 29 +- lib/services/update_checker.dart | 89 +++-- lib/widgets/update_dialog.dart | 532 ++++++++++++++++++----------- 20 files changed, 555 insertions(+), 240 deletions(-) diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 60892fff..49305cc1 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1664,6 +1664,18 @@ abstract class AppLocalizations { /// **'A new version is ready'** String get updateNewVersionReady; + /// Title of the mandatory update dialog shown when the installed version is too old + /// + /// In en, this message translates to: + /// **'Update required'** + String get updateRequiredTitle; + + /// Subtitle of the mandatory update dialog; explains why the dialog cannot be dismissed + /// + /// In en, this message translates to: + /// **'This version is {count} releases behind and is no longer supported. Update to keep using the app.'** + String updateRequiredNotice(int count); + /// Label for current version /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index 0603d394..f90643ef 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -875,6 +875,14 @@ class AppLocalizationsAr extends AppLocalizations { @override String get updateNewVersionReady => 'إصدار جديد جاهز'; + @override + String get updateRequiredTitle => 'Update required'; + + @override + String updateRequiredNotice(int count) { + return 'This version is $count releases behind and is no longer supported. Update to keep using the app.'; + } + @override String get updateCurrent => 'الحالي'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index c6aed0af..e4a8c036 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -892,6 +892,14 @@ class AppLocalizationsDe extends AppLocalizations { @override String get updateNewVersionReady => 'Eine neue Version ist verfügbar'; + @override + String get updateRequiredTitle => 'Update required'; + + @override + String updateRequiredNotice(int count) { + return 'This version is $count releases behind and is no longer supported. Update to keep using the app.'; + } + @override String get updateCurrent => 'Aktuell'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index dd1069d0..d88ef174 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -881,6 +881,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String get updateNewVersionReady => 'A new version is ready'; + @override + String get updateRequiredTitle => 'Update required'; + + @override + String updateRequiredNotice(int count) { + return 'This version is $count releases behind and is no longer supported. Update to keep using the app.'; + } + @override String get updateCurrent => 'Current'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index c6af6d15..833585ad 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -881,6 +881,14 @@ class AppLocalizationsEs extends AppLocalizations { @override String get updateNewVersionReady => 'A new version is ready'; + @override + String get updateRequiredTitle => 'Update required'; + + @override + String updateRequiredNotice(int count) { + return 'This version is $count releases behind and is no longer supported. Update to keep using the app.'; + } + @override String get updateCurrent => 'Current'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 543a9d1d..1f5ca6d1 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -902,6 +902,14 @@ class AppLocalizationsFr extends AppLocalizations { @override String get updateNewVersionReady => 'Une nouvelle version est disponible'; + @override + String get updateRequiredTitle => 'Update required'; + + @override + String updateRequiredNotice(int count) { + return 'This version is $count releases behind and is no longer supported. Update to keep using the app.'; + } + @override String get updateCurrent => 'Actuel'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index a583252d..5770a3a9 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -881,6 +881,14 @@ class AppLocalizationsHi extends AppLocalizations { @override String get updateNewVersionReady => 'A new version is ready'; + @override + String get updateRequiredTitle => 'Update required'; + + @override + String updateRequiredNotice(int count) { + return 'This version is $count releases behind and is no longer supported. Update to keep using the app.'; + } + @override String get updateCurrent => 'Current'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 1ee69df1..fd7c7e30 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -883,6 +883,14 @@ class AppLocalizationsId extends AppLocalizations { @override String get updateNewVersionReady => 'Versi baru sudah siap'; + @override + String get updateRequiredTitle => 'Update required'; + + @override + String updateRequiredNotice(int count) { + return 'This version is $count releases behind and is no longer supported. Update to keep using the app.'; + } + @override String get updateCurrent => 'Saat ini'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index ebe00f7a..bdadb2e9 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -876,6 +876,14 @@ class AppLocalizationsJa extends AppLocalizations { @override String get updateNewVersionReady => '新しいバージョンの準備ができています'; + @override + String get updateRequiredTitle => 'Update required'; + + @override + String updateRequiredNotice(int count) { + return 'This version is $count releases behind and is no longer supported. Update to keep using the app.'; + } + @override String get updateCurrent => '現在'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 9ab22a41..e7da23ed 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -857,6 +857,14 @@ class AppLocalizationsKo extends AppLocalizations { @override String get updateNewVersionReady => '새 버전이 준비되었습니다'; + @override + String get updateRequiredTitle => 'Update required'; + + @override + String updateRequiredNotice(int count) { + return 'This version is $count releases behind and is no longer supported. Update to keep using the app.'; + } + @override String get updateCurrent => '현재 버전'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 1b13fa7d..3b510840 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -881,6 +881,14 @@ class AppLocalizationsNl extends AppLocalizations { @override String get updateNewVersionReady => 'A new version is ready'; + @override + String get updateRequiredTitle => 'Update required'; + + @override + String updateRequiredNotice(int count) { + return 'This version is $count releases behind and is no longer supported. Update to keep using the app.'; + } + @override String get updateCurrent => 'Current'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 663d0898..45015609 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -881,6 +881,14 @@ class AppLocalizationsPt extends AppLocalizations { @override String get updateNewVersionReady => 'A new version is ready'; + @override + String get updateRequiredTitle => 'Update required'; + + @override + String updateRequiredNotice(int count) { + return 'This version is $count releases behind and is no longer supported. Update to keep using the app.'; + } + @override String get updateCurrent => 'Current'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 9d43c564..a37bf7e4 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -886,6 +886,14 @@ class AppLocalizationsRu extends AppLocalizations { @override String get updateNewVersionReady => 'Доступна новая версия'; + @override + String get updateRequiredTitle => 'Update required'; + + @override + String updateRequiredNotice(int count) { + return 'This version is $count releases behind and is no longer supported. Update to keep using the app.'; + } + @override String get updateCurrent => 'Текущая'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index f7d57f4e..491a873e 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -887,6 +887,14 @@ class AppLocalizationsTr extends AppLocalizations { @override String get updateNewVersionReady => 'Yeni bir sürüm hazır'; + @override + String get updateRequiredTitle => 'Update required'; + + @override + String updateRequiredNotice(int count) { + return 'This version is $count releases behind and is no longer supported. Update to keep using the app.'; + } + @override String get updateCurrent => 'Şimdiki'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 022aa896..b325d2aa 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -890,6 +890,14 @@ class AppLocalizationsUk extends AppLocalizations { @override String get updateNewVersionReady => 'Доступна нова версія'; + @override + String get updateRequiredTitle => 'Update required'; + + @override + String updateRequiredNotice(int count) { + return 'This version is $count releases behind and is no longer supported. Update to keep using the app.'; + } + @override String get updateCurrent => 'Поточна'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 51107a05..a11a3314 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -881,6 +881,14 @@ class AppLocalizationsZh extends AppLocalizations { @override String get updateNewVersionReady => 'A new version is ready'; + @override + String get updateRequiredTitle => 'Update required'; + + @override + String updateRequiredNotice(int count) { + return 'This version is $count releases behind and is no longer supported. Update to keep using the app.'; + } + @override String get updateCurrent => 'Current'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 6b266fb0..d1243d5b 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1154,6 +1154,19 @@ "@updateNewVersionReady": { "description": "Update subtitle" }, + "updateRequiredTitle": "Update required", + "@updateRequiredTitle": { + "description": "Title of the mandatory update dialog shown when the installed version is too old" + }, + "updateRequiredNotice": "This version is {count} releases behind and is no longer supported. Update to keep using the app.", + "@updateRequiredNotice": { + "description": "Subtitle of the mandatory update dialog; explains why the dialog cannot be dismissed", + "placeholders": { + "count": { + "type": "int" + } + } + }, "updateCurrent": "Current", "@updateCurrent": { "description": "Label for current version" diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 3642347c..7d468b75 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -161,23 +161,28 @@ class _MainShellState extends ConsumerState _hasCheckedUpdate = true; final settings = ref.read(settingsProvider); - if (!settings.checkForUpdates) return false; + // The check runs even when the user disabled update prompts: versions + // that fall forceUpdateThreshold stable releases behind must update, and + // that enforcement cannot be opted out of. final updateInfo = await UpdateChecker.checkForUpdate( channel: settings.updateChannel, ); - if (updateInfo != null && mounted) { - showUpdateDialog( - context, - updateInfo: updateInfo, - onDisableUpdates: () { - ref.read(settingsProvider.notifier).setCheckForUpdates(false); - }, - ); - return true; - } + if (updateInfo == null || !mounted) return false; - return false; + final forced = + updateInfo.releasesBehind >= UpdateChecker.forceUpdateThreshold; + if (!forced && !settings.checkForUpdates) return false; + + showUpdateDialog( + context, + updateInfo: updateInfo, + forced: forced, + onDisableUpdates: () { + ref.read(settingsProvider.notifier).setCheckForUpdates(false); + }, + ); + return true; } Future _checkAppAnnouncement() async { diff --git a/lib/services/update_checker.dart b/lib/services/update_checker.dart index ff182ac0..1b333c15 100644 --- a/lib/services/update_checker.dart +++ b/lib/services/update_checker.dart @@ -29,6 +29,10 @@ class UpdateInfo { final DateTime publishedAt; final bool isPrerelease; + /// How many stable (non-prerelease) releases are newer than the installed + /// version. Drives the forced-update flow. + final int releasesBehind; + const UpdateInfo({ required this.version, required this.changelog, @@ -36,63 +40,76 @@ class UpdateInfo { this.apkDownloadUrl, required this.publishedAt, this.isPrerelease = false, + this.releasesBehind = 0, }); } class UpdateChecker { - static const String _latestApiUrl = - 'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest'; static const String _allReleasesApiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases'; + /// Installed versions this many stable releases (or more) behind must + /// update before continuing to use the app. + static const int forceUpdateThreshold = 3; + static Future checkForUpdate({String channel = 'stable'}) async { if (!Platform.isAndroid) { return null; } try { + final response = await http + .get( + Uri.parse('$_allReleasesApiUrl?per_page=30'), + headers: {'Accept': 'application/vnd.github.v3+json'}, + ) + .timeout(const Duration(seconds: 10)); + + if (response.statusCode != 200) { + _log.w('GitHub API returned ${response.statusCode}'); + return null; + } + + final releases = (jsonDecode(response.body) as List) + .whereType>() + .toList(); + if (releases.isEmpty) { + _log.i('No releases found'); + return null; + } + Map? releaseData; - if (channel == 'preview') { - final response = await http - .get( - Uri.parse('$_allReleasesApiUrl?per_page=10'), - headers: {'Accept': 'application/vnd.github.v3+json'}, - ) - .timeout(const Duration(seconds: 10)); - - if (response.statusCode != 200) { - _log.w('GitHub API returned ${response.statusCode}'); - return null; - } - - final releases = jsonDecode(response.body) as List; - if (releases.isEmpty) { - _log.i('No releases found'); - return null; - } - - releaseData = releases.first as Map; + releaseData = releases.first; } else { - final response = await http - .get( - Uri.parse(_latestApiUrl), - headers: {'Accept': 'application/vnd.github.v3+json'}, - ) - .timeout(const Duration(seconds: 10)); - - if (response.statusCode != 200) { - _log.w('GitHub API returned ${response.statusCode}'); - return null; + for (final release in releases) { + if (release['prerelease'] != true) { + releaseData = release; + break; + } } - - releaseData = jsonDecode(response.body) as Map; + } + if (releaseData == null) { + _log.i('No stable release found'); + return null; } final tagName = releaseData['tag_name'] as String? ?? ''; final latestVersion = tagName.replaceFirst('v', ''); final isPrerelease = releaseData['prerelease'] as bool? ?? false; + var releasesBehind = 0; + for (final release in releases) { + if (release['prerelease'] == true) continue; + final version = (release['tag_name'] as String? ?? '').replaceFirst( + 'v', + '', + ); + if (version.isNotEmpty && _isNewerVersion(version, AppInfo.version)) { + releasesBehind++; + } + } + if (!_isNewerVersion(latestVersion, AppInfo.version)) { _log.i( 'No update available (current: ${AppInfo.version}, latest: $latestVersion, channel: $channel)', @@ -114,7 +131,8 @@ class UpdateChecker { final apkUrl = selectedAsset?.url; _log.i( - 'Update available: $latestVersion (prerelease: $isPrerelease), ' + 'Update available: $latestVersion (prerelease: $isPrerelease, ' + 'releases behind: $releasesBehind), ' 'APK asset: ${selectedAsset?.name ?? 'none'}, APK URL: $apkUrl', ); @@ -125,6 +143,7 @@ class UpdateChecker { apkDownloadUrl: apkUrl, publishedAt: publishedAt, isPrerelease: isPrerelease, + releasesBehind: releasesBehind, ); } catch (e) { _log.e('Error checking for updates: $e'); diff --git a/lib/widgets/update_dialog.dart b/lib/widgets/update_dialog.dart index 0eea31f7..748289c1 100644 --- a/lib/widgets/update_dialog.dart +++ b/lib/widgets/update_dialog.dart @@ -12,11 +12,16 @@ class UpdateDialog extends StatefulWidget { final VoidCallback onDismiss; final VoidCallback onDisableUpdates; + /// When true the dialog cannot be dismissed: the installed version is too + /// many releases behind and updating is mandatory. + final bool forced; + const UpdateDialog({ super.key, required this.updateInfo, required this.onDismiss, required this.onDisableUpdates, + this.forced = false, }); @override @@ -27,10 +32,14 @@ class _UpdateDialogState extends State { bool _isDownloading = false; double _progress = 0; String _statusText = ''; - static final RegExp _whatsNewPattern = - RegExp(r"###?\s*What'?s\s*New\s*\n", caseSensitive: false); - static final RegExp _cutoffPattern = - RegExp(r'\n---|\n###?\s*Downloads', caseSensitive: false); + static final RegExp _whatsNewPattern = RegExp( + r"###?\s*What'?s\s*New\s*\n", + caseSensitive: false, + ); + static final RegExp _cutoffPattern = RegExp( + r'\n---|\n###?\s*Downloads', + caseSensitive: false, + ); static final RegExp _sectionPattern = RegExp(r'^#{1,3}\s*(.+)$'); static final RegExp _listPattern = RegExp(r'^[-*]\s+(.+)$'); static final RegExp _subListPattern = RegExp(r'^\s+[-*]\s+(.+)$'); @@ -39,13 +48,13 @@ class _UpdateDialogState extends State { Future _downloadAndInstall() async { final apkUrl = widget.updateInfo.apkDownloadUrl; - + if (apkUrl == null) { final uri = Uri.parse(widget.updateInfo.downloadUrl); if (await canLaunchUrl(uri)) { await launchUrl(uri, mode: LaunchMode.externalApplication); } - if (mounted) Navigator.pop(context); + if (mounted && !widget.forced) Navigator.pop(context); return; } @@ -56,7 +65,7 @@ class _UpdateDialogState extends State { }); final notificationService = NotificationService(); - + final filePath = await ApkDownloader.downloadApk( url: apkUrl, version: widget.updateInfo.version, @@ -79,27 +88,36 @@ class _UpdateDialogState extends State { if (filePath != null) { await notificationService.cancelUpdateNotification(); - + await notificationService.showUpdateDownloadComplete( version: widget.updateInfo.version, ); - + if (mounted) { - Navigator.pop(context); + if (widget.forced) { + // Keep the mandatory dialog up behind the installer so backing out + // of the install cannot bypass it; allow a retry. + setState(() { + _isDownloading = false; + _statusText = ''; + }); + } else { + Navigator.pop(context); + } } - + await ApkDownloader.installApk(filePath); } else { await notificationService.cancelUpdateNotification(); - + await notificationService.showUpdateDownloadFailed(); - + if (mounted) { setState(() { _isDownloading = false; _statusText = context.l10n.updateDownloadFailed; }); - + ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.updateFailedMessage)), ); @@ -111,192 +129,299 @@ class _UpdateDialogState extends State { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; - - return Dialog( - backgroundColor: colorScheme.surfaceContainerHigh, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(16), - ), - child: Icon(Icons.system_update_rounded, color: colorScheme.onPrimaryContainer, size: 28), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(context.l10n.updateAvailable, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), - const SizedBox(height: 2), - Text(context.l10n.updateNewVersionReady, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), - ], - ), - ), - ], - ), - const SizedBox(height: 20), - - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: isDark - ? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface) - : Color.alphaBlend(Colors.black.withValues(alpha: 0.04), colorScheme.surface), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, + + return PopScope( + canPop: !widget.forced, + child: Dialog( + backgroundColor: colorScheme.surfaceContainerHigh, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( children: [ - _VersionChip(version: AppInfo.displayVersion, label: context.l10n.updateCurrent, colorScheme: colorScheme), - const SizedBox(width: 12), - Icon(Icons.arrow_forward_rounded, size: 20, color: colorScheme.primary), - const SizedBox(width: 12), - _VersionChip(version: widget.updateInfo.version, label: context.l10n.updateNew, colorScheme: colorScheme, isNew: true), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(16), + ), + child: Icon( + Icons.system_update_rounded, + color: colorScheme.onPrimaryContainer, + size: 28, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.forced + ? context.l10n.updateRequiredTitle + : context.l10n.updateAvailable, + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 2), + Text( + widget.forced + ? context.l10n.updateRequiredNotice( + widget.updateInfo.releasesBehind, + ) + : context.l10n.updateNewVersionReady, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: widget.forced + ? colorScheme.error + : colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), ], ), - ), - const SizedBox(height: 20), - - if (_isDownloading) ...[ + const SizedBox(height: 20), + Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: isDark - ? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface) - : Color.alphaBlend(Colors.black.withValues(alpha: 0.03), colorScheme.surface), - borderRadius: BorderRadius.circular(16), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - SizedBox( - width: 20, height: 20, - child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.primary), + decoration: BoxDecoration( + color: isDark + ? Color.alphaBlend( + Colors.white.withValues(alpha: 0.08), + colorScheme.surface, + ) + : Color.alphaBlend( + Colors.black.withValues(alpha: 0.04), + colorScheme.surface, ), - const SizedBox(width: 12), - Text(context.l10n.updateDownloading, style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)), - ], + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _VersionChip( + version: AppInfo.displayVersion, + label: context.l10n.updateCurrent, + colorScheme: colorScheme, ), - const SizedBox(height: 12), - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - value: _progress, - minHeight: 6, - backgroundColor: colorScheme.surfaceContainerHighest, - ), + const SizedBox(width: 12), + Icon( + Icons.arrow_forward_rounded, + size: 20, + color: colorScheme.primary, ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(_statusText, style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)), - Text('${(_progress * 100).toInt()}%', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.primary, fontWeight: FontWeight.w600)), - ], + const SizedBox(width: 12), + _VersionChip( + version: widget.updateInfo.version, + label: context.l10n.updateNew, + colorScheme: colorScheme, + isNew: true, ), ], ), ), - ] else ...[ - Text(context.l10n.updateWhatsNew, style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)), - const SizedBox(height: 8), - Container( - constraints: const BoxConstraints(maxHeight: 180), - decoration: BoxDecoration( - color: isDark - ? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface) - : Color.alphaBlend(Colors.black.withValues(alpha: 0.03), colorScheme.surface), - borderRadius: BorderRadius.circular(16), - ), - child: SingleChildScrollView( + const SizedBox(height: 20), + + if (_isDownloading) ...[ + Container( padding: const EdgeInsets.all(16), - child: Text( - _formatChangelog( - widget.updateInfo.changelog, - context.l10n.updateSeeReleaseNotes, - ), - style: Theme.of(context).textTheme.bodySmall?.copyWith(height: 1.5), + decoration: BoxDecoration( + color: isDark + ? Color.alphaBlend( + Colors.white.withValues(alpha: 0.05), + colorScheme.surface, + ) + : Color.alphaBlend( + Colors.black.withValues(alpha: 0.03), + colorScheme.surface, + ), + borderRadius: BorderRadius.circular(16), ), - ), - ), - ], - const SizedBox(height: 24), - - if (_isDownloading) - SizedBox( - width: double.infinity, - child: OutlinedButton( - onPressed: () => Navigator.pop(context), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - child: Text(context.l10n.dialogCancel), - ), - ) - else - Column( - children: [ - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: _downloadAndInstall, - icon: const Icon(Icons.download_rounded, size: 20), - label: Text(context.l10n.updateDownloadInstall), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - ), - ), - const SizedBox(height: 8), - Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: TextButton( - onPressed: () { - widget.onDisableUpdates(); - Navigator.pop(context); - }, - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.primary, + ), ), - child: Text(context.l10n.updateDontRemind, style: TextStyle(color: colorScheme.onSurfaceVariant)), + const SizedBox(width: 12), + Text( + context.l10n.updateDownloading, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.w600), + ), + ], + ), + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: _progress, + minHeight: 6, + backgroundColor: colorScheme.surfaceContainerHighest, ), ), - const SizedBox(width: 8), - Expanded( - child: OutlinedButton( - onPressed: () { - widget.onDismiss(); - Navigator.pop(context); - }, - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _statusText, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), ), - child: Text(context.l10n.updateLater), - ), + Text( + '${(_progress * 100).toInt()}%', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ], ), ], ), - ], - ), - ], + ), + ] else ...[ + Text( + context.l10n.updateWhatsNew, + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Container( + constraints: const BoxConstraints(maxHeight: 180), + decoration: BoxDecoration( + color: isDark + ? Color.alphaBlend( + Colors.white.withValues(alpha: 0.05), + colorScheme.surface, + ) + : Color.alphaBlend( + Colors.black.withValues(alpha: 0.03), + colorScheme.surface, + ), + borderRadius: BorderRadius.circular(16), + ), + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Text( + _formatChangelog( + widget.updateInfo.changelog, + context.l10n.updateSeeReleaseNotes, + ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(height: 1.5), + ), + ), + ), + ], + const SizedBox(height: 24), + + if (_isDownloading) + SizedBox( + width: double.infinity, + child: widget.forced + ? const SizedBox.shrink() + : OutlinedButton( + onPressed: () => Navigator.pop(context), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text(context.l10n.dialogCancel), + ), + ) + else + Column( + children: [ + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _downloadAndInstall, + icon: const Icon(Icons.download_rounded, size: 20), + label: Text(context.l10n.updateDownloadInstall), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + if (!widget.forced) const SizedBox(height: 8), + if (!widget.forced) + Row( + children: [ + Expanded( + child: TextButton( + onPressed: () { + widget.onDisableUpdates(); + Navigator.pop(context); + }, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + context.l10n.updateDontRemind, + style: TextStyle( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton( + onPressed: () { + widget.onDismiss(); + Navigator.pop(context); + }, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text(context.l10n.updateLater), + ), + ), + ], + ), + ], + ), + ], + ), ), ), ); @@ -305,24 +430,24 @@ class _UpdateDialogState extends State { /// Format changelog - clean up markdown and extract relevant content String _formatChangelog(String changelog, String emptyFallback) { var content = changelog; - + final whatsNewMatch = _whatsNewPattern.firstMatch(content); if (whatsNewMatch != null) { content = content.substring(whatsNewMatch.end); } - + final cutoffMatch = _cutoffPattern.firstMatch(content); if (cutoffMatch != null) { content = content.substring(0, cutoffMatch.start); } - + final lines = content.split('\n'); final formattedLines = []; - + for (var line in lines) { line = line.trim(); if (line.isEmpty) continue; - + final sectionMatch = _sectionPattern.firstMatch(line); if (sectionMatch != null) { final section = sectionMatch.group(1)?.trim(); @@ -332,30 +457,39 @@ class _UpdateDialogState extends State { } continue; } - + final listMatch = _listPattern.firstMatch(line); if (listMatch != null) { var itemText = listMatch.group(1) ?? ''; - itemText = itemText.replaceAllMapped(_boldPattern, (m) => m.group(1) ?? ''); - itemText = itemText.replaceAllMapped(_codePattern, (m) => m.group(1) ?? ''); + itemText = itemText.replaceAllMapped( + _boldPattern, + (m) => m.group(1) ?? '', + ); + itemText = itemText.replaceAllMapped( + _codePattern, + (m) => m.group(1) ?? '', + ); formattedLines.add('• $itemText'); continue; } - + final subListMatch = _subListPattern.firstMatch(line); if (subListMatch != null) { var itemText = subListMatch.group(1) ?? ''; - itemText = itemText.replaceAllMapped(_boldPattern, (m) => m.group(1) ?? ''); + itemText = itemText.replaceAllMapped( + _boldPattern, + (m) => m.group(1) ?? '', + ); formattedLines.add(' - $itemText'); continue; } } - + var formatted = formattedLines.join('\n').trim(); if (formatted.length > 2000) { formatted = '${formatted.substring(0, 2000)}...'; } - + return formatted.isEmpty ? emptyFallback : formatted; } } @@ -377,18 +511,27 @@ class _VersionChip extends StatelessWidget { Widget build(BuildContext context) { return Column( children: [ - Text(label, style: Theme.of(context).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant)), + Text( + label, + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant), + ), const SizedBox(height: 4), Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( - color: isNew ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest, + color: isNew + ? colorScheme.primaryContainer + : colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(20), ), child: Text( 'v$version', style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: isNew ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, + color: isNew + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, fontWeight: isNew ? FontWeight.bold : FontWeight.w500, ), ), @@ -402,13 +545,16 @@ Future showUpdateDialog( BuildContext context, { required UpdateInfo updateInfo, required VoidCallback onDisableUpdates, + bool forced = false, }) async { return showDialog( context: context, + barrierDismissible: !forced, builder: (context) => UpdateDialog( updateInfo: updateInfo, onDismiss: () {}, onDisableUpdates: onDisableUpdates, + forced: forced, ), ); }