mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-21 23:47:04 +02:00
release: v3.0.0 stable with Extension System
This commit is contained in:
+104
-10
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
|
||||
@@ -188,6 +188,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
||||
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<ExtensionDetailPage> {
|
||||
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<String> 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<ExtensionDetailsScreen> createState() =>
|
||||
_ExtensionDetailsScreenState();
|
||||
}
|
||||
|
||||
class _ExtensionDetailsScreenState
|
||||
extends ConsumerState<ExtensionDetailsScreen> {
|
||||
|
||||
@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<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.'
|
||||
: '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.'
|
||||
: 'Failed to update ${ext.displayName}',
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _uninstallExtension(StoreExtension ext) async {
|
||||
final confirm = await showDialog<bool>(
|
||||
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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+232
-181
@@ -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<StoreTab> {
|
||||
_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<StoreTab> {
|
||||
|
||||
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<StoreTab> {
|
||||
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<StoreTab> {
|
||||
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<StoreTab> {
|
||||
),
|
||||
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<StoreTab> {
|
||||
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<StoreTab> {
|
||||
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<StoreTab> {
|
||||
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<StoreTab> {
|
||||
),
|
||||
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<StoreTab> {
|
||||
}
|
||||
|
||||
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<StoreTab> {
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
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<StoreTab> {
|
||||
Future<void> _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<StoreTab> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user