mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 12:52:13 +02:00
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.
593 lines
22 KiB
Dart
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,
|
|
),
|
|
);
|
|
}
|