mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-02 22:26:52 +02:00
Android froze the process once the activity stopped, because only track downloads held a foreground service. A dataSync BackgroundWorkService with a partial wake lock now covers library scans and in-app APK downloads, and stops when the last task ends or the system timeout fires. Its notification shows the existing localized progress, so no duplicate appears. If Android refuses the service, work continues as before. On iOS a scan holds its own background task; on expiry it cancels the scan cleanly instead of being suspended.
605 lines
22 KiB
Dart
605 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();
|
|
|
|
// Without a foreground service the download freezes when the screen
|
|
// turns off; its notification then carries the progress.
|
|
await notificationService.beginUpdateDownloadWork(
|
|
version: widget.updateInfo.version,
|
|
);
|
|
final String? filePath;
|
|
try {
|
|
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,
|
|
);
|
|
},
|
|
);
|
|
} finally {
|
|
await notificationService.endBackgroundWork(
|
|
NotificationService.updateDownloadWorkKind,
|
|
);
|
|
}
|
|
|
|
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,
|
|
),
|
|
);
|
|
}
|