Files
SpotiFLAC-Mobile/lib/screens/store_tab.dart
T

670 lines
24 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/store_provider.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/screens/store/extension_details_screen.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
class StoreTab extends ConsumerStatefulWidget {
const StoreTab({super.key});
@override
ConsumerState<StoreTab> createState() => _StoreTabState();
}
class _StoreTabState extends ConsumerState<StoreTab> {
final _searchController = TextEditingController();
bool _isInitialized = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _initialize());
}
Future<void> _initialize() async {
if (_isInitialized) return;
_isInitialized = true;
final cacheDir = await getApplicationCacheDirectory();
if (!mounted) return;
await ref.read(storeProvider.notifier).initialize(cacheDir.path);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final storeFilterState = ref.watch(
storeProvider.select(
(s) => (s.extensions, s.selectedCategory, s.searchQuery),
),
);
final extensions = storeFilterState.$1;
final selectedCategory = storeFilterState.$2;
final searchQuery = storeFilterState.$3;
final isLoading = ref.watch(storeProvider.select((s) => s.isLoading));
final error = ref.watch(storeProvider.select((s) => s.error));
final downloadingId = ref.watch(
storeProvider.select((s) => s.downloadingId),
);
final filteredExtensions = StoreState(
extensions: extensions,
selectedCategory: selectedCategory,
searchQuery: searchQuery,
).filteredExtensions;
if (_searchController.text != searchQuery) {
_searchController.value = TextEditingValue(
text: searchQuery,
selection: TextSelection.collapsed(offset: searchQuery.length),
);
}
final colorScheme = Theme.of(context).colorScheme;
final topPadding = normalizedHeaderTopPadding(context);
return Scaffold(
body: RefreshIndicator(
onRefresh: () =>
ref.read(storeProvider.notifier).refresh(forceRefresh: true),
child: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
automaticallyImplyLeading: false,
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio =
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
title: Text(
context.l10n.storeTitle,
style: TextStyle(
fontSize: 20 + (14 * expandRatio),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _searchController,
builder: (context, value, _) {
return TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: context.l10n.storeSearch,
prefixIcon: const Icon(Icons.search),
suffixIcon: value.text.isNotEmpty
? IconButton(
tooltip: 'Clear search',
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
ref
.read(storeProvider.notifier)
.setSearchQuery('');
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide.none,
),
filled: true,
fillColor:
Theme.of(context).brightness == Brightness.dark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
colorScheme.surface,
)
: colorScheme.surfaceContainerHighest,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
onChanged: (value) {
ref.read(storeProvider.notifier).setSearchQuery(value);
},
);
},
),
),
),
SliverToBoxAdapter(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Row(
children: [
_CategoryChip(
label: context.l10n.storeFilterAll,
icon: Icons.apps,
isSelected: selectedCategory == null,
onTap: () =>
ref.read(storeProvider.notifier).setCategory(null),
),
const SizedBox(width: 8),
_CategoryChip(
label: context.l10n.storeFilterMetadata,
icon: Icons.label_outline,
isSelected: selectedCategory == StoreCategory.metadata,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.metadata),
),
const SizedBox(width: 8),
_CategoryChip(
label: context.l10n.storeFilterDownload,
icon: Icons.download_outlined,
isSelected: selectedCategory == StoreCategory.download,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.download),
),
const SizedBox(width: 8),
_CategoryChip(
label: context.l10n.storeFilterUtility,
icon: Icons.build_outlined,
isSelected: selectedCategory == StoreCategory.utility,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.utility),
),
const SizedBox(width: 8),
_CategoryChip(
label: context.l10n.storeFilterLyrics,
icon: Icons.lyrics_outlined,
isSelected: selectedCategory == StoreCategory.lyrics,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.lyrics),
),
const SizedBox(width: 8),
_CategoryChip(
label: context.l10n.storeFilterIntegration,
icon: Icons.link,
isSelected: selectedCategory == StoreCategory.integration,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.integration),
),
],
),
),
),
if (isLoading && extensions.isEmpty)
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
)
else if (error != null && extensions.isEmpty)
SliverFillRemaining(child: _buildErrorState(error, colorScheme))
else if (filteredExtensions.isEmpty)
SliverFillRemaining(
child: _buildEmptyState(
hasFilters:
searchQuery.isNotEmpty || selectedCategory != null,
colorScheme: colorScheme,
),
)
else ...[
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
'${filteredExtensions.length} ${filteredExtensions.length == 1 ? 'extension' : 'extensions'}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SettingsGroup(
children: filteredExtensions.asMap().entries.map((entry) {
final index = entry.key;
final ext = entry.value;
return _ExtensionItem(
extension: ext,
showDivider: index < filteredExtensions.length - 1,
isDownloading: downloadingId == ext.id,
onInstall: () => _installExtension(ext),
onUpdate: () => _updateExtension(ext),
onTap: () => _showExtensionDetails(ext),
);
}).toList(),
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 16)),
],
],
),
),
);
}
Widget _buildErrorState(String error, ColorScheme colorScheme) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 64, color: colorScheme.error),
const SizedBox(height: 16),
Text(
'Failed to load store',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
error,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () =>
ref.read(storeProvider.notifier).refresh(forceRefresh: true),
icon: const Icon(Icons.refresh),
label: Text(context.l10n.dialogRetry),
),
],
),
),
);
}
Widget _buildEmptyState({
required bool hasFilters,
required ColorScheme colorScheme,
}) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
hasFilters ? Icons.search_off : Icons.extension_off,
size: 64,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
hasFilters ? 'No extensions found' : 'No extensions available',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
if (hasFilters) ...[
const SizedBox(height: 8),
TextButton(
onPressed: () {
_searchController.clear();
ref.read(storeProvider.notifier).clearSearch();
},
child: Text(context.l10n.storeClearFilters),
),
],
],
),
);
}
void _showExtensionDetails(StoreExtension ext) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => ExtensionDetailsScreen(extension: ext),
),
);
}
Future<void> _installExtension(StoreExtension ext) async {
final tempDir = await getTemporaryDirectory();
final appDir = await getApplicationDocumentsDirectory();
final extensionsDir = '${appDir.path}/extensions';
final success = await ref
.read(storeProvider.notifier)
.installExtension(ext.id, tempDir.path, extensionsDir);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
success
? '${ext.displayName} installed. Enable it in Settings > Extensions'
: 'Failed to install ${ext.displayName}',
),
behavior: SnackBarBehavior.floating,
),
);
}
}
Future<void> _updateExtension(StoreExtension ext) async {
final tempDir = await getTemporaryDirectory();
final success = await ref
.read(storeProvider.notifier)
.updateExtension(ext.id, tempDir.path);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
success
? '${ext.displayName} updated to v${ext.version}'
: 'Failed to update ${ext.displayName}',
),
behavior: SnackBarBehavior.floating,
),
);
}
}
}
class _CategoryChip extends StatelessWidget {
final String label;
final IconData icon;
final bool isSelected;
final VoidCallback onTap;
const _CategoryChip({
required this.label,
required this.icon,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return FilterChip(
label: Row(
mainAxisSize: MainAxisSize.min,
children: [Icon(icon, size: 16), const SizedBox(width: 6), Text(label)],
),
selected: isSelected,
onSelected: (_) => onTap(),
showCheckmark: false,
);
}
}
class _ExtensionItem extends StatelessWidget {
final StoreExtension extension;
final bool showDivider;
final bool isDownloading;
final VoidCallback onInstall;
final VoidCallback onUpdate;
final VoidCallback? onTap;
const _ExtensionItem({
required this.extension,
required this.showDivider,
required this.isDownloading,
required this.onInstall,
required this.onUpdate,
this.onTap,
});
IconData _getCategoryIcon(String category) {
switch (category) {
case StoreCategory.metadata:
return Icons.label_outline;
case StoreCategory.download:
return Icons.download_outlined;
case StoreCategory.utility:
return Icons.build_outlined;
case StoreCategory.lyrics:
return Icons.lyrics_outlined;
case StoreCategory.integration:
return Icons.link;
default:
return Icons.extension;
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: extension.isInstalled
? colorScheme.primaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
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),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
extension.displayName,
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(fontWeight: FontWeight.w500),
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Text(
'v${extension.version}',
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
const SizedBox(height: 2),
Text(
'by ${extension.author}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
if (extension.requiresNewerApp) ...[
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.warning_amber_rounded,
size: 12,
color: colorScheme.onErrorContainer,
),
const SizedBox(width: 4),
Text(
'Requires v${extension.minAppVersion}+',
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: colorScheme.onErrorContainer,
fontWeight: FontWeight.w500,
),
),
],
),
),
] else ...[
const SizedBox(height: 4),
Text(
extension.description,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.onSurfaceVariant),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
const SizedBox(width: 12),
if (isDownloading)
const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
else if (extension.hasUpdate)
FilledButton.tonal(
onPressed: onUpdate,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12),
minimumSize: const Size(0, 36),
),
child: Text(context.l10n.storeUpdate),
)
else if (extension.isInstalled)
OutlinedButton(
onPressed: null,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12),
minimumSize: const Size(0, 36),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check, size: 16, color: colorScheme.outline),
const SizedBox(width: 4),
Text(
'Installed',
style: TextStyle(color: colorScheme.outline),
),
],
),
)
else
FilledButton(
onPressed: onInstall,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12),
minimumSize: const Size(0, 36),
),
child: Text(context.l10n.storeInstall),
),
],
),
),
),
if (showDivider)
Divider(
height: 1,
thickness: 1,
indent: 76,
endIndent: 16,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
],
);
}
}