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:36:10 +07:00
parent 81547013f9
commit 7845ac8be5
7 changed files with 978 additions and 266 deletions
+2
View File
@@ -17,6 +17,8 @@ class AppInfo {
static const String githubUrl = 'https://github.com/$githubRepo';
static const String originalGithubUrl =
'https://github.com/afkarxyz/SpotiFLAC';
static const String remoteConfigApiUrl =
'https://api.zarz.moe/v1/spotiflac-mobile/config';
static const String kofiUrl = 'https://ko-fi.com/zarzet';
static const String githubSponsorsUrl = 'https://github.com/sponsors/zarzet/';
+34 -5
View File
@@ -18,7 +18,9 @@ import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/shell_navigation_service.dart';
import 'package:spotiflac_android/services/share_intent_service.dart';
import 'package:spotiflac_android/services/notification_service.dart';
import 'package:spotiflac_android/services/app_remote_config_service.dart';
import 'package:spotiflac_android/services/update_checker.dart';
import 'package:spotiflac_android/widgets/app_announcement_dialog.dart';
import 'package:spotiflac_android/widgets/update_dialog.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/utils/logger.dart';
@@ -38,6 +40,7 @@ class _MainShellState extends ConsumerState<MainShell>
late final PageController _pageController;
late final AnimationController _tabJumpTransitionController;
bool _hasCheckedUpdate = false;
bool _hasCheckedAppAnnouncement = false;
StreamSubscription<String>? _shareSubscription;
DateTime? _lastBackPress;
final GlobalKey<NavigatorState> _homeTabNavigatorKey =
@@ -66,10 +69,13 @@ class _MainShellState extends ConsumerState<MainShell>
currentTabIndex: _currentIndex,
showRepoTab: false,
);
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkForUpdates();
WidgetsBinding.instance.addPostFrameCallback((_) async {
_setupShareListener();
_checkSafMigration();
final updateDialogShown = await _checkForUpdates();
if (!updateDialogShown) {
await _checkAppAnnouncement();
}
});
}
@@ -127,12 +133,12 @@ class _MainShellState extends ConsumerState<MainShell>
}
}
Future<void> _checkForUpdates() async {
if (_hasCheckedUpdate) return;
Future<bool> _checkForUpdates() async {
if (_hasCheckedUpdate) return false;
_hasCheckedUpdate = true;
final settings = ref.read(settingsProvider);
if (!settings.checkForUpdates) return;
if (!settings.checkForUpdates) return false;
final updateInfo = await UpdateChecker.checkForUpdate(
channel: settings.updateChannel,
@@ -145,7 +151,30 @@ class _MainShellState extends ConsumerState<MainShell>
ref.read(settingsProvider.notifier).setCheckForUpdates(false);
},
);
return true;
}
return false;
}
Future<void> _checkAppAnnouncement() async {
if (_hasCheckedAppAnnouncement) return;
_hasCheckedAppAnnouncement = true;
final locale = Localizations.localeOf(context).toLanguageTag();
final remoteConfigService = AppRemoteConfigService();
final announcement = await remoteConfigService.fetchActiveAnnouncement(
locale: locale,
);
if (announcement == null || !mounted) return;
showAppAnnouncementDialog(
context,
announcement: announcement,
onDismiss: () {
remoteConfigService.markAnnouncementDismissed(announcement.id);
},
);
}
static const _safMigrationShownKey = 'saf_migration_prompt_shown';
+266 -245
View File
@@ -1,13 +1,58 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/services/app_remote_config_service.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/donate_icons.dart';
class DonatePage extends StatelessWidget {
class DonatePage extends StatefulWidget {
const DonatePage({super.key});
@override
State<DonatePage> createState() => _DonatePageState();
}
class _DonatePageState extends State<DonatePage> {
DonateConfig _config = DonateConfig.fallback();
bool _hasRequestedConfig = false;
String? _activeRemoteJson;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_hasRequestedConfig) return;
_hasRequestedConfig = true;
_loadConfig(Localizations.localeOf(context).toLanguageTag());
}
Future<void> _loadConfig(String locale) async {
final service = AppRemoteConfigService();
final cached = await service.readCachedConfig();
if (!mounted) return;
if (cached != null) {
_applyRemoteConfig(cached);
}
unawaited(_refreshConfigCache(locale));
}
Future<void> _refreshConfigCache(String locale) async {
await AppRemoteConfigService().fetchConfigSnapshot(locale: locale);
}
void _applyRemoteConfig(RemoteConfigSnapshot snapshot) {
if (_activeRemoteJson == snapshot.rawJson) return;
setState(() {
_activeRemoteJson = snapshot.rawJson;
_config = snapshot.config.donate;
});
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
@@ -57,94 +102,16 @@ class DonatePage extends StatelessWidget {
padding: const EdgeInsets.all(16),
child: Column(
children: [
_DonateLinksCard(colorScheme: colorScheme),
_DonateLinksCard(colorScheme: colorScheme, config: _config),
const SizedBox(height: 24),
_RecentDonorsCard(colorScheme: colorScheme),
_RecentDonorsCard(
colorScheme: colorScheme,
supporters: _config.supporters,
),
const SizedBox(height: 16),
Card(
elevation: 0,
color: colorScheme.secondaryContainer.withValues(
alpha: 0.3,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.volunteer_activism_rounded,
size: 20,
color: colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'Good to Know',
style: Theme.of(context).textTheme.titleSmall
?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
],
),
const SizedBox(height: 10),
_NoticeLine(
icon: Icons.block,
text:
'Not selling early access, premium features, or paywalls',
colorScheme: colorScheme,
),
const SizedBox(height: 6),
_NoticeLine(
icon: Icons.build_outlined,
text: 'Funds go to dev tools & testing devices',
colorScheme: colorScheme,
),
const SizedBox(height: 6),
_NoticeLine(
icon: Icons.favorite_border,
text:
'Your support is the only way to keep this project alive',
colorScheme: colorScheme,
),
Divider(
height: 24,
color: colorScheme.outlineVariant.withValues(
alpha: 0.3,
),
),
_NoticeLine(
icon: Icons.history,
text:
'Your name stays permanently in every version it was included in',
colorScheme: colorScheme,
),
const SizedBox(height: 6),
_NoticeLine(
icon: Icons.update,
text:
'Supporter list is updated monthly and embedded in the app',
colorScheme: colorScheme,
),
const SizedBox(height: 6),
_NoticeLine(
icon: Icons.cloud_off,
text:
'No remote server -- everything is stored locally',
colorScheme: colorScheme,
),
],
),
),
_DonateNoticeCard(
colorScheme: colorScheme,
notices: _config.notices,
),
],
),
@@ -156,16 +123,112 @@ class DonatePage extends StatelessWidget {
}
}
class _RecentDonorsCard extends StatelessWidget {
class _DonateLinksCard extends StatelessWidget {
final ColorScheme colorScheme;
final DonateConfig config;
const _RecentDonorsCard({required this.colorScheme});
const _DonateLinksCard({required this.colorScheme, required this.config});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
const donorNames = <String>[];
final cardColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
colorScheme.surface,
)
: Color.alphaBlend(
Colors.black.withValues(alpha: 0.04),
colorScheme.surface,
);
return Card(
elevation: 0,
color: cardColor,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
config.title,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(
fontWeight: FontWeight.w700,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 4),
Text(
config.message,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.35,
),
),
],
),
),
],
),
),
Divider(
height: 1,
thickness: 1,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
if (!config.enabled)
Padding(
padding: const EdgeInsets.all(20),
child: Text(
'Donation links are currently unavailable.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
)
else
for (var index = 0; index < config.methods.length; index++) ...[
_DonateMethodItem(
method: config.methods[index],
colorScheme: colorScheme,
),
if (index < config.methods.length - 1)
Divider(
height: 1,
thickness: 1,
indent: 74,
endIndent: 16,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
],
],
),
);
}
}
class _RecentDonorsCard extends StatelessWidget {
final ColorScheme colorScheme;
final List<String> supporters;
const _RecentDonorsCard({
required this.colorScheme,
required this.supporters,
});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final cardColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
@@ -206,7 +269,7 @@ class _RecentDonorsCard extends StatelessWidget {
),
),
const SizedBox(height: 16),
if (donorNames.isEmpty)
if (supporters.isEmpty)
Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
@@ -221,7 +284,7 @@ class _RecentDonorsCard extends StatelessWidget {
),
const SizedBox(height: 8),
Text(
'No supporters yet be the first!',
'No supporters yet - be the first!',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.6,
@@ -236,7 +299,7 @@ class _RecentDonorsCard extends StatelessWidget {
Wrap(
spacing: 8,
runSpacing: 8,
children: donorNames
children: supporters
.map(
(name) =>
_SupporterChip(name: name, colorScheme: colorScheme),
@@ -250,135 +313,49 @@ class _RecentDonorsCard extends StatelessWidget {
}
}
class _DonateLinksCard extends StatelessWidget {
class _DonateNoticeCard extends StatelessWidget {
final ColorScheme colorScheme;
final List<String> notices;
const _DonateLinksCard({required this.colorScheme});
const _DonateNoticeCard({required this.colorScheme, required this.notices});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final cardColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
colorScheme.surface,
)
: Color.alphaBlend(
Colors.black.withValues(alpha: 0.04),
colorScheme.surface,
);
return Card(
elevation: 0,
color: cardColor,
color: colorScheme.secondaryContainer.withValues(alpha: 0.3),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
_DonateCardItem(
title: 'Ko-fi',
subtitle: 'ko-fi.com/zarzet',
customIcon: const KofiIcon(size: 22, color: Colors.white),
color: const Color(0xFFFF5E5B),
url: AppInfo.kofiUrl,
colorScheme: colorScheme,
),
Divider(
height: 1,
thickness: 1,
indent: 74,
endIndent: 16,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
_DonateCardItem(
title: 'GitHub Sponsors',
subtitle: 'github.com/sponsors/zarzet',
customIcon: const GitHubIcon(size: 22, color: Colors.white),
color: const Color(0xFF2D333B),
url: AppInfo.githubSponsorsUrl,
colorScheme: colorScheme,
),
Divider(
height: 1,
thickness: 1,
indent: 74,
endIndent: 16,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
_CryptoWalletItem(
title: 'USDT (TRC20)',
walletAddress: 'TL7iAqjq9M8BwVMi9AtHvuAGHtdwEvsDta',
color: const Color(0xFF26A17B),
colorScheme: colorScheme,
),
],
),
);
}
}
class _DonateCardItem extends StatelessWidget {
final String title;
final String subtitle;
final Widget customIcon;
final Color color;
final String url;
final ColorScheme colorScheme;
const _DonateCardItem({
required this.title,
required this.subtitle,
required this.customIcon,
required this.color,
required this.url,
required this.colorScheme,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () =>
launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(12),
),
child: Center(child: customIcon),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
Row(
children: [
Icon(
Icons.volunteer_activism_rounded,
size: 20,
color: colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'Good to Know',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
const SizedBox(height: 2),
Text(
subtitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
],
),
const SizedBox(height: 10),
for (var index = 0; index < notices.length; index++) ...[
_NoticeLine(
icon: _noticeIcon(index),
text: notices[index],
colorScheme: colorScheme,
),
),
Icon(
Icons.open_in_new,
size: 18,
color: colorScheme.onSurfaceVariant,
),
if (index < notices.length - 1) const SizedBox(height: 6),
],
],
),
),
@@ -386,32 +363,18 @@ class _DonateCardItem extends StatelessWidget {
}
}
class _CryptoWalletItem extends StatelessWidget {
final String title;
final String walletAddress;
final Color color;
class _DonateMethodItem extends StatelessWidget {
final DonateMethod method;
final ColorScheme colorScheme;
const _CryptoWalletItem({
required this.title,
required this.walletAddress,
required this.color,
required this.colorScheme,
});
const _DonateMethodItem({required this.method, required this.colorScheme});
@override
Widget build(BuildContext context) {
final color = Color(method.color);
return InkWell(
onTap: () {
Clipboard.setData(ClipboardData(text: walletAddress));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('$title address copied to clipboard'),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 2),
),
);
},
onTap: () => _handleTap(context),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
@@ -423,16 +386,7 @@ class _CryptoWalletItem extends StatelessWidget {
color: color,
borderRadius: BorderRadius.circular(12),
),
child: const Center(
child: Text(
'\$',
style: TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
),
child: Center(child: _methodIcon(method)),
),
const SizedBox(width: 14),
Expanded(
@@ -440,7 +394,7 @@ class _CryptoWalletItem extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
method.title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
@@ -448,10 +402,12 @@ class _CryptoWalletItem extends StatelessWidget {
),
const SizedBox(height: 2),
Text(
walletAddress,
method.subtitle.isEmpty
? method.walletAddress ?? method.url ?? ''
: method.subtitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 11,
fontSize: method.isWallet ? 11 : null,
),
overflow: TextOverflow.ellipsis,
),
@@ -459,7 +415,7 @@ class _CryptoWalletItem extends StatelessWidget {
),
),
Icon(
Icons.copy_rounded,
method.isWallet ? Icons.copy_rounded : Icons.open_in_new,
size: 18,
color: colorScheme.onSurfaceVariant,
),
@@ -468,6 +424,30 @@ class _CryptoWalletItem extends StatelessWidget {
),
);
}
Future<void> _handleTap(BuildContext context) async {
if (method.isWallet) {
await Clipboard.setData(ClipboardData(text: method.walletAddress!));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${method.title} address copied to clipboard'),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 2),
),
);
}
return;
}
final url = method.url;
if (url == null || url.isEmpty) return;
final uri = Uri.tryParse(url);
if (uri == null) return;
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
class _SupporterChip extends StatelessWidget {
@@ -543,3 +523,44 @@ class _NoticeLine extends StatelessWidget {
);
}
}
Widget _methodIcon(DonateMethod method) {
switch (method.icon.toLowerCase()) {
case 'kofi':
case 'ko-fi':
return const KofiIcon(size: 22, color: Colors.white);
case 'github':
case 'github-sponsors':
return const GitHubIcon(size: 22, color: Colors.white);
case 'crypto':
case 'wallet':
return const Text(
'\$',
style: TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold,
),
);
case 'coffee':
return const Icon(
Icons.local_cafe_rounded,
color: Colors.white,
size: 22,
);
case 'heart':
default:
return const Icon(Icons.favorite_rounded, color: Colors.white, size: 22);
}
}
IconData _noticeIcon(int index) {
const icons = [
Icons.block,
Icons.build_outlined,
Icons.favorite_border,
Icons.history,
Icons.update,
];
return icons[index % icons.length];
}
+461
View File
@@ -0,0 +1,461 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('AppRemoteConfig');
class AppRemoteConfig {
final RemoteAnnouncement? announcement;
final DonateConfig donate;
const AppRemoteConfig({this.announcement, required this.donate});
factory AppRemoteConfig.fromJson(Map<String, dynamic> json) {
final announcementJson = json['announcement'];
final donateJson = json['donate'];
return AppRemoteConfig(
announcement: announcementJson is Map
? RemoteAnnouncement.fromJson(
Map<String, dynamic>.from(announcementJson),
)
: null,
donate: donateJson is Map
? DonateConfig.fromJson(Map<String, dynamic>.from(donateJson))
: DonateConfig.fallback(),
);
}
}
class RemoteConfigSnapshot {
final AppRemoteConfig config;
final String rawJson;
final bool changed;
const RemoteConfigSnapshot({
required this.config,
required this.rawJson,
required this.changed,
});
}
class RemoteAnnouncement {
final String id;
final bool enabled;
final String title;
final String message;
final bool ctaEnabled;
final String? ctaLabel;
final String? ctaUrl;
final bool dismissible;
final DateTime? startsAt;
final DateTime? endsAt;
final String? minVersion;
final String? maxVersion;
final String priority;
const RemoteAnnouncement({
required this.id,
required this.enabled,
required this.title,
required this.message,
this.ctaEnabled = false,
this.ctaLabel,
this.ctaUrl,
this.dismissible = true,
this.startsAt,
this.endsAt,
this.minVersion,
this.maxVersion,
this.priority = 'normal',
});
factory RemoteAnnouncement.fromJson(Map<String, dynamic> json) {
return RemoteAnnouncement(
id: _readString(json['id']),
enabled: json['enabled'] as bool? ?? true,
title: _readString(json['title']),
message: _readString(json['message']),
ctaEnabled: _readBool(json['cta_enabled'] ?? json['ctaEnabled']),
ctaLabel: _readNullableString(json['cta_label'] ?? json['ctaLabel']),
ctaUrl: _readNullableString(json['cta_url'] ?? json['ctaUrl']),
dismissible: json['dismissible'] as bool? ?? true,
startsAt: _readDate(json['starts_at'] ?? json['startsAt']),
endsAt: _readDate(json['ends_at'] ?? json['endsAt']),
minVersion: _readNullableString(
json['min_version'] ?? json['minVersion'],
),
maxVersion: _readNullableString(
json['max_version'] ?? json['maxVersion'],
),
priority: _readString(json['priority']).isEmpty
? 'normal'
: _readString(json['priority']),
);
}
bool get hasCta =>
ctaEnabled &&
ctaLabel != null &&
ctaLabel!.isNotEmpty &&
ctaUrl != null &&
ctaUrl!.isNotEmpty;
bool isActive({DateTime? now, String currentVersion = AppInfo.version}) {
if (!enabled || id.isEmpty || title.isEmpty || message.isEmpty) {
return false;
}
final referenceTime = now ?? DateTime.now();
if (startsAt != null && referenceTime.isBefore(startsAt!)) {
return false;
}
if (endsAt != null && referenceTime.isAfter(endsAt!)) {
return false;
}
if (minVersion != null &&
minVersion!.isNotEmpty &&
_compareVersions(currentVersion, minVersion!) < 0) {
return false;
}
if (maxVersion != null &&
maxVersion!.isNotEmpty &&
_compareVersions(currentVersion, maxVersion!) > 0) {
return false;
}
return true;
}
}
class DonateConfig {
final bool enabled;
final String title;
final String message;
final List<DonateMethod> methods;
final List<String> supporters;
final List<String> notices;
const DonateConfig({
required this.enabled,
required this.title,
required this.message,
required this.methods,
required this.supporters,
required this.notices,
});
factory DonateConfig.fromJson(Map<String, dynamic> json) {
final methods = (json['methods'] as List<dynamic>? ?? const [])
.whereType<Map<Object?, Object?>>()
.map((value) => DonateMethod.fromJson(Map<String, dynamic>.from(value)))
.where((method) => method.isValid)
.toList(growable: false);
return DonateConfig(
enabled: json['enabled'] as bool? ?? true,
title: _readString(json['title']).isEmpty
? 'Support Development'
: _readString(json['title']),
message: _readString(json['message']).isEmpty
? 'Optional support helps cover tools, testing devices, and hosting.'
: _readString(json['message']),
methods: methods.isEmpty ? DonateConfig.fallback().methods : methods,
supporters: _readStringList(
json['supporters'] ?? json['recent_supporters'],
),
notices: _readStringList(json['notices']).isEmpty
? DonateConfig.fallback().notices
: _readStringList(json['notices']),
);
}
factory DonateConfig.fallback() {
return const DonateConfig(
enabled: true,
title: 'Support Development',
message: 'Optional support helps cover dev tools and testing devices.',
methods: [
DonateMethod(
id: 'kofi',
title: 'Ko-fi',
subtitle: 'ko-fi.com/zarzet',
url: AppInfo.kofiUrl,
icon: 'kofi',
color: 0xFFFF5E5B,
),
DonateMethod(
id: 'github-sponsors',
title: 'GitHub Sponsors',
subtitle: 'github.com/sponsors/zarzet',
url: AppInfo.githubSponsorsUrl,
icon: 'github',
color: 0xFF2D333B,
),
DonateMethod(
id: 'usdt-trc20',
title: 'USDT (TRC20)',
subtitle: 'TL7iAqjq9M8BwVMi9AtHvuAGHtdwEvsDta',
walletAddress: 'TL7iAqjq9M8BwVMi9AtHvuAGHtdwEvsDta',
icon: 'crypto',
color: 0xFF26A17B,
),
],
supporters: [],
notices: [
'Not selling early access, premium features, or paywalls',
'Funds go to dev tools and testing devices',
'Your support helps keep this project active',
'Supporter list can be updated from the app API',
],
);
}
}
class DonateMethod {
final String id;
final String title;
final String subtitle;
final String? url;
final String? walletAddress;
final String icon;
final int color;
const DonateMethod({
required this.id,
required this.title,
required this.subtitle,
this.url,
this.walletAddress,
this.icon = 'heart',
this.color = 0xFF6750A4,
});
factory DonateMethod.fromJson(Map<String, dynamic> json) {
return DonateMethod(
id: _readString(json['id']),
title: _readString(json['title'] ?? json['label']),
subtitle: _readString(json['subtitle']),
url: _readNullableString(json['url']),
walletAddress: _readNullableString(
json['wallet_address'] ?? json['walletAddress'],
),
icon: _readString(json['icon']).isEmpty
? 'heart'
: _readString(json['icon']),
color: _readColor(json['color']) ?? 0xFF6750A4,
);
}
bool get isWallet => walletAddress != null && walletAddress!.isNotEmpty;
bool get isLink => url != null && url!.isNotEmpty;
bool get isValid => id.isNotEmpty && title.isNotEmpty && (isLink || isWallet);
}
class AppRemoteConfigService {
static const _cachedConfigJsonKey = 'app_remote_config_cached_json';
static const _cachedConfigFetchedAtKey =
'app_remote_config_cached_fetched_at';
static const _dismissedAnnouncementIdsKey =
'app_remote_config_dismissed_announcement_ids';
final http.Client _client;
final String endpoint;
AppRemoteConfigService({
http.Client? client,
this.endpoint = AppInfo.remoteConfigApiUrl,
}) : _client = client ?? http.Client();
Future<AppRemoteConfig?> fetchConfig({String? locale}) async {
final snapshot = await fetchConfigSnapshot(locale: locale);
return snapshot?.config;
}
Future<RemoteConfigSnapshot?> readCachedConfig() async {
final prefs = await SharedPreferences.getInstance();
final cachedJson = prefs.getString(_cachedConfigJsonKey);
if (cachedJson == null || cachedJson.isEmpty) {
return null;
}
return _parseSnapshot(cachedJson, changed: false);
}
Future<RemoteConfigSnapshot?> fetchConfigSnapshot({String? locale}) async {
try {
final uri = Uri.parse(endpoint).replace(
queryParameters: {
'platform': Platform.isAndroid ? 'android' : Platform.operatingSystem,
'version': AppInfo.version,
'build': AppInfo.buildNumber,
if (locale != null && locale.isNotEmpty) 'locale': locale,
},
);
final response = await _client
.get(uri, headers: {'Accept': 'application/json'})
.timeout(const Duration(seconds: 8));
if (response.statusCode != 200) {
_log.w('Remote config API returned ${response.statusCode}');
return null;
}
final snapshot = _parseSnapshot(response.body);
if (snapshot == null) return null;
final prefs = await SharedPreferences.getInstance();
final cachedJson = prefs.getString(_cachedConfigJsonKey);
if (cachedJson != snapshot.rawJson) {
await prefs.setString(_cachedConfigJsonKey, snapshot.rawJson);
await prefs.setString(
_cachedConfigFetchedAtKey,
DateTime.now().toIso8601String(),
);
return RemoteConfigSnapshot(
config: snapshot.config,
rawJson: snapshot.rawJson,
changed: true,
);
}
return snapshot;
} catch (e) {
_log.w('Remote config fetch failed: $e');
return null;
}
}
Future<RemoteAnnouncement?> fetchActiveAnnouncement({String? locale}) async {
final snapshot =
await fetchConfigSnapshot(locale: locale) ?? await readCachedConfig();
final announcement = snapshot?.config.announcement;
if (announcement == null || !announcement.isActive()) {
return null;
}
final prefs = await SharedPreferences.getInstance();
final dismissedIds =
prefs.getStringList(_dismissedAnnouncementIdsKey) ?? const <String>[];
if (dismissedIds.contains(announcement.id)) {
return null;
}
return announcement;
}
Future<void> markAnnouncementDismissed(String id) async {
if (id.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final dismissedIds =
prefs.getStringList(_dismissedAnnouncementIdsKey) ?? const <String>[];
if (dismissedIds.contains(id)) return;
await prefs.setStringList(_dismissedAnnouncementIdsKey, [
...dismissedIds,
id,
]);
}
RemoteConfigSnapshot? _parseSnapshot(String body, {bool changed = false}) {
try {
final decoded = jsonDecode(body);
if (decoded is! Map) {
_log.w('Remote config API returned non-object JSON');
return null;
}
final normalizedJson = jsonEncode(decoded);
return RemoteConfigSnapshot(
config: AppRemoteConfig.fromJson(Map<String, dynamic>.from(decoded)),
rawJson: normalizedJson,
changed: changed,
);
} catch (e) {
_log.w('Remote config JSON parse failed: $e');
return null;
}
}
}
String _readString(Object? value) {
return value is String ? value.trim() : '';
}
String? _readNullableString(Object? value) {
final text = _readString(value);
return text.isEmpty ? null : text;
}
bool _readBool(Object? value) {
if (value is bool) return value;
if (value is String) {
final normalized = value.trim().toLowerCase();
return normalized == 'true' || normalized == '1' || normalized == 'yes';
}
return false;
}
DateTime? _readDate(Object? value) {
final text = _readString(value);
return text.isEmpty ? null : DateTime.tryParse(text);
}
List<String> _readStringList(Object? value) {
if (value is! List) return const [];
return value
.whereType<String>()
.map((text) => text.trim())
.where((text) => text.isNotEmpty)
.toList(growable: false);
}
int? _readColor(Object? value) {
if (value is int) {
return value;
}
if (value is! String) {
return null;
}
final normalized = value.trim().replaceFirst('#', '').replaceFirst('0x', '');
if (normalized.length == 6) {
return int.tryParse('FF$normalized', radix: 16);
}
if (normalized.length == 8) {
return int.tryParse(normalized, radix: 16);
}
return null;
}
int _compareVersions(String left, String right) {
final leftParts = _versionParts(left);
final rightParts = _versionParts(right);
for (var index = 0; index < 3; index++) {
if (leftParts[index] > rightParts[index]) return 1;
if (leftParts[index] < rightParts[index]) return -1;
}
return 0;
}
List<int> _versionParts(String version) {
final base = version.split('-').first;
final parts = base
.split('.')
.map((part) => int.tryParse(part) ?? 0)
.toList(growable: true);
while (parts.length < 3) {
parts.add(0);
}
return parts.take(3).toList(growable: false);
}
+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);
}
}
+124
View File
@@ -4,6 +4,7 @@ import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/models/theme_settings.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/services/app_remote_config_service.dart';
import 'package:spotiflac_android/services/download_request_payload.dart';
import 'package:spotiflac_android/utils/artist_utils.dart';
import 'package:spotiflac_android/utils/mime_utils.dart';
@@ -507,4 +508,127 @@ void main() {
expect(keys, contains('C:/Music/Song'));
});
});
group('AppRemoteConfig', () {
test('parses announcement and donate payloads from API JSON', () {
final config = AppRemoteConfig.fromJson({
'announcement': {
'id': 'hello-2026',
'enabled': true,
'title': 'Server message',
'message': 'A clear message for users',
'cta_enabled': true,
'cta_label': 'Donate',
'cta_url': 'https://example.test/donate',
'starts_at': '2026-05-01T00:00:00Z',
'ends_at': '2026-06-01T00:00:00Z',
'min_version': '4.5.0',
'priority': 'high',
},
'donate': {
'enabled': true,
'title': 'Support SpotiFLAC Mobile',
'message': 'Help cover infrastructure.',
'methods': [
{
'id': 'kofi',
'title': 'Ko-fi',
'subtitle': 'ko-fi.com/example',
'url': 'https://ko-fi.com/example',
'icon': 'kofi',
'color': '#FF5E5B',
},
{
'id': 'wallet',
'title': 'USDT',
'subtitle': 'TRC20',
'wallet_address': 'T123',
'icon': 'wallet',
'color': '0xFF26A17B',
},
],
'supporters': ['Alice', 'Bob'],
'notices': ['No paywalls'],
},
});
expect(config.announcement?.id, 'hello-2026');
expect(config.announcement?.hasCta, isTrue);
expect(
config.announcement?.isActive(
now: DateTime.utc(2026, 5, 11),
currentVersion: '4.5.1',
),
isTrue,
);
expect(config.donate.title, 'Support SpotiFLAC Mobile');
expect(config.donate.methods, hasLength(2));
expect(config.donate.methods.first.color, 0xFFFF5E5B);
expect(config.donate.methods.last.isWallet, isTrue);
expect(config.donate.supporters, ['Alice', 'Bob']);
expect(config.donate.notices, ['No paywalls']);
});
test('requires enabled announcement CTA with label and url', () {
final disabledCta = RemoteAnnouncement.fromJson({
'id': 'notice',
'title': 'Notice',
'message': 'No button',
'cta_label': 'Open',
'cta_url': 'https://api.zarz.moe',
});
final missingLabel = RemoteAnnouncement.fromJson({
'id': 'notice',
'title': 'Notice',
'message': 'No button',
'cta_enabled': true,
'cta_url': 'https://example.test',
});
final enabledCta = RemoteAnnouncement.fromJson({
'id': 'notice',
'title': 'Notice',
'message': 'With button',
'cta_enabled': true,
'cta_label': 'Read More',
'cta_url': 'https://example.test',
});
expect(disabledCta.hasCta, isFalse);
expect(missingLabel.hasCta, isFalse);
expect(enabledCta.hasCta, isTrue);
expect(enabledCta.ctaLabel, 'Read More');
});
test('filters inactive announcements by window and app version', () {
final announcement = RemoteAnnouncement.fromJson({
'id': 'future',
'title': 'Future',
'message': 'Not yet',
'starts_at': '2026-06-01T00:00:00Z',
'min_version': '4.6.0',
});
expect(
announcement.isActive(
now: DateTime.utc(2026, 5, 11),
currentVersion: '4.5.1',
),
isFalse,
);
expect(
announcement.isActive(
now: DateTime.utc(2026, 6, 2),
currentVersion: '4.5.1',
),
isFalse,
);
expect(
announcement.isActive(
now: DateTime.utc(2026, 6, 2),
currentVersion: '4.6.0',
),
isTrue,
);
});
});
}