mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-28 23:08:59 +02:00
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:
@@ -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:
|
||||
|
||||
@@ -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 => 'الحالي';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => '現在';
|
||||
|
||||
|
||||
@@ -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 => '현재 버전';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => 'Текущая';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => 'Поточна';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user