mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-15 05:10:28 +02:00
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:
@@ -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/';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user