mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 22:41:38 +02:00
feat(home): expose extension country settings and isolate regional caches
This commit is contained in:
1 parent
2455cabced
commit
1a6dc7366e
11 files changed
+606
-21
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<String, int> 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<String, int>? 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,21 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
|
||||
final _log = AppLogger('ExploreProvider');
|
||||
|
||||
final homeFeedExtensionProvider = Provider<Extension?>((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<String, Object?> _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<String, Object?> cachePayload) {
|
||||
@@ -295,6 +314,15 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
|
||||
@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<ExploreState> {
|
||||
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<ExploreState> {
|
||||
}
|
||||
|
||||
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<String?> _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<void> _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<void> _saveToCache(
|
||||
List<Map<String, Object?>> 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<ExploreState> {
|
||||
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<ExploreState> {
|
||||
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;
|
||||
|
||||
@@ -22,6 +22,11 @@ const _metadataProviderPriorityKey = 'metadata_provider_priority';
|
||||
const _providerPriorityKey = 'provider_priority';
|
||||
const _storeRegistryUrlPrefKey = 'store_registry_url';
|
||||
|
||||
final extensionSettingsProvider = FutureProvider.autoDispose
|
||||
.family<Map<String, dynamic>, String>((ref, extensionId) {
|
||||
return PlatformBridge.getExtensionSettings(extensionId);
|
||||
});
|
||||
|
||||
/// Result of restoring extensions from a backup.
|
||||
class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
static const _extensionHealthDefaultCacheTtl = Duration(minutes: 10);
|
||||
@@ -784,7 +789,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
...current,
|
||||
...Map<String, dynamic>.from(settings),
|
||||
};
|
||||
await PlatformBridge.setExtensionSettings(id, merged);
|
||||
await setExtensionSettings(id, merged);
|
||||
} catch (e) {
|
||||
_log.w('Restore: failed to apply settings for $id: $e');
|
||||
}
|
||||
|
||||
@@ -236,16 +236,47 @@ extension ExtensionNotifierProviderPriority on ExtensionNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setExtensionSettings(
|
||||
Future<bool> setExtensionSettings(
|
||||
String extensionId,
|
||||
Map<String, dynamic> 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<bool> 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ExtensionDetailPage> {
|
||||
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<dynamic> onChanged;
|
||||
final String extensionId;
|
||||
final bool countryOptions;
|
||||
final Future<void> Function(Map<String, dynamic> 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 ?? <String>[])
|
||||
AppSheetOption.choice(
|
||||
title: Text(option),
|
||||
title: Text(
|
||||
widget.countryOptions
|
||||
? extensionCountryName(sheetContext, option)
|
||||
: option,
|
||||
),
|
||||
selected: option == widget.value,
|
||||
onTap: () => Navigator.pop(sheetContext, option),
|
||||
),
|
||||
|
||||
@@ -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<ExtensionsPage> {
|
||||
_MetadataPriorityItem(),
|
||||
_SearchProviderSelector(),
|
||||
_HomeFeedProviderSelector(),
|
||||
HomeFeedRegionSetting(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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<HomeFeedRegionSetting> createState() =>
|
||||
_HomeFeedRegionSettingState();
|
||||
}
|
||||
|
||||
class _HomeFeedRegionSettingState extends ConsumerState<HomeFeedRegionSetting> {
|
||||
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<String>(
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void> ensureInitialized() async {
|
||||
state = const ExtensionState(extensions: [_extension], isInitialized: true);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object?> _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<void> _until(bool Function() condition) async {
|
||||
for (var i = 0; i < 100; i++) {
|
||||
if (condition()) return;
|
||||
await Future<void>.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<String, dynamic> settings;
|
||||
late List<Map<String, dynamic>> writes;
|
||||
late bool failSave;
|
||||
Completer<String>? 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<String, dynamic>.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<String>();
|
||||
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<void>.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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user