Files
SpotiFLAC-Mobile/lib/widgets/update_dialog.dart
T
zarzet 63e1a9b9c8 feat(ui): add Mornye glass theme and adaptive music player
Add a selectable Mornye theme with shared glass controls, dialogs, sheets, notifications, and collection headers while retaining Material components.

Update player artwork, lyrics transitions and highlighting, synchronized rolling time labels, volume controls, and compact navigation. Unify metadata, Library, search, and extension surfaces; preserve restored artwork and report extension load failures.

Validation: full Flutter analyzer clean; 713 tests passed and 4 skipped; pinned Dart formatting and diff checks passed; signed ARM64 and ARM32 release APKs built and verified.
2026-09-19 12:10:10 +07:00

593 lines
22 KiB
Dart

import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/services/update_checker.dart';
import 'package:spotiflac_android/services/apk_downloader.dart';
import 'package:spotiflac_android/services/notification_service.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
import 'package:spotiflac_android/widgets/app_action_button.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
class UpdateDialog extends StatefulWidget {
final UpdateInfo updateInfo;
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
State<UpdateDialog> createState() => _UpdateDialogState();
}
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 _sectionPattern = RegExp(r'^#{1,3}\s*(.+)$');
static final RegExp _listPattern = RegExp(r'^[-*]\s+(.+)$');
static final RegExp _subListPattern = RegExp(r'^\s+[-*]\s+(.+)$');
static final RegExp _boldPattern = RegExp(r'\*\*([^*]+)\*\*');
static final RegExp _codePattern = RegExp(r'`([^`]+)`');
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 && !widget.forced) Navigator.pop(context);
return;
}
setState(() {
_isDownloading = true;
_progress = 0;
_statusText = context.l10n.updateStartingDownload;
});
final notificationService = NotificationService();
final filePath = await ApkDownloader.downloadApk(
url: apkUrl,
version: widget.updateInfo.version,
expectedSha256: widget.updateInfo.apkSha256,
onProgress: (received, total) {
if (mounted) {
setState(() {
_progress = total > 0 ? received / total : 0;
final receivedMB = formatMegabytes(received);
final totalMB = formatMegabytes(total);
_statusText = '$receivedMB / $totalMB MB';
});
}
notificationService.showUpdateDownloadProgress(
version: widget.updateInfo.version,
received: received,
total: total,
);
},
);
if (filePath != null) {
await notificationService.cancelUpdateNotification();
await notificationService.showUpdateDownloadComplete(
version: widget.updateInfo.version,
);
if (mounted) {
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)),
);
}
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
return PopScope(
canPop: !widget.forced,
child: AppDialogSurface(
backgroundColor: colorScheme.surfaceContainerHigh,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
child: ConstrainedBox(
// Keeps the changelog column readable on tablets/landscape.
constraints: const BoxConstraints(maxWidth: 480),
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(
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),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
color: context.isMornye
? MornyeTheme.controlFill(context)
: 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: context.isMornye
? null
: Border.all(
color: colorScheme.outlineVariant.withValues(
alpha: 0.5,
),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: _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),
Flexible(
child: _VersionChip(
version: widget.updateInfo.version,
label: context.l10n.updateNew,
colorScheme: colorScheme,
isNew: true,
),
),
],
),
),
const SizedBox(height: 20),
if (_isDownloading) ...[
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: context.isMornye
? MornyeTheme.controlFill(context)
: 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: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.primary,
),
),
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(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,
),
),
],
),
],
),
),
] 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: context.isMornye
? MornyeTheme.controlFill(context)
: 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()
: AppDialogAction(
outlined: true,
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: AppActionButton(
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: AppDialogAction(
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: AppDialogAction(
outlined: true,
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),
),
),
],
),
],
),
],
),
),
),
),
);
}
/// 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();
if (section != null && section.isNotEmpty) {
if (formattedLines.isNotEmpty) formattedLines.add('');
formattedLines.add(section);
}
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) ?? '',
);
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) ?? '',
);
formattedLines.add(' - $itemText');
continue;
}
}
var formatted = formattedLines.join('\n').trim();
if (formatted.length > 2000) {
formatted = '${formatted.substring(0, 2000)}...';
}
return formatted.isEmpty ? emptyFallback : formatted;
}
}
class _VersionChip extends StatelessWidget {
final String version;
final String label;
final ColorScheme colorScheme;
final bool isNew;
const _VersionChip({
required this.version,
required this.label,
required this.colorScheme,
this.isNew = false,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
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: context.isMornye
? Colors.transparent
: isNew
? colorScheme.primaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Text(
'v$version',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: context.isMornye && isNew
? colorScheme.primary
: isNew
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
fontWeight: isNew ? FontWeight.bold : FontWeight.w500,
),
),
),
],
);
}
}
Future<void> showUpdateDialog(
BuildContext context, {
required UpdateInfo updateInfo,
required VoidCallback onDisableUpdates,
bool forced = false,
}) async {
return showAppDialog<void>(
context: context,
barrierDismissible: !forced,
builder: (context) => UpdateDialog(
updateInfo: updateInfo,
onDismiss: () {},
onDisableUpdates: onDisableUpdates,
forced: forced,
),
);
}