diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index a56af88f..90cb9c0e 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -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/'; diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index a0f8150a..805637e0 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -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 late final PageController _pageController; late final AnimationController _tabJumpTransitionController; bool _hasCheckedUpdate = false; + bool _hasCheckedAppAnnouncement = false; StreamSubscription? _shareSubscription; DateTime? _lastBackPress; final GlobalKey _homeTabNavigatorKey = @@ -66,10 +69,13 @@ class _MainShellState extends ConsumerState 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 } } - Future _checkForUpdates() async { - if (_hasCheckedUpdate) return; + Future _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 ref.read(settingsProvider.notifier).setCheckForUpdates(false); }, ); + return true; } + + return false; + } + + Future _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'; diff --git a/lib/screens/settings/donate_page.dart b/lib/screens/settings/donate_page.dart index 8a3880b1..6aa4ad6c 100644 --- a/lib/screens/settings/donate_page.dart +++ b/lib/screens/settings/donate_page.dart @@ -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 createState() => _DonatePageState(); +} + +class _DonatePageState extends State { + 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 _loadConfig(String locale) async { + final service = AppRemoteConfigService(); + final cached = await service.readCachedConfig(); + if (!mounted) return; + + if (cached != null) { + _applyRemoteConfig(cached); + } + + unawaited(_refreshConfigCache(locale)); + } + + Future _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 = []; + 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 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 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 _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]; +} diff --git a/lib/services/app_remote_config_service.dart b/lib/services/app_remote_config_service.dart new file mode 100644 index 00000000..223a43f4 --- /dev/null +++ b/lib/services/app_remote_config_service.dart @@ -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 json) { + final announcementJson = json['announcement']; + final donateJson = json['donate']; + + return AppRemoteConfig( + announcement: announcementJson is Map + ? RemoteAnnouncement.fromJson( + Map.from(announcementJson), + ) + : null, + donate: donateJson is Map + ? DonateConfig.fromJson(Map.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 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 methods; + final List supporters; + final List 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 json) { + final methods = (json['methods'] as List? ?? const []) + .whereType>() + .map((value) => DonateMethod.fromJson(Map.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 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 fetchConfig({String? locale}) async { + final snapshot = await fetchConfigSnapshot(locale: locale); + return snapshot?.config; + } + + Future 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 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 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 []; + if (dismissedIds.contains(announcement.id)) { + return null; + } + + return announcement; + } + + Future markAnnouncementDismissed(String id) async { + if (id.isEmpty) return; + + final prefs = await SharedPreferences.getInstance(); + final dismissedIds = + prefs.getStringList(_dismissedAnnouncementIdsKey) ?? const []; + 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.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 _readStringList(Object? value) { + if (value is! List) return const []; + return value + .whereType() + .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 _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); +} diff --git a/lib/widgets/app_announcement_dialog.dart b/lib/widgets/app_announcement_dialog.dart new file mode 100644 index 00000000..612f23c6 --- /dev/null +++ b/lib/widgets/app_announcement_dialog.dart @@ -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 _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 showAppAnnouncementDialog( + BuildContext context, { + required RemoteAnnouncement announcement, + required VoidCallback onDismiss, +}) { + return showDialog( + context: context, + barrierDismissible: announcement.dismissible, + builder: (context) => + AppAnnouncementDialog(announcement: announcement, onDismiss: onDismiss), + ).whenComplete(onDismiss); +} diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index c6dad072..21fc0f02 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -78,7 +78,8 @@ class _DownloadServicePickerState extends ConsumerState { }); 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); } } diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index d4082e26..965cff9f 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -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, + ); + }); + }); }