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.
This commit is contained in:
zarzet
2026-07-13 09:44:59 +07:00
parent aae48cc47c
commit 4fc6a6b45d
20 changed files with 555 additions and 240 deletions
+12
View File
@@ -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:
+8
View File
@@ -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 => 'الحالي';
+8
View File
@@ -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';
+8
View File
@@ -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';
+8
View File
@@ -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';
+8
View File
@@ -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';
+8
View File
@@ -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';
+8
View File
@@ -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';
+8
View File
@@ -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 => '現在';
+8
View File
@@ -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 => '현재 버전';
+8
View File
@@ -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';
+8
View File
@@ -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';
+8
View File
@@ -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 => 'Текущая';
+8
View File
@@ -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';
+8
View File
@@ -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 => 'Поточна';
+8
View File
@@ -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';
+13
View File
@@ -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"
+17 -12
View File
@@ -161,23 +161,28 @@ class _MainShellState extends ConsumerState<MainShell>
_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<void> _checkAppAnnouncement() async {
+54 -35
View File
@@ -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<UpdateInfo?> 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<dynamic>)
.whereType<Map<String, dynamic>>()
.toList();
if (releases.isEmpty) {
_log.i('No releases found');
return null;
}
Map<String, dynamic>? 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<dynamic>;
if (releases.isEmpty) {
_log.i('No releases found');
return null;
}
releaseData = releases.first as Map<String, dynamic>;
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<String, dynamic>;
}
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');
+339 -193
View File
@@ -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<UpdateDialog> {
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<UpdateDialog> {
Future<void> _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<UpdateDialog> {
});
final notificationService = NotificationService();
final filePath = await ApkDownloader.downloadApk(
url: apkUrl,
version: widget.updateInfo.version,
@@ -79,27 +88,36 @@ class _UpdateDialogState extends State<UpdateDialog> {
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<UpdateDialog> {
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<UpdateDialog> {
/// 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 = <String>[];
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<UpdateDialog> {
}
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<void> 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,
),
);
}