fix: harden download errors and re-enrich sidecars

This commit is contained in:
zarzet
2026-06-02 00:58:40 +07:00
parent 4f5163be01
commit 56a89c5fc6
35 changed files with 742 additions and 99 deletions
+12
View File
@@ -6209,6 +6209,18 @@ abstract class AppLocalizations {
/// **'Download completed'**
String get queueDownloadCompleted;
/// Title shown on a failed queue item when the download service rate limits requests
///
/// In en, this message translates to:
/// **'Service rate limited'**
String get queueRateLimitTitle;
/// Explanation shown on a failed queue item when the download service rate limits requests
///
/// In en, this message translates to:
/// **'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.'**
String get queueRateLimitMessage;
/// Accessibility label for picking an accent color
///
/// In en, this message translates to:
+7
View File
@@ -3696,6 +3696,13 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get queueDownloadCompleted => 'Download completed';
@override
String get queueRateLimitTitle => 'Service rate limited';
@override
String get queueRateLimitMessage =>
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
+7
View File
@@ -3667,6 +3667,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get queueDownloadCompleted => 'Download completed';
@override
String get queueRateLimitTitle => 'Service rate limited';
@override
String get queueRateLimitMessage =>
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
+7
View File
@@ -3661,6 +3661,13 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get queueDownloadCompleted => 'Download completed';
@override
String get queueRateLimitTitle => 'Service rate limited';
@override
String get queueRateLimitMessage =>
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
+7
View File
@@ -3665,6 +3665,13 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get queueDownloadCompleted => 'Download completed';
@override
String get queueRateLimitTitle => 'Service rate limited';
@override
String get queueRateLimitMessage =>
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
+7
View File
@@ -3662,6 +3662,13 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get queueDownloadCompleted => 'Download completed';
@override
String get queueRateLimitTitle => 'Service rate limited';
@override
String get queueRateLimitMessage =>
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
+7
View File
@@ -3653,6 +3653,13 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get queueDownloadCompleted => 'Download completed';
@override
String get queueRateLimitTitle => 'Layanan sedang membatasi permintaan';
@override
String get queueRateLimitMessage =>
'Lagu ini mungkin masih tersedia. Tunggu beberapa menit, kurangi unduhan paralel, lalu coba lagi.';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
+7
View File
@@ -3649,6 +3649,13 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get queueDownloadCompleted => 'Download completed';
@override
String get queueRateLimitTitle => 'Service rate limited';
@override
String get queueRateLimitMessage =>
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
+7
View File
@@ -3642,6 +3642,13 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get queueDownloadCompleted => 'Download completed';
@override
String get queueRateLimitTitle => 'Service rate limited';
@override
String get queueRateLimitMessage =>
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
+7
View File
@@ -3662,6 +3662,13 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get queueDownloadCompleted => 'Download completed';
@override
String get queueRateLimitTitle => 'Service rate limited';
@override
String get queueRateLimitMessage =>
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
+7
View File
@@ -3661,6 +3661,13 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get queueDownloadCompleted => 'Download completed';
@override
String get queueRateLimitTitle => 'Service rate limited';
@override
String get queueRateLimitMessage =>
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
+7
View File
@@ -3721,6 +3721,13 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get queueDownloadCompleted => 'Download completed';
@override
String get queueRateLimitTitle => 'Service rate limited';
@override
String get queueRateLimitMessage =>
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
+7
View File
@@ -3688,6 +3688,13 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get queueDownloadCompleted => 'Download completed';
@override
String get queueRateLimitTitle => 'Service rate limited';
@override
String get queueRateLimitMessage =>
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
+7
View File
@@ -3721,6 +3721,13 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get queueDownloadCompleted => 'Download completed';
@override
String get queueRateLimitTitle => 'Service rate limited';
@override
String get queueRateLimitMessage =>
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
+7
View File
@@ -3661,6 +3661,13 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get queueDownloadCompleted => 'Download completed';
@override
String get queueRateLimitTitle => 'Service rate limited';
@override
String get queueRateLimitMessage =>
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
+8
View File
@@ -4808,6 +4808,14 @@
"@queueDownloadCompleted": {
"description": "Accessibility label for completed download state in queue"
},
"queueRateLimitTitle": "Service rate limited",
"@queueRateLimitTitle": {
"description": "Title shown on a failed queue item when the download service rate limits requests"
},
"queueRateLimitMessage": "This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.",
"@queueRateLimitMessage": {
"description": "Explanation shown on a failed queue item when the download service rate limits requests"
},
"appearanceSelectAccentColor": "Select accent color {hex}",
"@appearanceSelectAccentColor": {
"description": "Accessibility label for picking an accent color",
+8
View File
@@ -4614,5 +4614,13 @@
"downloadFallbackExtensionsSubtitle": "Choose which extensions can be used as fallback",
"@downloadFallbackExtensionsSubtitle": {
"description": "Subtitle for fallback extensions item"
},
"queueRateLimitTitle": "Layanan sedang membatasi permintaan",
"@queueRateLimitTitle": {
"description": "Title shown on a failed queue item when the download service rate limits requests"
},
"queueRateLimitMessage": "Lagu ini mungkin masih tersedia. Tunggu beberapa menit, kurangi unduhan paralel, lalu coba lagi.",
"@queueRateLimitMessage": {
"description": "Explanation shown on a failed queue item when the download service rate limits requests"
}
}
+1 -1
View File
@@ -89,7 +89,7 @@ class DownloadItem {
case DownloadErrorType.notFound:
return 'Song not found on any service';
case DownloadErrorType.rateLimit:
return 'Rate limit reached, try again later';
return 'Service rate limit reached. Wait before retrying.';
case DownloadErrorType.network:
return 'Connection failed, check your internet';
case DownloadErrorType.permission:
+29 -1
View File
@@ -6551,6 +6551,32 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
DownloadErrorType _downloadErrorTypeFromMessage(String errorMsg) {
final lowerMsg = errorMsg.toLowerCase();
if (errorMsg.contains('429') ||
lowerMsg.contains('rate limit') ||
lowerMsg.contains('too many requests')) {
return DownloadErrorType.rateLimit;
}
if (lowerMsg.contains('not found') ||
lowerMsg.contains('not available') ||
lowerMsg.contains('no results')) {
return DownloadErrorType.notFound;
}
if (lowerMsg.contains('permission') ||
lowerMsg.contains('operation not permitted') ||
lowerMsg.contains('access denied')) {
return DownloadErrorType.permission;
}
if (lowerMsg.contains('network') ||
lowerMsg.contains('connection') ||
lowerMsg.contains('timeout') ||
lowerMsg.contains('dial')) {
return DownloadErrorType.network;
}
return DownloadErrorType.unknown;
}
Future<void> _processQueue() async {
if (state.isProcessing) return;
@@ -8723,7 +8749,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
errorType = DownloadErrorType.permission;
break;
default:
errorType = DownloadErrorType.unknown;
errorType = _downloadErrorTypeFromMessage(errorMsg);
}
_log.e('Download failed: $errorMsg (type: $errorTypeStr)');
@@ -8777,6 +8803,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
errorMsg.contains('track not found on Deezer')) {
errorMsg = 'Track not found on Deezer (Metadata Unavailable)';
errorType = DownloadErrorType.notFound;
} else {
errorType = _downloadErrorTypeFromMessage(errorMsg);
}
updateItemStatus(
+16
View File
@@ -863,6 +863,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
await _safeDeleteFile(tempPath!);
return false;
}
await writeReEnrichSafSidecarLrc(safUri: safUri, reEnrichResult: result);
}
if (_hasValue(downloadedCoverPath)) {
@@ -875,6 +876,15 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
await _safeDeleteFile(tempPath!);
}
if (ffmpegResult != null) {
// Filesystem .lrc sidecar. SAF sidecar is written only after
// writeTempToSaf succeeds.
await writeReEnrichSidecarLrc(
audioFilePath: item.filePath,
reEnrichResult: result,
);
}
return ffmpegResult != null;
}
@@ -890,6 +900,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
'cover_url': '',
'max_quality': true,
'embed_lyrics': settings.embedLyrics,
'lyrics_mode': settings.lyricsMode,
'artist_tag_mode': artistTagMode,
'spotify_id': '',
'track_name': item.trackName,
@@ -912,6 +923,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final result = await PlatformBridge.reEnrichFile(request);
final method = result['method'] as String?;
if (method == 'native') {
// Filesystem .lrc sidecar (SAF sidecar handled natively in Kotlin).
await writeReEnrichSidecarLrc(
audioFilePath: item.filePath,
reEnrichResult: result,
);
return true;
}
if (method == 'ffmpeg') {
+84 -6
View File
@@ -4388,6 +4388,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
await _safeDeleteTempFile(tempPath!);
return false;
}
await writeReEnrichSafSidecarLrc(safUri: safUri, reEnrichResult: result);
}
if (_hasTextValue(downloadedCoverPath)) {
@@ -4400,6 +4401,15 @@ class _QueueTabState extends ConsumerState<QueueTab> {
await _safeDeleteTempFile(tempPath!);
}
if (ffmpegResult != null) {
// Filesystem .lrc sidecar. SAF sidecar is written only after
// writeTempToSaf succeeds.
await writeReEnrichSidecarLrc(
audioFilePath: item.filePath,
reEnrichResult: result,
);
}
return ffmpegResult != null;
}
@@ -4415,6 +4425,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
'cover_url': '',
'max_quality': true,
'embed_lyrics': settings.embedLyrics,
'lyrics_mode': settings.lyricsMode,
'artist_tag_mode': artistTagMode,
'spotify_id': '',
'track_name': item.trackName,
@@ -4437,6 +4448,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final result = await PlatformBridge.reEnrichFile(request);
final method = result['method'] as String?;
if (method == 'native') {
// Filesystem .lrc sidecar (SAF sidecar handled natively in Kotlin).
await writeReEnrichSidecarLrc(
audioFilePath: item.filePath,
reEnrichResult: result,
);
return true;
}
if (method == 'ffmpeg') {
@@ -5664,12 +5680,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
],
if (item.status == DownloadStatus.failed) ...[
const SizedBox(height: 4),
Text(
item.errorMessage,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(color: colorScheme.error),
_buildDownloadFailureMessage(
context,
item,
colorScheme,
),
],
],
@@ -5686,6 +5700,70 @@ class _QueueTabState extends ConsumerState<QueueTab> {
);
}
Widget _buildDownloadFailureMessage(
BuildContext context,
DownloadItem item,
ColorScheme colorScheme,
) {
if (item.errorType != DownloadErrorType.rateLimit) {
return Text(
item.errorMessage,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: colorScheme.error),
);
}
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer.withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: colorScheme.tertiary.withValues(alpha: 0.35)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.hourglass_top_rounded,
size: 16,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.l10n.queueRateLimitTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
context.l10n.queueRateLimitMessage,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onTertiaryContainer,
),
),
],
),
),
],
),
);
}
Widget _buildCoverArt(DownloadItem item, ColorScheme colorScheme) {
final coverSize = _queueCoverSize();
+18 -1
View File
@@ -2933,6 +2933,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
'cover_url': _coverUrl ?? '',
'max_quality': true,
'embed_lyrics': settings.embedLyrics,
'lyrics_mode': settings.lyricsMode,
'artist_tag_mode': artistTagMode,
'spotify_id': _spotifyId ?? '',
'track_name': trackName,
@@ -2979,7 +2980,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
if (method == 'native') {
// FLAC - handled natively by Go (SAF write-back handled in Kotlin)
// FLAC - handled natively by Go (SAF write-back handled in Kotlin).
// External .lrc sidecar for filesystem files is written here; SAF
// sidecars are created natively in the Kotlin reEnrichFile handler.
await writeReEnrichSidecarLrc(
audioFilePath: cleanFilePath,
reEnrichResult: result,
);
await _refreshEmbeddedCoverPreview(force: true);
_markMetadataChanged();
await _syncDownloadHistoryMetadata();
@@ -3073,6 +3080,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
return;
}
await writeReEnrichSafSidecarLrc(
safUri: safUri,
reEnrichResult: result,
);
}
if (tempPath != null && tempPath.isNotEmpty) {
@@ -3082,6 +3093,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
if (ffmpegResult != null) {
// Filesystem .lrc sidecar. SAF sidecar is written only after
// writeTempToSaf succeeds.
await writeReEnrichSidecarLrc(
audioFilePath: cleanFilePath,
reEnrichResult: result,
);
await _refreshEmbeddedCoverPreview(force: true);
_markMetadataChanged();
await _syncDownloadHistoryMetadata();
+9
View File
@@ -817,6 +817,15 @@ class PlatformBridge {
return map['success'] == true;
}
static Future<bool> writeSafSidecarLrc(String safUri, String lyrics) async {
final result = await _channel.invokeMethod('writeSafSidecarLrc', {
'saf_uri': safUri,
'lyrics': lyrics,
});
final map = _decodeRequiredMapResult(result, 'writeSafSidecarLrc');
return map['success'] == true;
}
static Future<void> startDownloadService({
String trackName = '',
String artistName = '',
+51
View File
@@ -22,6 +22,57 @@ String _sidecarLrcPath(String path) {
return '$path.lrc';
}
/// Writes a ".lrc" sidecar next to a re-enriched audio file when the Go backend
/// result requests it (`write_external_lrc`), honoring the user's lyrics mode.
///
/// This handles the filesystem case only. SAF (`content://`) files are written
/// centrally by the Kotlin `reEnrichFile` handler, which still holds the
/// original document URI, so callers should skip those here (they are detected
/// and ignored). Best-effort: returns true only when a sidecar was actually
/// written, and never throws.
Future<bool> writeReEnrichSidecarLrc({
required String audioFilePath,
required Map<String, dynamic> reEnrichResult,
}) async {
if (reEnrichResult['write_external_lrc'] != true) return false;
// SAF documents are handled natively in Kotlin; nothing to do from Dart.
if (isContentUri(audioFilePath)) return false;
final lrc = (reEnrichResult['lyrics'] as String?)?.trim() ?? '';
if (lrc.isEmpty) return false;
try {
final lrcPath = _sidecarLrcPath(audioFilePath);
await File(lrcPath).writeAsString(lrc);
return true;
} catch (_) {
return false;
}
}
/// Writes a SAF ".lrc" sidecar after a FFmpeg re-enrich write-back succeeds.
///
/// Native FLAC re-enrich handles SAF sidecars in Kotlin after the direct
/// write-back. This helper is for the FFmpeg path, where Dart owns the final
/// `writeTempToSaf` success/failure decision.
Future<bool> writeReEnrichSafSidecarLrc({
required String safUri,
required Map<String, dynamic> reEnrichResult,
}) async {
if (reEnrichResult['write_external_lrc'] != true) return false;
if (!isContentUri(safUri)) return false;
final lrc = (reEnrichResult['lyrics'] as String?)?.trim() ?? '';
if (lrc.isEmpty) return false;
try {
return await PlatformBridge.writeSafSidecarLrc(safUri, lrc);
} catch (_) {
return false;
}
}
Future<void> ensureLyricsMetadataForConversion({
required Map<String, String> metadata,
required String sourcePath,
+88 -37
View File
@@ -12,60 +12,107 @@ class AppAnnouncementDialog extends StatelessWidget {
required this.onDismiss,
});
void _close(BuildContext context) {
onDismiss();
Navigator.pop(context);
}
Future<void> _openCta(BuildContext context) async {
final ctaUrl = announcement.ctaUrl;
if (ctaUrl == null || ctaUrl.isEmpty) return;
if (ctaUrl == null || ctaUrl.isEmpty) {
_showCtaOpenFailed(context);
return;
}
final uri = Uri.tryParse(ctaUrl);
if (uri == null) return;
if (uri == null) {
_showCtaOpenFailed(context);
return;
}
bool launched;
try {
launched = await launchUrl(uri, mode: LaunchMode.externalApplication);
} catch (_) {
launched = false;
}
if (!launched) {
_showCtaOpenFailed(context);
return;
}
await launchUrl(uri, mode: LaunchMode.externalApplication);
onDismiss();
if (context.mounted) {
Navigator.pop(context);
}
}
void _showCtaOpenFailed(BuildContext context) {
if (!context.mounted) return;
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
const SnackBar(content: Text('Unable to open link. Please try again.')),
);
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isUrgent = announcement.priority.toLowerCase() == 'high';
return AlertDialog(
icon: Icon(
isUrgent ? Icons.priority_high_rounded : Icons.campaign_rounded,
color: isUrgent ? colorScheme.error : colorScheme.primary,
),
title: Text(announcement.title),
content: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 260),
child: SingleChildScrollView(
child: Text(
announcement.message,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(height: 1.45),
// The notice can only be closed through an explicit affordance: never by
// tapping the scrim or the system back button (handled by the barrier and
// PopScope below). Always keep at least one way out (the X). A
// non-dismissible announcement may omit the X only when it carries a CTA,
// so the user can never get permanently trapped.
final showCloseButton = announcement.dismissible || !announcement.hasCta;
final actions = <Widget>[
if (announcement.hasCta)
FilledButton(
onPressed: () => _openCta(context),
child: Text(announcement.ctaLabel!),
),
];
return PopScope(
canPop: false,
child: AlertDialog(
title: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
isUrgent ? Icons.priority_high_rounded : Icons.campaign_rounded,
color: isUrgent ? colorScheme.error : colorScheme.primary,
),
const SizedBox(width: 12),
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(announcement.title),
),
),
if (showCloseButton)
IconButton(
icon: const Icon(Icons.close_rounded),
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
visualDensity: VisualDensity.compact,
onPressed: () => _close(context),
),
],
),
content: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 260),
child: SingleChildScrollView(
child: Text(
announcement.message,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(height: 1.45),
),
),
),
actions: actions.isEmpty ? null : actions,
),
actions: [
TextButton(
onPressed: () {
onDismiss();
Navigator.pop(context);
},
child: Text(
announcement.dismissible
? MaterialLocalizations.of(context).closeButtonLabel
: 'OK',
),
),
if (announcement.hasCta)
FilledButton(
onPressed: () => _openCta(context),
child: Text(announcement.ctaLabel!),
),
],
);
}
}
@@ -75,10 +122,14 @@ Future<void> showAppAnnouncementDialog(
required RemoteAnnouncement announcement,
required VoidCallback onDismiss,
}) {
// barrierDismissible is false so a stray tap outside the dialog can no longer
// close (and silently mark-as-seen) the notice. Dismissal — and the
// mark-as-seen side effect in onDismiss — only happens via the explicit close
// button or the CTA, both of which call onDismiss themselves.
return showDialog<void>(
context: context,
barrierDismissible: announcement.dismissible,
barrierDismissible: false,
builder: (context) =>
AppAnnouncementDialog(announcement: announcement, onDismiss: onDismiss),
).whenComplete(onDismiss);
);
}