diff --git a/CHANGELOG.md b/CHANGELOG.md index 49930c6a..6abce1b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,109 @@ # Changelog +## [3.0.0] - 2026-01-14 + +### 🎉 Extension System (Major Feature) + +SpotiFLAC 3.0 introduces a powerful extension system that allows third-party integrations for metadata, downloads, and more. + +#### Extension Store +- Browse and install extensions directly from the app +- New "Store" tab in bottom navigation +- Browse by category: Metadata, Download, Utility, Lyrics, Integration +- Search extensions by name, description, or tags +- One-tap install, update, and uninstall +- Offline cache for browsing without internet + +#### Extension Capabilities +- **Custom Search Providers** +- **Custom URL Handlers** +- **Custom Thumbnail Ratios**: Square (1:1), Wide (16:9), Portrait (2:3) +- **Post-Processing Hooks**: Extensions can process downloaded files +- **Quality Options**: Extensions can define custom quality settings + +#### Extension APIs +- Full HTTP support: GET, POST, PUT, DELETE, PATCH +- Persistent cookie jar per extension +- Browser-like polyfills: `fetch()`, `atob()`/`btoa()`, `TextEncoder`/`TextDecoder`, `URL`/`URLSearchParams` +- Storage API for persistent data +- File API for file operations +- HMAC-SHA1 utility for cryptographic operations + +#### Security +- Sandboxed JavaScript runtime (goja) +- Permission-based access control +- Network domain whitelisting +- Improved credential encryption with per-installation random salt + +### Added + +- **Album Folder Structure Setting**: Option to remove artist folder from album path + - `Artist / Album` (default): `Albums/Artist Name/Album Name/` + - `Album Only`: `Albums/Album Name/` + +- **Separate Singles Folder**: Organize downloads into Albums/ and Singles/ folders + - Based on `album_type` from Spotify/Deezer metadata + - Toggle in Settings > Download > Separate Singles Folder + +- **Parallel API Calls**: Download URL fetching now uses parallel requests + - Tidal: All 8 APIs requested simultaneously, first success wins + - Qobuz: Both APIs requested simultaneously, first success wins + - Significantly reduces download URL fetch time + +### Fixed + +- **Back Gesture Freeze on Android 13+**: Fixed app freeze when using back gesture in settings + - Added `PopScope` with `canPop: true` to all settings pages + - Changed navigation to use `PageRouteBuilder` with proper slide transition + +- **Bottom Overflow in Folder Organization Dialog**: Fixed overflow in portrait and landscape mode + - Made dialog scrollable with max height constraint + +- **Japanese Artist Name Order**: Fixed artist mismatch for Japanese names + - "Sawano Hiroyuki" vs "Hiroyuki Sawano" now correctly matches + +- **Multi-Artist Matching**: Fixed artist mismatch for collaboration tracks + - "RADWIMPS feat. Toko Miura" now matches when service only shows "Toko Miura" + +- **Max Resolution Cover Download**: Fixed cover not upgrading to max resolution on mobile + - Mobile now correctly upgrades 300x300 → 640x640 → max resolution (~2000x2000) + +- **EXISTS: Prefix in File Path**: Fixed "File not found" error in metadata screen + - Duplicate detection prefix now stripped before saving to history + +- **Extension Search Result Parsing**: Fixed "cannot unmarshal array" error + - Go backend now handles both array and object formats from extensions + +- **Store Tab Unmount Crash**: Fixed "Using ref when widget is unmounted" error + +- **Duplicate History Entries**: Fixed duplicate entries when re-downloading same track + - Detects existing entries by Spotify ID, Deezer ID, or ISRC + +- **Permission Error Message**: Fixed download showing "Song not found" when actually permission error + - Now shows proper message: "Cannot write to folder, check storage permission" + +- **Android 13+ Storage Permission**: Fixed storage permission not working on Android 13+ + - Now requests both `MANAGE_EXTERNAL_STORAGE` and `READ_MEDIA_AUDIO` + +### Changed + +- **Extension Manifest**: New `file` permission required for file operations + ```json + "permissions": { + "network": ["api.example.com"], + "storage": true, + "file": true + } + ``` + +### Technical + +- Go backend: Simplified parallel download result handling in Tidal/Qobuz +- Go backend: Removed unused functions and fixed bit shifting warnings +- Release workflow: Fixed duplicate `---` separator in release notes + +--- + ## [3.0.0-beta.2] - 2026-01-13 ### Added @@ -321,16 +425,6 @@ - **Android Changes**: - `android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt`: Already had upgrade methods -### Documentation - -- Updated `docs/EXTENSION_DEVELOPMENT.md`: - - Added thumbnail ratio customization section - - Added extension upgrade documentation - - Added settings fields table with `secret` field - - Added new troubleshooting entries - - Updated table of contents - - Updated changelog - --- ## [2.2.8] - 2026-01-12 diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index dd428f1e..724afbe6 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,8 +1,8 @@ /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '3.0.0-beta.2'; - static const String buildNumber = '56'; + static const String version = '3.0.0'; + static const String buildNumber = '57'; static const String fullVersion = '$version+$buildNumber'; diff --git a/lib/screens/settings/extension_detail_page.dart b/lib/screens/settings/extension_detail_page.dart index 90a12922..0144111e 100644 --- a/lib/screens/settings/extension_detail_page.dart +++ b/lib/screens/settings/extension_detail_page.dart @@ -188,6 +188,7 @@ class _ExtensionDetailPageState extends ConsumerState { const SizedBox(height: 16), _InfoRow(label: 'Author', value: extension.author), _InfoRow(label: 'ID', value: extension.id), + _InfoRow(label: 'Version', value: 'v${extension.version}'), if (hasError && extension.errorMessage != null) _InfoRow( label: 'Error', @@ -238,28 +239,57 @@ class _ExtensionDetailPageState extends ConsumerState { subtitle: extension.postProcessing?.hooks.isNotEmpty == true ? '${extension.postProcessing!.hooks.length} hook(s) available' : null, + ), + _CapabilityItem( + icon: Icons.link, + title: 'URL Handler', + enabled: extension.hasURLHandler, + subtitle: extension.urlHandler?.patterns.isNotEmpty == true + ? '${extension.urlHandler!.patterns.length} pattern(s)' + : null, showDivider: false, ), ], ), ), - // Search Provider Section (if extension has custom search) - if (extension.hasCustomSearch) ...[ + + + // URL Handler Section (if extension handles URLs) + if (extension.hasURLHandler && extension.urlHandler!.patterns.isNotEmpty) ...[ const SliverToBoxAdapter( - child: SettingsSectionHeader(title: 'Search Provider'), + child: SettingsSectionHeader(title: 'URL Handler'), ), SliverToBoxAdapter( child: SettingsGroup( children: [ - _SearchProviderInfo( - extension: extension, + _URLHandlerInfo( + patterns: extension.urlHandler!.patterns, ), ], ), ), ], + // Quality Options Section (for download providers) + if (extension.hasDownloadProvider && extension.qualityOptions.isNotEmpty) ...[ + const SliverToBoxAdapter( + child: SettingsSectionHeader(title: 'Quality Options'), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: extension.qualityOptions.asMap().entries.map((entry) { + final index = entry.key; + final quality = entry.value; + return _QualityOptionItem( + quality: quality, + showDivider: index < extension.qualityOptions.length - 1, + ); + }).toList(), + ), + ), + ], + // Post-Processing Hooks (if available) if (extension.hasPostProcessing && extension.postProcessing!.hooks.isNotEmpty) ...[ const SliverToBoxAdapter( @@ -820,17 +850,18 @@ class _PostProcessingHookItem extends StatelessWidget { } } -class _SearchProviderInfo extends StatelessWidget { - final Extension extension; - const _SearchProviderInfo({ - required this.extension, + +class _URLHandlerInfo extends StatelessWidget { + final List patterns; + + const _URLHandlerInfo({ + required this.patterns, }); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final searchBehavior = extension.searchBehavior; return Padding( padding: const EdgeInsets.all(16), @@ -843,12 +874,12 @@ class _SearchProviderInfo extends StatelessWidget { width: 48, height: 48, decoration: BoxDecoration( - color: colorScheme.secondaryContainer, + color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(12), ), child: Icon( - Icons.manage_search, - color: colorScheme.onSecondaryContainer, + Icons.link, + color: colorScheme.onTertiaryContainer, size: 24, ), ), @@ -858,14 +889,14 @@ class _SearchProviderInfo extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Custom Search Available', + 'Custom URL Handling', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 2), Text( - 'This extension provides its own search functionality', + 'This extension can handle links from these sites', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -876,25 +907,38 @@ class _SearchProviderInfo extends StatelessWidget { ], ), const SizedBox(height: 16), - // Search placeholder info - if (searchBehavior?.placeholder != null) ...[ - _InfoTile( - icon: Icons.text_fields, - label: 'Search Hint', - value: searchBehavior!.placeholder!, - ), - const SizedBox(height: 8), - ], - // Primary search info - _InfoTile( - icon: searchBehavior?.primary == true ? Icons.star : Icons.star_border, - label: 'Priority', - value: searchBehavior?.primary == true - ? 'Primary search provider' - : 'Secondary search provider', + Wrap( + spacing: 8, + runSpacing: 8, + children: patterns.map((pattern) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.language, + size: 16, + color: colorScheme.primary, + ), + const SizedBox(width: 6), + Text( + pattern, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + }).toList(), ), const SizedBox(height: 16), - // Usage instructions Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( @@ -911,7 +955,7 @@ class _SearchProviderInfo extends StatelessWidget { const SizedBox(width: 12), Expanded( child: Text( - 'To use this search provider, tap the search bar on the Home tab and select "${extension.displayName}" from the provider chips.', + 'Share links from these sites to SpotiFLAC and this extension will handle them.', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -926,44 +970,95 @@ class _SearchProviderInfo extends StatelessWidget { } } -class _InfoTile extends StatelessWidget { - final IconData icon; - final String label; - final String value; +class _QualityOptionItem extends StatelessWidget { + final QualityOption quality; + final bool showDivider; - const _InfoTile({ - required this.icon, - required this.label, - required this.value, + const _QualityOptionItem({ + required this.quality, + this.showDivider = true, }); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - return Row( + return Column( + mainAxisSize: MainAxisSize.min, children: [ - Icon( - icon, - size: 18, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 8), - Text( - '$label: ', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + Icons.high_quality, + color: colorScheme.onSecondaryContainer, + size: 20, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + quality.label, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + if (quality.description != null && quality.description!.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + quality.description!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + const SizedBox(height: 4), + Text( + quality.id, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.primary, + fontFamily: 'monospace', + ), + ), + ], + ), + ), + if (quality.settings.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '${quality.settings.length} setting${quality.settings.length > 1 ? 's' : ''}', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], ), ), - Expanded( - child: Text( - value, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurface, - fontWeight: FontWeight.w500, - ), + if (showDivider) + Divider( + height: 1, + thickness: 1, + indent: 72, + endIndent: 16, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), ), - ), ], ); } diff --git a/lib/screens/store/extension_details_screen.dart b/lib/screens/store/extension_details_screen.dart new file mode 100644 index 00000000..4ca5b7a3 --- /dev/null +++ b/lib/screens/store/extension_details_screen.dart @@ -0,0 +1,751 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:spotiflac_android/providers/store_provider.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; + +class ExtensionDetailsScreen extends ConsumerStatefulWidget { + final StoreExtension extension; + + const ExtensionDetailsScreen({super.key, required this.extension}); + + @override + ConsumerState createState() => + _ExtensionDetailsScreenState(); +} + +class _ExtensionDetailsScreenState + extends ConsumerState { + + @override + Widget build(BuildContext context) { + // Watch store provider to get latest state of this extension (e.g. if updated/installed) + final storeState = ref.watch(storeProvider); + + // Find our extension in the store state to get the latest status + // If not found in current store state (rare), fallback to widget.extension + final liveExtension = + storeState.extensions + .where((e) => e.id == widget.extension.id) + .firstOrNull ?? + widget.extension; + + final isDownloading = storeState.downloadingId == liveExtension.id; + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + body: CustomScrollView( + slivers: [ + _buildAppBar(context, liveExtension, colorScheme), + _buildInfoCard(context, liveExtension, colorScheme, isDownloading), + _buildSectionHeader( + context, + 'About', + Icons.info_outline, + colorScheme, + ), + _buildDescription(context, liveExtension, colorScheme), + + if (liveExtension.tags.isNotEmpty) ...[ + _buildSectionHeader(context, 'Tags', Icons.tag, colorScheme), + _buildTags(context, liveExtension, colorScheme), + ], + + _buildSectionHeader( + context, + 'Information', + Icons.table_chart_outlined, + colorScheme, + ), + _buildMetadataTable(context, liveExtension, colorScheme), + + _buildSectionHeader( + context, + 'Capabilities', + Icons.extension_outlined, + colorScheme, + ), + _buildCapabilities(context, liveExtension, colorScheme), + + const SliverToBoxAdapter(child: SizedBox(height: 32)), + ], + ), + ); + } + + Widget _buildAppBar( + BuildContext context, + StoreExtension ext, + ColorScheme colorScheme, + ) { + return SliverAppBar( + expandedHeight: 200, + pinned: true, + stretch: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + flexibleSpace: FlexibleSpaceBar( + background: Center( + child: Padding( + padding: const EdgeInsets.only(top: kToolbarHeight), + child: Container( + width: 100, + height: 100, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + color: colorScheme.surfaceContainerHighest, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: ext.iconUrl != null && ext.iconUrl!.isNotEmpty + ? Image.network( + ext.iconUrl!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + _buildFallbackIcon(ext, colorScheme, 50), + ) + : _buildFallbackIcon(ext, colorScheme, 50), + ), + ), + ), + ), + ), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), + ); + } + + Widget _buildFallbackIcon( + StoreExtension ext, + ColorScheme colorScheme, + double size, + ) { + return Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + _getCategoryIcon(ext.category), + size: size, + color: colorScheme.onSurfaceVariant, + ), + ); + } + + Widget _buildInfoCard( + BuildContext context, + StoreExtension ext, + ColorScheme colorScheme, + bool isDownloading, + ) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: Card( + elevation: 0, + color: colorScheme.surfaceContainerLow, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + ext.displayName, + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + const SizedBox(height: 4), + Text( + 'by ${ext.author}', + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ), + ], + ), + + const SizedBox(height: 16), + + // Badges row + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _Badge( + label: 'v${ext.version}', + color: colorScheme.secondaryContainer, + textColor: colorScheme.onSecondaryContainer, + ), + _Badge( + label: _getCategoryName(ext.category), + color: colorScheme.tertiaryContainer, + textColor: colorScheme.onTertiaryContainer, + ), + if (ext.isInstalled) + _Badge( + label: 'Installed', + color: colorScheme.primaryContainer, + textColor: colorScheme.onPrimaryContainer, + icon: Icons.check, + ), + ], + ), + + const SizedBox(height: 24), + + // Action Buttons + if (isDownloading) + Center( + child: CircularProgressIndicator( + color: colorScheme.primary, + ), + ) + else ...[ + if (ext.hasUpdate) + FilledButton.icon( + onPressed: () => _updateExtension(ext), + icon: const Icon(Icons.update), + label: Text('Update to v${ext.version}'), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ) + else if (ext.isInstalled) + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: null, + icon: const Icon(Icons.check), + label: const Text('Installed'), + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, 52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ), + const SizedBox(width: 12), + IconButton.filled( + onPressed: () => _uninstallExtension(ext), + icon: const Icon(Icons.delete_outline), + style: IconButton.styleFrom( + backgroundColor: colorScheme.errorContainer, + foregroundColor: colorScheme.onErrorContainer, + minimumSize: const Size(52, 52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + tooltip: 'Uninstall', + ), + ], + ) + else + FilledButton.icon( + onPressed: () => _installExtension(ext), + icon: const Icon(Icons.download), + label: const Text('Install Extension'), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ], + ], + ), + ), + ), + ), + ); + } + + Widget _buildSectionHeader( + BuildContext context, + String title, + IconData icon, + ColorScheme colorScheme, + ) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), + child: Row( + children: [ + Icon(icon, size: 20, color: colorScheme.primary), + const SizedBox(width: 8), + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + ], + ), + ), + ); + } + + Widget _buildDescription( + BuildContext context, + StoreExtension ext, + ColorScheme colorScheme, + ) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + child: Text( + ext.description, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + height: 1.5, + color: colorScheme.onSurface, + ), + ), + ), + ); + } + + Widget _buildTags( + BuildContext context, + StoreExtension ext, + ColorScheme colorScheme, + ) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: ext.tags + .map( + (tag) => Chip( + label: Text(tag), + backgroundColor: colorScheme.surfaceContainer, + labelStyle: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + side: BorderSide.none, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + ) + .toList(), + ), + ), + ); + } + + Widget _buildMetadataTable( + BuildContext context, + StoreExtension ext, + ColorScheme colorScheme, + ) { + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + sliver: SliverToBoxAdapter( + child: Card( + elevation: 0, + color: colorScheme.surfaceContainer, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + _MetadataRow( + label: 'Updated', + value: ext.updatedAt.isNotEmpty + ? _formatDate(ext.updatedAt) + : '-', + colorScheme: colorScheme, + ), + _MetadataRow( + label: 'ID', + value: ext.id, + colorScheme: colorScheme, + ), + _MetadataRow( + label: 'Min App Version', + value: ext.minAppVersion ?? 'Any', + colorScheme: colorScheme, + isLast: true, + ), + ], + ), + ), + ), + ); + } + + Widget _buildCapabilities( + BuildContext context, + StoreExtension ext, + ColorScheme colorScheme, + ) { + // Determine capabilities based on category + final isMetadataProvider = ext.category == 'metadata' || ext.category == 'integration'; + final isDownloadProvider = ext.category == 'download'; + final isLyricsProvider = ext.category == 'lyrics'; + final isUtility = ext.category == 'utility'; + + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + sliver: SliverToBoxAdapter( + child: Card( + elevation: 0, + color: colorScheme.surfaceContainer, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + _CapabilityRow( + icon: Icons.search, + label: 'Metadata Provider', + enabled: isMetadataProvider, + colorScheme: colorScheme, + ), + _CapabilityRow( + icon: Icons.download, + label: 'Download Provider', + enabled: isDownloadProvider, + colorScheme: colorScheme, + ), + _CapabilityRow( + icon: Icons.lyrics, + label: 'Lyrics Provider', + enabled: isLyricsProvider, + colorScheme: colorScheme, + ), + _CapabilityRow( + icon: Icons.build, + label: 'Utility Functions', + enabled: isUtility, + colorScheme: colorScheme, + isLast: true, + ), + ], + ), + ), + ), + ); + } + + String _formatDate(String dateStr) { + try { + final date = DateTime.parse(dateStr); + final now = DateTime.now(); + final diff = now.difference(date); + + if (diff.inDays == 0) { + return 'Today'; + } else if (diff.inDays == 1) { + return 'Yesterday'; + } else if (diff.inDays < 7) { + return '${diff.inDays} days ago'; + } else if (diff.inDays < 30) { + return '${(diff.inDays / 7).floor()} weeks ago'; + } else if (diff.inDays < 365) { + return '${(diff.inDays / 30).floor()} months ago'; + } else { + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } + } catch (_) { + return dateStr.split('T').first; + } + } + + IconData _getCategoryIcon(String category) { + switch (category) { + case 'metadata': + return Icons.label_outline; + case 'download': + return Icons.download_outlined; + case 'utility': + return Icons.build_outlined; + case 'lyrics': + return Icons.lyrics_outlined; + case 'integration': + return Icons.link; + default: + return Icons.extension; + } + } + + String _getCategoryName(String category) { + switch (category) { + case 'metadata': + return 'Metadata'; + case 'download': + return 'Download'; + case 'utility': + return 'Utility'; + case 'lyrics': + return 'Lyrics'; + case 'integration': + return 'Integration'; + default: + return category; + } + } + + Future _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.' + : 'Failed to install ${ext.displayName}', + ), + behavior: SnackBarBehavior.floating, + ), + ); + } + } + + Future _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.' + : 'Failed to update ${ext.displayName}', + ), + behavior: SnackBarBehavior.floating, + ), + ); + } + } + + Future _uninstallExtension(StoreExtension ext) async { + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Uninstall Extension?'), + content: Text('Are you sure you want to remove ${ext.displayName}?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text( + 'Uninstall', + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ], + ), + ); + + if (confirm == true) { + await ref.read(extensionProvider.notifier).removeExtension(ext.id); + await ref.read(storeProvider.notifier).refresh(); + if (mounted) { + Navigator.pop(context); + } + } + } +} + +class _Badge extends StatelessWidget { + final String label; + final Color color; + final Color textColor; + final IconData? icon; + + const _Badge({ + required this.label, + required this.color, + required this.textColor, + this.icon, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 14, color: textColor), + const SizedBox(width: 4), + ], + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: textColor, + ), + ), + ], + ), + ); + } +} + +class _MetadataRow extends StatelessWidget { + final String label; + final String value; + final ColorScheme colorScheme; + final bool isLast; + + const _MetadataRow({ + required this.label, + required this.value, + required this.colorScheme, + this.isLast = false, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: 14, + ), + ), + Expanded( + child: Text( + value, + textAlign: TextAlign.end, + style: TextStyle( + color: colorScheme.onSurface, + fontWeight: FontWeight.w500, + fontSize: 14, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + if (!isLast) + Divider( + height: 1, + thickness: 1, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + indent: 16, + endIndent: 16, + ), + ], + ); + } +} + +class _CapabilityRow extends StatelessWidget { + final IconData icon; + final String label; + final bool enabled; + final ColorScheme colorScheme; + final bool isLast; + + const _CapabilityRow({ + required this.icon, + required this.label, + required this.enabled, + required this.colorScheme, + this.isLast = false, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon( + icon, + size: 20, + color: enabled ? colorScheme.primary : colorScheme.outline, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + style: TextStyle( + color: colorScheme.onSurface, + fontSize: 14, + ), + ), + ), + Icon( + enabled ? Icons.check_circle : Icons.cancel_outlined, + size: 20, + color: enabled ? colorScheme.primary : colorScheme.outline, + ), + ], + ), + ), + if (!isLast) + Divider( + height: 1, + thickness: 1, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + indent: 16, + endIndent: 16, + ), + ], + ); + } +} diff --git a/lib/screens/store_tab.dart b/lib/screens/store_tab.dart index 8935f3d8..7d8e956a 100644 --- a/lib/screens/store_tab.dart +++ b/lib/screens/store_tab.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.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'; class StoreTab extends ConsumerStatefulWidget { const StoreTab({super.key}); @@ -26,10 +27,10 @@ class _StoreTabState extends ConsumerState { _isInitialized = true; final cacheDir = await getApplicationCacheDirectory(); - + // Check if widget is still mounted after async operation if (!mounted) return; - + await ref.read(storeProvider.notifier).initialize(cacheDir.path); } @@ -47,7 +48,8 @@ class _StoreTabState extends ConsumerState { return Scaffold( body: RefreshIndicator( - onRefresh: () => ref.read(storeProvider.notifier).refresh(forceRefresh: true), + onRefresh: () => + ref.read(storeProvider.notifier).refresh(forceRefresh: true), child: CustomScrollView( slivers: [ // App Bar - consistent with other tabs @@ -63,9 +65,10 @@ class _StoreTabState extends ConsumerState { builder: (context, constraints) { final maxHeight = 120 + topPadding; final minHeight = kToolbarHeight + topPadding; - final expandRatio = ((constraints.maxHeight - minHeight) / - (maxHeight - minHeight)) - .clamp(0.0, 1.0); + final expandRatio = + ((constraints.maxHeight - minHeight) / + (maxHeight - minHeight)) + .clamp(0.0, 1.0); return FlexibleSpaceBar( expandedTitleScale: 1.0, @@ -97,7 +100,9 @@ class _StoreTabState extends ConsumerState { icon: const Icon(Icons.clear), onPressed: () { _searchController.clear(); - ref.read(storeProvider.notifier).setSearchQuery(''); + ref + .read(storeProvider.notifier) + .setSearchQuery(''); }, ) : null, @@ -107,9 +112,15 @@ class _StoreTabState extends ConsumerState { ), filled: true, fillColor: Theme.of(context).brightness == Brightness.dark - ? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface) + ? Color.alphaBlend( + Colors.white.withValues(alpha: 0.08), + colorScheme.surface, + ) : colorScheme.surfaceContainerHighest, - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), ), onChanged: (value) { ref.read(storeProvider.notifier).setSearchQuery(value); @@ -123,49 +134,68 @@ class _StoreTabState extends ConsumerState { SliverToBoxAdapter( child: SingleChildScrollView( scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), child: Row( children: [ _CategoryChip( label: 'All', icon: Icons.apps, isSelected: state.selectedCategory == null, - onTap: () => ref.read(storeProvider.notifier).setCategory(null), + onTap: () => + ref.read(storeProvider.notifier).setCategory(null), ), const SizedBox(width: 8), _CategoryChip( label: 'Metadata', icon: Icons.label_outline, - isSelected: state.selectedCategory == StoreCategory.metadata, - onTap: () => ref.read(storeProvider.notifier).setCategory(StoreCategory.metadata), + isSelected: + state.selectedCategory == StoreCategory.metadata, + onTap: () => ref + .read(storeProvider.notifier) + .setCategory(StoreCategory.metadata), ), const SizedBox(width: 8), _CategoryChip( label: 'Download', icon: Icons.download_outlined, - isSelected: state.selectedCategory == StoreCategory.download, - onTap: () => ref.read(storeProvider.notifier).setCategory(StoreCategory.download), + isSelected: + state.selectedCategory == StoreCategory.download, + onTap: () => ref + .read(storeProvider.notifier) + .setCategory(StoreCategory.download), ), const SizedBox(width: 8), _CategoryChip( label: 'Utility', icon: Icons.build_outlined, - isSelected: state.selectedCategory == StoreCategory.utility, - onTap: () => ref.read(storeProvider.notifier).setCategory(StoreCategory.utility), + isSelected: + state.selectedCategory == StoreCategory.utility, + onTap: () => ref + .read(storeProvider.notifier) + .setCategory(StoreCategory.utility), ), const SizedBox(width: 8), _CategoryChip( label: 'Lyrics', icon: Icons.lyrics_outlined, - isSelected: state.selectedCategory == StoreCategory.lyrics, - onTap: () => ref.read(storeProvider.notifier).setCategory(StoreCategory.lyrics), + isSelected: + state.selectedCategory == StoreCategory.lyrics, + onTap: () => ref + .read(storeProvider.notifier) + .setCategory(StoreCategory.lyrics), ), const SizedBox(width: 8), _CategoryChip( label: 'Integration', icon: Icons.link, - isSelected: state.selectedCategory == StoreCategory.integration, - onTap: () => ref.read(storeProvider.notifier).setCategory(StoreCategory.integration), + isSelected: + state.selectedCategory == StoreCategory.integration, + onTap: () => ref + .read(storeProvider.notifier) + .setCategory(StoreCategory.integration), ), ], ), @@ -182,9 +212,7 @@ class _StoreTabState extends ConsumerState { child: _buildErrorState(state.error!, colorScheme), ) else if (state.filteredExtensions.isEmpty) - SliverFillRemaining( - child: _buildEmptyState(state, colorScheme), - ) + SliverFillRemaining(child: _buildEmptyState(state, colorScheme)) else ...[ // Extensions count SliverToBoxAdapter( @@ -204,15 +232,19 @@ class _StoreTabState extends ConsumerState { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: SettingsGroup( - children: state.filteredExtensions.asMap().entries.map((entry) { + children: state.filteredExtensions.asMap().entries.map(( + entry, + ) { final index = entry.key; final ext = entry.value; return _ExtensionItem( extension: ext, - showDivider: index < state.filteredExtensions.length - 1, + showDivider: + index < state.filteredExtensions.length - 1, isDownloading: state.downloadingId == ext.id, onInstall: () => _installExtension(ext), onUpdate: () => _updateExtension(ext), + onTap: () => _showExtensionDetails(ext), ); }).toList(), ), @@ -251,7 +283,8 @@ class _StoreTabState extends ConsumerState { ), const SizedBox(height: 24), FilledButton.icon( - onPressed: () => ref.read(storeProvider.notifier).refresh(forceRefresh: true), + onPressed: () => + ref.read(storeProvider.notifier).refresh(forceRefresh: true), icon: const Icon(Icons.refresh), label: const Text('Retry'), ), @@ -262,7 +295,8 @@ class _StoreTabState extends ConsumerState { } Widget _buildEmptyState(StoreState state, ColorScheme colorScheme) { - final hasFilters = state.searchQuery.isNotEmpty || state.selectedCategory != null; + final hasFilters = + state.searchQuery.isNotEmpty || state.selectedCategory != null; return Center( child: Column( @@ -295,23 +329,31 @@ class _StoreTabState extends ConsumerState { ); } + void _showExtensionDetails(StoreExtension ext) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ExtensionDetailsScreen(extension: ext), + ), + ); + } + Future _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, - ); + 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}'), + content: Text( + success + ? '${ext.displayName} installed. Enable it in Settings > Extensions' + : 'Failed to install ${ext.displayName}', + ), behavior: SnackBarBehavior.floating, ), ); @@ -321,17 +363,18 @@ class _StoreTabState extends ConsumerState { Future _updateExtension(StoreExtension ext) async { final tempDir = await getTemporaryDirectory(); - final success = await ref.read(storeProvider.notifier).updateExtension( - ext.id, - tempDir.path, - ); + 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}'), + content: Text( + success + ? '${ext.displayName} updated to v${ext.version}' + : 'Failed to update ${ext.displayName}', + ), behavior: SnackBarBehavior.floating, ), ); @@ -339,7 +382,6 @@ class _StoreTabState extends ConsumerState { } } - class _CategoryChip extends StatelessWidget { final String label; final IconData icon; @@ -358,11 +400,7 @@ class _CategoryChip extends StatelessWidget { return FilterChip( label: Row( mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 16), - const SizedBox(width: 6), - Text(label), - ], + children: [Icon(icon, size: 16), const SizedBox(width: 6), Text(label)], ), selected: isSelected, onSelected: (_) => onTap(), @@ -377,6 +415,7 @@ class _ExtensionItem extends StatelessWidget { final bool isDownloading; final VoidCallback onInstall; final VoidCallback onUpdate; + final VoidCallback? onTap; const _ExtensionItem({ required this.extension, @@ -384,6 +423,7 @@ class _ExtensionItem extends StatelessWidget { required this.isDownloading, required this.onInstall, required this.onUpdate, + this.onTap, }); IconData _getCategoryIcon(String category) { @@ -410,151 +450,162 @@ class _ExtensionItem extends StatelessWidget { return Column( mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Row( - children: [ - // Extension icon - custom or category-based - 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( + InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + // Extension icon - custom or category-based + 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, ), - 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, - ), + ), + const SizedBox(width: 16), + // Extension info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + extension.displayName, + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith(fontWeight: FontWeight.w500), ), - ); - }, - ) - : Icon( - _getCategoryIcon(extension.category), - color: extension.isInstalled - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, + ), + // Version badge + 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(width: 16), - // Extension info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - extension.displayName, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), - ), + const SizedBox(height: 2), + Text( + 'by ${extension.author}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, ), - // Version badge - 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: 4), + Text( + extension.description, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 12), + // Action button + 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: const Text('Update'), + ) + 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), ), ], ), - const SizedBox(height: 2), - Text( - 'by ${extension.author}', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + ) + else + FilledButton( + onPressed: onInstall, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12), + minimumSize: const Size(0, 36), ), - 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), - // Action button - 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: const Text('Install'), ), - child: const Text('Update'), - ) - 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: const Text('Install'), - ), - ], + ], + ), ), ), if (showDivider) diff --git a/pubspec.yaml b/pubspec.yaml index a3e97e20..1c4d0a3d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 3.0.0-beta.2+56 +version: 3.0.0+57 environment: sdk: ^3.10.0