diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index c9c71387..7dae792b 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -5330,6 +5330,14 @@ "@extensionsHomeFeedProvider": { "description": "Extensions page - label for home feed provider selector" }, + "extensionsHomeFeedCountry": "Home feed country", + "@extensionsHomeFeedCountry": { + "description": "Country picker for the active Home feed extension" + }, + "extensionsHomeFeedCountryDescription": "Choose a country's Home recommendations without a VPN.", + "@extensionsHomeFeedCountryDescription": { + "description": "Help for choosing regional Home recommendations" + }, "extensionsHomeFeedDescription": "Choose which extension provides the home feed on the main screen", "@extensionsHomeFeedDescription": { "description": "Extensions page - description for home feed provider picker" @@ -6985,6 +6993,14 @@ "@regionCountryUS": { "description": "Country name for SongLink region picker" }, + "regionCountryIT": "Italy", + "@regionCountryIT": { + "description": "Country name for Italy" + }, + "regionCountryES": "Spain", + "@regionCountryES": { + "description": "Country name for Spain" + }, "regionCountryGB": "United Kingdom", "@regionCountryGB": { "description": "Country name for SongLink region picker" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index b763a60f..c969c093 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -5358,6 +5358,8 @@ "@extensionsHomeFeedProvider": { "description": "Extensions page - label for home feed provider selector" }, + "extensionsHomeFeedCountry": "Negara beranda", + "extensionsHomeFeedCountryDescription": "Pilih rekomendasi beranda suatu negara tanpa VPN.", "extensionsHomeFeedDescription": "Pilih ekstensi provider feed beranda di layar utama", "@extensionsHomeFeedDescription": { "description": "Extensions page - description for home feed provider picker" @@ -7025,6 +7027,8 @@ "@regionCountryUS": { "description": "Country name for SongLink region picker" }, + "regionCountryIT": "Italia", + "regionCountryES": "Spanyol", "regionCountryGB": "Inggris Raya", "@regionCountryGB": { "description": "Country name for SongLink region picker" diff --git a/lib/models/extension.dart b/lib/models/extension.dart index a0f0c96c..cdd07724 100644 --- a/lib/models/extension.dart +++ b/lib/models/extension.dart @@ -244,6 +244,22 @@ class Extension { bool get hasPostProcessing => postProcessing?.enabled ?? false; bool get hasServiceHealth => serviceHealth.isNotEmpty; bool get hasHomeFeed => capabilities['homeFeed'] == true; + + /// The extension owns its country choices and the setting sent to its API. + ExtensionSetting? get homeFeedRegionSetting { + if (!hasHomeFeed) return null; + final key = capabilities['homeFeedRegionSetting']; + if (key is! String || key.isEmpty) return null; + return settings.where((setting) { + final options = setting.options; + return setting.key == key && + (setting.type == 'select' || setting.type == 'string') && + options != null && + options.isNotEmpty && + options.every((code) => RegExp(r'^[a-zA-Z]{2}$').hasMatch(code)); + }).firstOrNull; + } + ExtensionDownloadTransferPolicy get downloadTransferPolicy => ExtensionDownloadTransferPolicy.fromCapabilities(capabilities); bool get requiresNativeContainerConversion => @@ -760,6 +776,7 @@ class ExtensionState { final bool isLoading; final String? error; final bool isInitialized; + final Map settingsRevisions; const ExtensionState({ this.extensions = const [], @@ -769,6 +786,7 @@ class ExtensionState { this.isLoading = false, this.error, this.isInitialized = false, + this.settingsRevisions = const {}, }); ExtensionState copyWith({ @@ -779,6 +797,7 @@ class ExtensionState { bool? isLoading, String? error, bool? isInitialized, + Map? settingsRevisions, }) { return ExtensionState( extensions: extensions ?? this.extensions, @@ -789,6 +808,7 @@ class ExtensionState { isLoading: isLoading ?? this.isLoading, error: error, isInitialized: isInitialized ?? this.isInitialized, + settingsRevisions: settingsRevisions ?? this.settingsRevisions, ); } } diff --git a/lib/providers/explore_provider.dart b/lib/providers/explore_provider.dart index 2d3503e2..58721f34 100644 --- a/lib/providers/explore_provider.dart +++ b/lib/providers/explore_provider.dart @@ -12,6 +12,21 @@ import 'package:spotiflac_android/providers/settings_provider.dart'; final _log = AppLogger('ExploreProvider'); +final homeFeedExtensionProvider = Provider((ref) { + final preferred = ref.watch( + settingsProvider.select((settings) => settings.homeFeedProvider), + ); + if (preferred == AppSettings.homeFeedProviderOff) return null; + final extensions = ref.watch( + extensionProvider.select((state) => state.extensions), + ); + final available = extensions.where((ext) => ext.enabled && ext.hasHomeFeed); + if (preferred != null && preferred.isNotEmpty) { + return available.where((ext) => ext.id == preferred).firstOrNull; + } + return available.firstOrNull; +}); + class ExploreItem { final String id; final String uri; @@ -256,7 +271,11 @@ Map _decodeExploreCache(String rawCache) { sections = _withDefaultExploreProviderId(sections, providerId); } - return {'provider_id': providerId, 'sections': sections}; + return { + 'provider_id': providerId, + 'region': decoded['region'], + 'sections': sections, + }; } String _encodeExploreCache(Map cachePayload) { @@ -295,6 +314,15 @@ class ExploreNotifier extends Notifier { @override ExploreState build() { + ref.listen(extensionProvider.select((state) => state.settingsRevisions), ( + previous, + next, + ) { + final extension = _resolveHomeFeedExtension(); + if (extension != null && previous?[extension.id] != next[extension.id]) { + _reloadConfiguration(extension.id); + } + }); _restoreFromCache(); return const ExploreState(); } @@ -328,6 +356,21 @@ class ExploreNotifier extends Notifier { final resolvedProviderId = providerId?.isNotEmpty == true ? providerId : _resolveHomeFeedExtension()?.id; + var extension = _resolveHomeFeedExtension(); + if (extension == null && cachePayload['region'] != null) { + // Regional caches cannot be validated until installed manifests and + // their saved settings are available on a cold launch. + await ref.read(extensionProvider.notifier).ensureInitialized(); + if (!_isCacheRestoreValid(generation)) return; + extension = _resolveHomeFeedExtension(); + if (extension == null) return; + } + if (extension != null) { + if (resolvedProviderId != extension.id) return; + final region = await _homeFeedRegion(extension); + if (!_isCacheRestoreValid(generation)) return; + if (cachePayload['region'] != region) return; + } if (resolvedProviderId != null && resolvedProviderId.isNotEmpty) { normalizedSections = _withDefaultExploreProviderId( normalizedSections, @@ -369,34 +412,53 @@ class ExploreNotifier extends Notifier { } Extension? _resolveHomeFeedExtension() { - final settings = ref.read(settingsProvider); - final preferredId = settings.homeFeedProvider; - final enabledHomeFeedExtensions = ref - .read(extensionProvider) - .extensions - .where((extension) => extension.enabled && extension.hasHomeFeed) - .toList(growable: false); + return ref.read(homeFeedExtensionProvider); + } - if (preferredId != null && preferredId.isNotEmpty) { - return enabledHomeFeedExtensions - .where((extension) => extension.id == preferredId) - .firstOrNull; + Future _homeFeedRegion(Extension extension) async { + final setting = extension.homeFeedRegionSetting; + if (setting == null) return null; + final settings = await ref.read( + extensionSettingsProvider(extension.id).future, + ); + return (settings[setting.key] ?? setting.defaultValue) + ?.toString() + .trim() + .toUpperCase(); + } + + Future _reloadConfiguration(String extensionId) async { + clear(); + final generation = _homeFeedRequestId; + try { + final prefs = await SharedPreferences.getInstance(); + if (!_isRequestValid(generation)) return; + await ExploreCacheStore(prefs).clear(); + } catch (error) { + _log.w('Failed to clear changed home feed: $error'); + } + if (_isRequestValid(generation) && + _resolveHomeFeedExtension()?.id == extensionId) { + await refresh(); } - - return enabledHomeFeedExtensions.firstOrNull; } Future _saveToCache( List> normalizedSections, String providerId, + String? region, + int requestId, ) async { try { final prefs = await SharedPreferences.getInstance(); final encoded = await compute(_encodeExploreCache, { 'provider_id': providerId, + 'region': region, 'sections': normalizedSections, }); - await ExploreCacheStore(prefs).write(encoded); + if (_isRequestValid(requestId)) { + await ExploreCacheStore(prefs).write(encoded); + } } catch (e) { _log.w('Failed to save explore cache: $e'); } @@ -440,6 +502,8 @@ class ExploreNotifier extends Notifier { return; } + final region = await _homeFeedRegion(targetExt); + if (!_isRequestValid(requestId)) return; final result = await PlatformBridge.getExtensionHomeFeed( targetExt.id, cancelPrevious: forceRefresh, @@ -490,7 +554,7 @@ class ExploreNotifier extends Notifier { lastFetched: DateTime.now(), ); - _saveToCache(normalizedSections, targetExt.id); + _saveToCache(normalizedSections, targetExt.id, region, requestId); } catch (e, stack) { _log.e('Error fetching home feed: $e', e, stack); if (!_isRequestValid(requestId)) return; diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index 89f37945..c46de08a 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -22,6 +22,11 @@ const _metadataProviderPriorityKey = 'metadata_provider_priority'; const _providerPriorityKey = 'provider_priority'; const _storeRegistryUrlPrefKey = 'store_registry_url'; +final extensionSettingsProvider = FutureProvider.autoDispose + .family, String>((ref, extensionId) { + return PlatformBridge.getExtensionSettings(extensionId); + }); + /// Result of restoring extensions from a backup. class ExtensionNotifier extends Notifier { static const _extensionHealthDefaultCacheTtl = Duration(minutes: 10); @@ -784,7 +789,7 @@ class ExtensionNotifier extends Notifier { ...current, ...Map.from(settings), }; - await PlatformBridge.setExtensionSettings(id, merged); + await setExtensionSettings(id, merged); } catch (e) { _log.w('Restore: failed to apply settings for $id: $e'); } diff --git a/lib/providers/extension_provider_priority.dart b/lib/providers/extension_provider_priority.dart index abb6a125..6169097f 100644 --- a/lib/providers/extension_provider_priority.dart +++ b/lib/providers/extension_provider_priority.dart @@ -236,16 +236,47 @@ extension ExtensionNotifierProviderPriority on ExtensionNotifier { } } - Future setExtensionSettings( + Future setExtensionSettings( String extensionId, Map settings, ) async { try { await PlatformBridge.setExtensionSettings(extensionId, settings); + ref.invalidate(extensionSettingsProvider(extensionId)); + state = state.copyWith( + settingsRevisions: { + ...state.settingsRevisions, + extensionId: (state.settingsRevisions[extensionId] ?? 0) + 1, + }, + ); _log.d('Updated settings for extension: $extensionId'); + return true; } catch (e) { _log.e('Failed to set extension settings: $e'); state = state.copyWith(error: e.toString()); + return false; + } + } + + Future setHomeFeedRegion(String extensionId, String region) async { + final extension = state.extensions + .where((extension) => extension.id == extensionId) + .firstOrNull; + final setting = extension?.homeFeedRegionSetting; + if (setting == null || !setting.options!.contains(region)) return false; + try { + final settings = await PlatformBridge.getExtensionSettings(extensionId); + if ((settings[setting.key] ?? setting.defaultValue) == region) { + return true; + } + return await setExtensionSettings(extensionId, { + ...settings, + setting.key: region, + }); + } catch (error) { + _log.e('Failed to update home feed country: $error'); + state = state.copyWith(error: error.toString()); + return false; } } diff --git a/lib/screens/settings/extension_detail_page.dart b/lib/screens/settings/extension_detail_page.dart index 447eef3f..31df40ae 100644 --- a/lib/screens/settings/extension_detail_page.dart +++ b/lib/screens/settings/extension_detail_page.dart @@ -18,6 +18,7 @@ import 'package:spotiflac_android/providers/repo_provider.dart'; import 'package:spotiflac_android/utils/extension_auth_launcher.dart'; import 'package:spotiflac_android/utils/extension_action_forms.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; +import 'package:spotiflac_android/utils/extension_country_name.dart'; import 'package:spotiflac_android/widgets/app_icon.dart'; import 'package:spotiflac_android/widgets/app_icon_button.dart'; import 'package:spotiflac_android/widgets/app_loading_indicator.dart'; @@ -268,6 +269,8 @@ class _ExtensionDetailPageState extends ConsumerState { final setting = entry.value; return _SettingItem( key: ValueKey(setting.key), + countryOptions: + setting.key == extension.homeFeedRegionSetting?.key, setting: setting, value: _settings[setting.key] ?? setting.defaultValue, showDivider: index < extension.settings.length - 1, @@ -1026,6 +1029,7 @@ class _SettingItem extends StatefulWidget { final bool showDivider; final ValueChanged onChanged; final String extensionId; + final bool countryOptions; final Future Function(Map payload)? onActionPayload; const _SettingItem({ @@ -1034,6 +1038,7 @@ class _SettingItem extends StatefulWidget { required this.value, required this.onChanged, required this.extensionId, + this.countryOptions = false, this.onActionPayload, this.showDivider = true, }); @@ -1065,7 +1070,9 @@ class _SettingItemState extends State<_SettingItem> { ? _showOptions : null; footer = Text( - widget.value?.toString() ?? context.l10n.extensionSettingNotSet, + widget.countryOptions + ? extensionCountryName(context, widget.value?.toString() ?? '') + : widget.value?.toString() ?? context.l10n.extensionSettingNotSet, style: Theme.of( context, ).textTheme.bodyMedium?.copyWith(color: colorScheme.primary), @@ -1090,7 +1097,13 @@ class _SettingItemState extends State<_SettingItem> { colorScheme: colorScheme, ) : Text( - widget.value?.toString() ?? context.l10n.extensionSettingNotSet, + widget.countryOptions + ? extensionCountryName( + context, + widget.value?.toString() ?? '', + ) + : widget.value?.toString() ?? + context.l10n.extensionSettingNotSet, style: Theme.of( context, ).textTheme.bodyMedium?.copyWith(color: colorScheme.primary), @@ -1120,7 +1133,11 @@ class _SettingItemState extends State<_SettingItem> { children: [ for (final option in widget.setting.options ?? []) AppSheetOption.choice( - title: Text(option), + title: Text( + widget.countryOptions + ? extensionCountryName(sheetContext, option) + : option, + ), selected: option == widget.value, onTap: () => Navigator.pop(sheetContext, option), ), diff --git a/lib/screens/settings/extensions_page.dart b/lib/screens/settings/extensions_page.dart index 55c16e94..d2a1027c 100644 --- a/lib/screens/settings/extensions_page.dart +++ b/lib/screens/settings/extensions_page.dart @@ -23,6 +23,7 @@ import 'package:spotiflac_android/widgets/app_icon.dart'; import 'package:spotiflac_android/widgets/app_loading_indicator.dart'; import 'package:spotiflac_android/widgets/mornye_chrome.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; +import 'package:spotiflac_android/widgets/home_feed_region_setting.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; class ExtensionsPage extends ConsumerStatefulWidget { @@ -125,6 +126,7 @@ class _ExtensionsPageState extends ConsumerState { _MetadataPriorityItem(), _SearchProviderSelector(), _HomeFeedProviderSelector(), + HomeFeedRegionSetting(), ], ), ), diff --git a/lib/utils/extension_country_name.dart b/lib/utils/extension_country_name.dart new file mode 100644 index 00000000..f1823f3e --- /dev/null +++ b/lib/utils/extension_country_name.dart @@ -0,0 +1,24 @@ +import 'package:flutter/widgets.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; + +String extensionCountryName(BuildContext context, String code) { + final l10n = context.l10n; + return switch (code.toUpperCase()) { + 'US' => l10n.regionCountryUS, + 'GB' => l10n.regionCountryGB, + 'FR' => l10n.regionCountryFR, + 'DE' => l10n.regionCountryDE, + 'IT' => l10n.regionCountryIT, + 'ES' => l10n.regionCountryES, + 'JP' => l10n.regionCountryJP, + 'KR' => l10n.regionCountryKR, + 'IN' => l10n.regionCountryIN, + 'ID' => l10n.regionCountryID, + 'BR' => l10n.regionCountryBR, + 'MX' => l10n.regionCountryMX, + 'AU' => l10n.regionCountryAU, + 'CA' => l10n.regionCountryCA, + 'XK' => l10n.regionCountryXK, + _ => code.toUpperCase(), + }; +} diff --git a/lib/widgets/home_feed_region_setting.dart b/lib/widgets/home_feed_region_setting.dart new file mode 100644 index 00000000..c1e675f3 --- /dev/null +++ b/lib/widgets/home_feed_region_setting.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/explore_provider.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/utils/extension_country_name.dart'; +import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; +import 'package:spotiflac_android/widgets/app_snack_bar.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; + +class HomeFeedRegionSetting extends ConsumerStatefulWidget { + const HomeFeedRegionSetting({super.key}); + + @override + ConsumerState createState() => + _HomeFeedRegionSettingState(); +} + +class _HomeFeedRegionSettingState extends ConsumerState { + bool _saving = false; + + @override + Widget build(BuildContext context) { + final extension = ref.watch(homeFeedExtensionProvider); + final setting = extension?.homeFeedRegionSetting; + if (extension == null || setting == null) return const SizedBox.shrink(); + final saved = ref.watch(extensionSettingsProvider(extension.id)); + final selected = (saved.value?[setting.key] ?? setting.defaultValue) + ?.toString(); + return SettingsItem( + icon: Icons.public, + title: context.l10n.extensionsHomeFeedCountry, + subtitle: saved.hasError + ? context.friendlyError(saved.error) + : '${extensionCountryName(context, selected ?? '')} · ${extension.displayName}', + showDivider: false, + onTap: _saving + ? null + : () async { + if (saved.hasError) { + ref.invalidate(extensionSettingsProvider(extension.id)); + return; + } + final region = await showAppScrollableBottomSheet( + context: context, + title: context.l10n.extensionsHomeFeedCountry, + subtitle: + setting.description ?? + context.l10n.extensionsHomeFeedCountryDescription, + builder: (sheetContext, controller) => SingleChildScrollView( + controller: controller, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final code in setting.options!) + AppSheetOption.choice( + title: Text(extensionCountryName(sheetContext, code)), + subtitle: Text(code.toUpperCase()), + selected: + code.toUpperCase() == selected?.toUpperCase(), + onTap: () => Navigator.pop(sheetContext, code), + ), + ], + ), + ), + ); + if (!mounted || region == null) return; + setState(() => _saving = true); + final success = await ref + .read(extensionProvider.notifier) + .setHomeFeedRegion(extension.id, region); + if (!mounted || !context.mounted) return; + setState(() => _saving = false); + if (!success) { + showAppSnackBar( + context, + content: Text( + context.l10n.snackbarError( + context.friendlyError(ref.read(extensionProvider).error), + ), + ), + ); + } + }, + ); + } +} diff --git a/test/home_feed_region_test.dart b/test/home_feed_region_test.dart new file mode 100644 index 00000000..4550cc16 --- /dev/null +++ b/test/home_feed_region_test.dart @@ -0,0 +1,315 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/l10n/app_localizations.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/providers/explore_provider.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_snack_bar.dart'; +import 'package:spotiflac_android/widgets/home_feed_region_setting.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; + +const _extension = Extension( + id: 'example.feed', + name: 'example.feed', + displayName: 'Example Music', + version: '1.0.0', + description: 'Regional recommendations', + enabled: true, + status: 'loaded', + capabilities: {'homeFeed': true, 'homeFeedRegionSetting': 'market'}, + settings: [ + ExtensionSetting( + key: 'market', + label: 'Country', + type: 'select', + defaultValue: 'us', + options: ['us', 'jp', 'gb'], + ), + ], +); + +class _Settings extends SettingsNotifier { + @override + AppSettings build() => const AppSettings(homeFeedProvider: 'example.feed'); +} + +class _Extensions extends ExtensionNotifier { + @override + ExtensionState build() => + const ExtensionState(extensions: [_extension], isInitialized: true); +} + +class _ColdExtensions extends ExtensionNotifier { + @override + ExtensionState build() => const ExtensionState(); + + @override + Future ensureInitialized() async { + state = const ExtensionState(extensions: [_extension], isInitialized: true); + } +} + +Map _feed(String region) => { + 'success': true, + 'provider_id': _extension.id, + 'region': region.toUpperCase(), + 'sections': [ + { + 'title': region, + 'items': [ + { + 'id': 'collection-$region', + 'type': 'album', + 'name': 'Music', + 'uri': 'https://example.test/$region/album', + }, + ], + }, + ], +}; + +Future _until(bool Function() condition) async { + for (var i = 0; i < 100; i++) { + if (condition()) return; + await Future.delayed(const Duration(milliseconds: 10)); + } + fail('Home feed did not reach the expected country'); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('com.zarz.spotiflac/backend'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + late Map settings; + late List> writes; + late bool failSave; + Completer? pendingFeed; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + settings = {'market': 'us', 'otherSetting': 'preserved'}; + writes = []; + failSave = false; + pendingFeed = null; + messenger.setMockMethodCallHandler(channel, (call) async { + switch (call.method) { + case 'getExtensionSettings': + return jsonEncode(settings); + case 'setExtensionSettings': + if (failSave) throw PlatformException(code: 'save_failed'); + settings = Map.from( + jsonDecode((call.arguments as Map)['settings'] as String) as Map, + ); + writes.add(Map.of(settings)); + return null; + case 'getExtensionHomeFeed': + final pending = pendingFeed; + pendingFeed = null; + return pending?.future ?? + jsonEncode(_feed(settings['market'] as String)); + } + return null; + }); + }); + tearDown(() => messenger.setMockMethodCallHandler(channel, null)); + + ProviderContainer container({bool coldStart = false}) { + final result = ProviderContainer( + overrides: [ + settingsProvider.overrideWith(_Settings.new), + extensionProvider.overrideWith( + coldStart ? _ColdExtensions.new : _Extensions.new, + ), + ], + ); + addTearDown(result.dispose); + return result; + } + + test('region capability must reference declared country choices', () { + expect(_extension.homeFeedRegionSetting?.key, 'market'); + expect( + _extension + .copyWith( + settings: const [ + ExtensionSetting( + key: 'market', + label: 'Country', + type: 'string', + defaultValue: 'us', + options: ['us', 'jp', 'gb'], + ), + ], + ) + .homeFeedRegionSetting + ?.type, + 'string', + ); + for (final capabilities in [ + {'homeFeed': true}, + {'homeFeed': true, 'homeFeedRegionSetting': 'undeclared'}, + {'homeFeed': false, 'homeFeedRegionSetting': 'market'}, + ]) { + expect( + _extension.copyWith(capabilities: capabilities).homeFeedRegionSetting, + isNull, + ); + } + }); + + test( + 'saving country preserves settings and reloads the active feed', + () async { + final providers = container(); + final explore = providers.read(exploreProvider.notifier); + await explore.fetchHomeFeed(); + expect(providers.read(exploreProvider).sections.single.title, 'us'); + expect( + await providers + .read(extensionProvider.notifier) + .setHomeFeedRegion(_extension.id, 'jp'), + isTrue, + ); + await _until( + () => + providers.read(exploreProvider).sections.firstOrNull?.title == 'jp', + ); + expect(writes.single, {'market': 'jp', 'otherSetting': 'preserved'}); + final prefs = await SharedPreferences.getInstance(); + await _until( + () => + prefs.getString('explore_home_feed_cache')?.contains('"JP"') == + true, + ); + }, + ); + + test( + 'late response from the previous country cannot replace the new feed', + () async { + final providers = container(); + final explore = providers.read(exploreProvider.notifier); + final old = Completer(); + pendingFeed = old; + final request = explore.fetchHomeFeed(); + await _until(() => pendingFeed == null); + await providers + .read(extensionProvider.notifier) + .setHomeFeedRegion(_extension.id, 'gb'); + await _until( + () => + providers.read(exploreProvider).sections.firstOrNull?.title == 'gb', + ); + old.complete(jsonEncode(_feed('us'))); + await request; + expect(providers.read(exploreProvider).sections.single.title, 'gb'); + }, + ); + + test( + 'cached Home from another country is not restored after relaunch', + () async { + SharedPreferences.setMockInitialValues({ + 'explore_home_feed_cache': jsonEncode(_feed('us')), + 'explore_home_feed_ts': DateTime.now().millisecondsSinceEpoch, + }); + settings['market'] = 'jp'; + final providers = container(); + providers.read(exploreProvider); + await Future.delayed(const Duration(milliseconds: 100)); + expect(providers.read(exploreProvider).hasContent, isFalse); + await providers.read(exploreProvider.notifier).fetchHomeFeed(); + expect(providers.read(exploreProvider).sections.single.title, 'jp'); + }, + ); + + test( + 'cached Home from the selected country is restored without fetching', + () async { + SharedPreferences.setMockInitialValues({ + 'explore_home_feed_cache': jsonEncode(_feed('jp')), + 'explore_home_feed_ts': DateTime.now().millisecondsSinceEpoch, + }); + settings['market'] = 'jp'; + final providers = container(); + providers.read(exploreProvider); + await _until(() => providers.read(exploreProvider).hasContent); + expect(providers.read(exploreProvider).sections.single.title, 'jp'); + }, + ); + + test('regional cache waits for manifests on a cold launch', () async { + SharedPreferences.setMockInitialValues({ + 'explore_home_feed_cache': jsonEncode(_feed('jp')), + 'explore_home_feed_ts': DateTime.now().millisecondsSinceEpoch, + }); + settings['market'] = 'jp'; + final providers = container(coldStart: true); + providers.read(exploreProvider); + await _until(() => providers.read(exploreProvider).hasContent); + expect(providers.read(extensionProvider).isInitialized, isTrue); + expect(providers.read(exploreProvider).sections.single.title, 'jp'); + }); + + test( + 'failed saves and unsupported countries leave the current feed intact', + () async { + final providers = container(); + final explore = providers.read(exploreProvider.notifier); + await explore.fetchHomeFeed(); + final extensions = providers.read(extensionProvider.notifier); + expect( + await extensions.setHomeFeedRegion(_extension.id, 'invalid'), + isFalse, + ); + failSave = true; + expect(await extensions.setHomeFeedRegion(_extension.id, 'jp'), isFalse); + expect(settings['market'], 'us'); + expect(providers.read(exploreProvider).sections.single.title, 'us'); + expect(providers.read(extensionProvider).settingsRevisions, isEmpty); + }, + ); + + for (final mornye in [false, true]) { + testWidgets('country picker saves a country with either theme ($mornye)', ( + tester, + ) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith(_Settings.new), + extensionProvider.overrideWith(_Extensions.new), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: mornye ? MornyeTheme.build(Brightness.dark) : ThemeData(), + builder: (_, child) => AppScaffoldMessenger(child: child!), + home: const Scaffold( + body: SettingsGroup(children: [HomeFeedRegionSetting()]), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('United States · Example Music'), findsOneWidget); + await tester.tap(find.text('Home feed country')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Japan')); + await tester.pumpAndSettle(); + expect(writes.single['market'], 'jp'); + expect(find.text('Japan · Example Music'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } +}