feat: improve Extension Store with custom icons and various fixes

- Support custom extension icons from registry (iconUrl field)
- Support both camelCase and snake_case in registry JSON
- Fix download file extension (.spotiflac-ext)
- New extensions start disabled by default
- Preserve enabled state on extension upgrade
- Add toggle to show/hide Store tab in Settings > Options
- Reorder tabs: Home, History, Store, Settings
This commit is contained in:
zarzet
2026-01-13 01:01:43 +07:00
parent a38d66fd41
commit 8daff4d0a4
9 changed files with 210 additions and 72 deletions
+4
View File
@@ -28,6 +28,7 @@ class AppSettings {
final bool useExtensionProviders; // Use extension providers for downloads when available
final String? searchProvider; // null/empty = default (Deezer/Spotify), otherwise extension ID
final bool separateSingles; // Separate singles/EPs into their own folder
final bool showExtensionStore; // Show Extension Store tab in navigation
const AppSettings({
this.defaultService = 'tidal',
@@ -54,6 +55,7 @@ class AppSettings {
this.useExtensionProviders = true, // Default: use extensions when available
this.searchProvider, // Default: null (use Deezer/Spotify)
this.separateSingles = false, // Default: disabled
this.showExtensionStore = true, // Default: show store
});
AppSettings copyWith({
@@ -81,6 +83,7 @@ class AppSettings {
bool? useExtensionProviders,
String? searchProvider,
bool? separateSingles,
bool? showExtensionStore,
}) {
return AppSettings(
defaultService: defaultService ?? this.defaultService,
@@ -107,6 +110,7 @@ class AppSettings {
useExtensionProviders: useExtensionProviders ?? this.useExtensionProviders,
searchProvider: searchProvider ?? this.searchProvider,
separateSingles: separateSingles ?? this.separateSingles,
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
);
}
+2
View File
@@ -32,6 +32,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
searchProvider: json['searchProvider'] as String?,
separateSingles: json['separateSingles'] as bool? ?? false,
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
);
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
@@ -60,4 +61,5 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'useExtensionProviders': instance.useExtensionProviders,
'searchProvider': instance.searchProvider,
'separateSingles': instance.separateSingles,
'showExtensionStore': instance.showExtensionStore,
};
+5
View File
@@ -216,6 +216,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
state = state.copyWith(separateSingles: enabled);
_saveSettings();
}
void setShowExtensionStore(bool enabled) {
state = state.copyWith(showExtensionStore: enabled);
_saveSettings();
}
}
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
+55 -37
View File
@@ -173,6 +173,7 @@ class _MainShellState extends ConsumerState<MainShell> {
Widget build(BuildContext context) {
final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
final trackState = ref.watch(trackProvider);
final showStore = ref.watch(settingsProvider.select((s) => s.showExtensionStore));
// Check if keyboard is visible (bottom inset > 0 means keyboard is showing)
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
@@ -186,6 +187,57 @@ class _MainShellState extends ConsumerState<MainShell> {
!trackState.isLoading &&
!isKeyboardVisible;
// Build tabs and destinations based on settings
final tabs = <Widget>[
const HomeTab(),
const QueueTab(),
if (showStore) const StoreTab(),
const SettingsTab(),
];
final destinations = <NavigationDestination>[
const NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: 'Home',
),
NavigationDestination(
icon: Badge(
isLabelVisible: queueState > 0,
label: Text('$queueState'),
child: const Icon(Icons.history_outlined),
),
selectedIcon: Badge(
isLabelVisible: queueState > 0,
label: Text('$queueState'),
child: const Icon(Icons.history),
),
label: 'History',
),
if (showStore)
const NavigationDestination(
icon: Icon(Icons.store_outlined),
selectedIcon: Icon(Icons.store),
label: 'Store',
),
const NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: 'Settings',
),
];
// Clamp current index if tabs changed
final maxIndex = tabs.length - 1;
if (_currentIndex > maxIndex) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() => _currentIndex = maxIndex);
_pageController.jumpToPage(maxIndex);
}
});
}
return PopScope(
canPop: canPop,
onPopInvokedWithResult: (didPop, result) async {
@@ -203,50 +255,16 @@ class _MainShellState extends ConsumerState<MainShell> {
controller: _pageController,
onPageChanged: _onPageChanged,
physics: const BouncingScrollPhysics(),
children: const [
HomeTab(),
StoreTab(),
QueueTab(),
SettingsTab(),
],
children: tabs,
),
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex,
selectedIndex: _currentIndex.clamp(0, maxIndex),
onDestinationSelected: _onNavTap,
animationDuration: const Duration(milliseconds: 200),
backgroundColor: Theme.of(context).brightness == Brightness.dark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), Theme.of(context).colorScheme.surface)
: Color.alphaBlend(Colors.black.withValues(alpha: 0.03), Theme.of(context).colorScheme.surface),
destinations: [
const NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: 'Home',
),
const NavigationDestination(
icon: Icon(Icons.store_outlined),
selectedIcon: Icon(Icons.store),
label: 'Store',
),
NavigationDestination(
icon: Badge(
isLabelVisible: queueState > 0,
label: Text('$queueState'),
child: const Icon(Icons.history_outlined),
),
selectedIcon: Badge(
isLabelVisible: queueState > 0,
label: Text('$queueState'),
child: const Icon(Icons.history),
),
label: 'History',
),
const NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: 'Settings',
),
],
destinations: destinations,
),
),
);
@@ -202,6 +202,15 @@ class OptionsSettingsPage extends ConsumerWidget {
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.store,
title: 'Extension Store',
subtitle: 'Show Store tab in navigation',
value: settings.showExtensionStore,
onChanged: (v) => ref
.read(settingsProvider.notifier)
.setShowExtensionStore(v),
),
SettingsSwitchItem(
icon: Icons.system_update,
title: 'Check for Updates',
+37 -7
View File
@@ -410,7 +410,7 @@ class _ExtensionItem extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
// Extension icon
// Extension icon - custom or category-based
Container(
width: 44,
height: 44,
@@ -420,12 +420,42 @@ class _ExtensionItem extends StatelessWidget {
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
_getCategoryIcon(extension.category),
color: extension.isInstalled
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
clipBehavior: Clip.antiAlias,
child: extension.iconUrl != null && extension.iconUrl!.isNotEmpty
? Image.network(
extension.iconUrl!,
width: 44,
height: 44,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Icon(
_getCategoryIcon(extension.category),
color: extension.isInstalled
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null,
),
),
);
},
)
: Icon(
_getCategoryIcon(extension.category),
color: extension.isInstalled
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 16),
// Extension info