feat: show remote-config launch announcement on app start

Introduce AppRemoteConfigService which fetches a platform/version/locale-aware JSON payload from api.zarz.moe/v1/spotiflac-mobile/config and caches it in SharedPreferences. main_shell shows a one-shot announcement dialog (respecting dismissible, CTA, time window and version gates) when no update prompt is pending; dismissed IDs are persisted so each announcement surfaces only once.

Tweaks bundled in: the service health dot loses its blur halo in favour of solid Material 3 tones, and AppInfo gains the remote config endpoint constant. The share listener and SAF migration hook stay synchronous inside the post-frame callback so share-intent URLs never race the network-bound checks.

New unit tests cover the announcement CTA/active-window rules.
This commit is contained in:
zarzet
2026-05-11 01:37:10 +07:00
parent 81547013f9
commit 7845ac8be5
7 changed files with 978 additions and 266 deletions
+84
View File
@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:spotiflac_android/services/app_remote_config_service.dart';
class AppAnnouncementDialog extends StatelessWidget {
final RemoteAnnouncement announcement;
final VoidCallback onDismiss;
const AppAnnouncementDialog({
super.key,
required this.announcement,
required this.onDismiss,
});
Future<void> _openCta(BuildContext context) async {
final ctaUrl = announcement.ctaUrl;
if (ctaUrl == null || ctaUrl.isEmpty) return;
final uri = Uri.tryParse(ctaUrl);
if (uri == null) return;
await launchUrl(uri, mode: LaunchMode.externalApplication);
onDismiss();
if (context.mounted) {
Navigator.pop(context);
}
}
@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),
),
),
),
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!),
),
],
);
}
}
Future<void> showAppAnnouncementDialog(
BuildContext context, {
required RemoteAnnouncement announcement,
required VoidCallback onDismiss,
}) {
return showDialog<void>(
context: context,
barrierDismissible: announcement.dismissible,
builder: (context) =>
AppAnnouncementDialog(announcement: announcement, onDismiss: onDismiss),
).whenComplete(onDismiss);
}
+7 -16
View File
@@ -78,7 +78,8 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
});
final downloadExtensions = _downloadExtensions();
final recommended = widget.recommendedService;
if (recommended != null && _serviceExists(recommended, downloadExtensions)) {
if (recommended != null &&
_serviceExists(recommended, downloadExtensions)) {
_selectedService = recommended;
} else {
_selectedService = ref.read(settingsProvider).defaultService;
@@ -383,17 +384,7 @@ class _ServiceHealthDot extends StatelessWidget {
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: color.withValues(alpha: 0.35),
blurRadius: 6,
spreadRadius: 1,
),
],
),
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
);
}
@@ -402,14 +393,14 @@ class _ServiceHealthDot extends StatelessWidget {
Color _serviceHealthColor(String status) {
switch (status) {
case 'online':
return const Color(0xFF35D07F);
return const Color(0xFF24D47A);
case 'degraded':
case 'unknown':
return const Color(0xFFFFC857);
return const Color(0xFFFFC247);
case 'offline':
return const Color(0xFFFF4D5E);
return const Color(0xFFFF5A66);
default:
return const Color(0xFFFFC857);
return const Color(0xFFFFC247);
}
}