Files
SpotiFLAC-Mobile/lib/widgets/app_announcement_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

134 lines
3.9 KiB
Dart

import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/services/app_remote_config_service.dart';
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
class AppAnnouncementDialog extends StatelessWidget {
final RemoteAnnouncement announcement;
final VoidCallback onDismiss;
const AppAnnouncementDialog({
super.key,
required this.announcement,
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) {
_showCtaOpenFailed(context);
return;
}
final uri = Uri.tryParse(ctaUrl);
if (uri == null) {
_showCtaOpenFailed(context);
return;
}
bool launched;
try {
launched = await launchUrl(uri, mode: LaunchMode.externalApplication);
} catch (_) {
launched = false;
}
if (!context.mounted) return;
if (!launched) {
_showCtaOpenFailed(context);
return;
}
onDismiss();
Navigator.pop(context);
}
void _showCtaOpenFailed(BuildContext context) {
if (!context.mounted) return;
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
SnackBar(content: Text(context.l10n.announcementUnableToOpenLink)),
);
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isUrgent = announcement.priority.toLowerCase() == 'high';
// 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)
AppDialogAction(
filled: true,
onPressed: () => _openCta(context),
child: Text(announcement.ctaLabel!),
),
];
return PopScope(
canPop: false,
child: AppAlertDialog(
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, maxWidth: 480),
child: SingleChildScrollView(
child: Text(
announcement.message,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(height: 1.45),
),
),
),
actions: actions,
),
);
}
}
Future<void> showAppAnnouncementDialog(
BuildContext context, {
required RemoteAnnouncement announcement,
required VoidCallback onDismiss,
}) {
return showAppDialog<void>(
context: context,
barrierDismissible: false,
builder: (context) =>
AppAnnouncementDialog(announcement: announcement, onDismiss: onDismiss),
);
}