diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cae6700..ea71ad4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -222,6 +222,36 @@ jobs: contents: write steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Extract changelog for version + id: changelog + run: | + VERSION=${{ needs.get-version.outputs.version }} + VERSION_NUM=${VERSION#v} # Remove 'v' prefix + + # Extract changelog section for this version + # Look for ## [X.X.X] and capture until next ## [ or end of file + CHANGELOG=$(awk -v ver="$VERSION_NUM" ' + /^## \[/ { + if (found) exit + if ($0 ~ "\\[" ver "\\]") found=1 + next + } + found { print } + ' CHANGELOG.md) + + # If no changelog found, use default message + if [ -z "$CHANGELOG" ]; then + CHANGELOG="See CHANGELOG.md for details." + fi + + # Save to file for multiline support + echo "$CHANGELOG" > /tmp/changelog.txt + echo "Extracted changelog:" + cat /tmp/changelog.txt + - name: Download Android APK uses: actions/download-artifact@v4 with: @@ -234,24 +264,45 @@ jobs: name: ios-ipa path: ./release + - name: Prepare release body + run: | + VERSION=${{ needs.get-version.outputs.version }} + cat > /tmp/release_body.txt << 'HEADER' + ## SpotiFLAC $VERSION + + Download Spotify tracks in FLAC quality from Tidal, Qobuz & Amazon Music. + + ### What's New + HEADER + + # Replace $VERSION in header + sed -i "s/\$VERSION/$VERSION/g" /tmp/release_body.txt + + cat /tmp/changelog.txt >> /tmp/release_body.txt + + cat >> /tmp/release_body.txt << FOOTER + + --- + + ### Downloads + - **Android (arm64)**: \`SpotiFLAC-${VERSION}-arm64.apk\` (recommended) + - **Android (arm32)**: \`SpotiFLAC-${VERSION}-arm32.apk\` (older devices) + - **iOS**: \`SpotiFLAC-${VERSION}-ios-unsigned.ipa\` (sideload required) + + ### Installation + **Android**: Enable "Install from unknown sources" and install the APK + **iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA + FOOTER + + echo "Release body:" + cat /tmp/release_body.txt + - name: Create Release uses: softprops/action-gh-release@v1 with: tag_name: ${{ needs.get-version.outputs.version }} name: SpotiFLAC ${{ needs.get-version.outputs.version }} - body: | - ## SpotiFLAC ${{ needs.get-version.outputs.version }} - - Download Spotify tracks in FLAC quality from Tidal, Qobuz & Amazon Music. - - ### Downloads - - **Android (arm64)**: `SpotiFLAC-${{ needs.get-version.outputs.version }}-arm64.apk` (recommended) - - **Android (arm32)**: `SpotiFLAC-${{ needs.get-version.outputs.version }}-arm32.apk` (older devices) - - **iOS**: `SpotiFLAC-${{ needs.get-version.outputs.version }}-ios-unsigned.ipa` (sideload required) - - ### Installation - **Android**: Enable "Install from unknown sources" and install the APK - **iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA + body_path: /tmp/release_body.txt files: ./release/* draft: false prerelease: false diff --git a/README.md b/README.md index 3dc1444..c31d67f 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,11 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account ## Screenshots

- - - - + + + + +

## Other project diff --git a/docs/Screenshot_20260101-210622_SpotiFLAC.png b/assets/images/Screenshot_20260101-210622_SpotiFLAC.png similarity index 100% rename from docs/Screenshot_20260101-210622_SpotiFLAC.png rename to assets/images/Screenshot_20260101-210622_SpotiFLAC.png diff --git a/docs/Screenshot_20260101-210626_SpotiFLAC.png b/assets/images/Screenshot_20260101-210626_SpotiFLAC.png similarity index 100% rename from docs/Screenshot_20260101-210626_SpotiFLAC.png rename to assets/images/Screenshot_20260101-210626_SpotiFLAC.png diff --git a/docs/Screenshot_20260101-210653_SpotiFLAC.png b/assets/images/Screenshot_20260101-210653_SpotiFLAC.png similarity index 100% rename from docs/Screenshot_20260101-210653_SpotiFLAC.png rename to assets/images/Screenshot_20260101-210653_SpotiFLAC.png diff --git a/assets/images/photo_2026-01-01_23-44-06.jpg b/assets/images/photo_2026-01-01_23-44-06.jpg new file mode 100644 index 0000000..71a582f Binary files /dev/null and b/assets/images/photo_2026-01-01_23-44-06.jpg differ diff --git a/assets/images/photo_2026-01-01_23-56-11.jpg b/assets/images/photo_2026-01-01_23-56-11.jpg new file mode 100644 index 0000000..cdefe05 Binary files /dev/null and b/assets/images/photo_2026-01-01_23-56-11.jpg differ diff --git a/docs/Screenshot_20260101-210633_SpotiFLAC.png b/docs/Screenshot_20260101-210633_SpotiFLAC.png deleted file mode 100644 index 7ac054d..0000000 Binary files a/docs/Screenshot_20260101-210633_SpotiFLAC.png and /dev/null differ diff --git a/go_backend/exports.go b/go_backend/exports.go index 916f06a..d5c4ec1 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -99,6 +99,7 @@ type DownloadRequest struct { CoverURL string `json:"cover_url"` OutputDir string `json:"output_dir"` FilenameFormat string `json:"filename_format"` + Quality string `json:"quality"` // LOSSLESS, HI_RES, HI_RES_LOSSLESS EmbedLyrics bool `json:"embed_lyrics"` EmbedMaxQualityCover bool `json:"embed_max_quality_cover"` TrackNumber int `json:"track_number"` diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 1c6fea5..0034c44 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -346,8 +346,22 @@ func downloadFromQobuz(req DownloadRequest) (string, error) { return "EXISTS:" + outputPath, nil } + // Map quality from Tidal format to Qobuz format + // Tidal: LOSSLESS (16-bit), HI_RES (24-bit), HI_RES_LOSSLESS (24-bit hi-res) + // Qobuz: 5 (MP3 320), 6 (16-bit), 7 (24-bit 96kHz), 27 (24-bit 192kHz) + qobuzQuality := "27" // Default to highest quality + switch req.Quality { + case "LOSSLESS": + qobuzQuality = "6" // 16-bit FLAC + case "HI_RES": + qobuzQuality = "7" // 24-bit 96kHz + case "HI_RES_LOSSLESS": + qobuzQuality = "27" // 24-bit 192kHz + } + fmt.Printf("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality) + // Get download URL using parallel API requests - downloadURL, err := downloader.GetDownloadURL(track.ID, "27") // 27 = FLAC 24-bit + downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality) if err != nil { return "", fmt.Errorf("failed to get download URL: %w", err) } diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 8551e4a..8d3df9f 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -859,8 +859,15 @@ func downloadFromTidal(req DownloadRequest) (string, error) { return "EXISTS:" + outputPath, nil } + // Determine quality to use (default to LOSSLESS if not specified) + quality := req.Quality + if quality == "" { + quality = "LOSSLESS" + } + fmt.Printf("[Tidal] Using quality: %s\n", quality) + // Get download URL using parallel API requests - downloadURL, err := downloader.GetDownloadURL(track.ID, "LOSSLESS") + downloadURL, err := downloader.GetDownloadURL(track.ID, quality) if err != nil { return "", fmt.Errorf("failed to get download URL: %w", err) } diff --git a/lib/app.dart b/lib/app.dart index fc8638e..654f75b 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -7,10 +7,11 @@ import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart'; final _routerProvider = Provider((ref) { - final settings = ref.watch(settingsProvider); + // Only watch isFirstLaunch to prevent router rebuild on other settings changes + final isFirstLaunch = ref.watch(settingsProvider.select((s) => s.isFirstLaunch)); return GoRouter( - initialLocation: settings.isFirstLaunch ? '/setup' : '/', + initialLocation: isFirstLaunch ? '/setup' : '/', routes: [ GoRoute( path: '/', diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart new file mode 100644 index 0000000..25f4723 --- /dev/null +++ b/lib/constants/app_info.dart @@ -0,0 +1,17 @@ +/// App version and info constants +/// Update version here only - all other files will reference this +class AppInfo { + static const String version = '1.2.0'; + static const String buildNumber = '10'; + static const String fullVersion = '$version+$buildNumber'; + + static const String appName = 'SpotiFLAC'; + static const String copyright = '© 2026 SpotiFLAC'; + + static const String mobileAuthor = 'zarzet'; + static const String originalAuthor = 'afkarxyz'; + + static const String githubRepo = 'zarzet/SpotiFLAC-Mobile'; + static const String githubUrl = 'https://github.com/$githubRepo'; + static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC'; +} diff --git a/lib/models/settings.dart b/lib/models/settings.dart index c91f221..af12030 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -13,6 +13,7 @@ class AppSettings { final bool maxQualityCover; final bool isFirstLaunch; final int concurrentDownloads; // 1 = sequential (default), max 3 + final bool checkForUpdates; // Check for updates on app start const AppSettings({ this.defaultService = 'tidal', @@ -24,6 +25,7 @@ class AppSettings { this.maxQualityCover = true, this.isFirstLaunch = true, this.concurrentDownloads = 1, // Default: sequential (off) + this.checkForUpdates = true, // Default: enabled }); AppSettings copyWith({ @@ -36,6 +38,7 @@ class AppSettings { bool? maxQualityCover, bool? isFirstLaunch, int? concurrentDownloads, + bool? checkForUpdates, }) { return AppSettings( defaultService: defaultService ?? this.defaultService, @@ -47,6 +50,7 @@ class AppSettings { maxQualityCover: maxQualityCover ?? this.maxQualityCover, isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch, concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads, + checkForUpdates: checkForUpdates ?? this.checkForUpdates, ); } diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 57a53eb..564baae 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -16,6 +16,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( maxQualityCover: json['maxQualityCover'] as bool? ?? true, isFirstLaunch: json['isFirstLaunch'] as bool? ?? true, concurrentDownloads: (json['concurrentDownloads'] as num?)?.toInt() ?? 1, + checkForUpdates: json['checkForUpdates'] as bool? ?? true, ); Map _$AppSettingsToJson(AppSettings instance) => @@ -29,4 +30,5 @@ Map _$AppSettingsToJson(AppSettings instance) => 'maxQualityCover': instance.maxQualityCover, 'isFirstLaunch': instance.isFirstLaunch, 'concurrentDownloads': instance.concurrentDownloads, + 'checkForUpdates': instance.checkForUpdates, }; diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 3dcc963..5cefccb 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -9,6 +9,7 @@ import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart'; import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; @@ -18,20 +19,37 @@ class DownloadHistoryItem { final String trackName; final String artistName; final String albumName; + final String? albumArtist; final String? coverUrl; final String filePath; final String service; final DateTime downloadedAt; + // Additional metadata + final String? isrc; + final String? spotifyId; + final int? trackNumber; + final int? discNumber; + final int? duration; + final String? releaseDate; + final String? quality; const DownloadHistoryItem({ required this.id, required this.trackName, required this.artistName, required this.albumName, + this.albumArtist, this.coverUrl, required this.filePath, required this.service, required this.downloadedAt, + this.isrc, + this.spotifyId, + this.trackNumber, + this.discNumber, + this.duration, + this.releaseDate, + this.quality, }); Map toJson() => { @@ -39,10 +57,18 @@ class DownloadHistoryItem { 'trackName': trackName, 'artistName': artistName, 'albumName': albumName, + 'albumArtist': albumArtist, 'coverUrl': coverUrl, 'filePath': filePath, 'service': service, 'downloadedAt': downloadedAt.toIso8601String(), + 'isrc': isrc, + 'spotifyId': spotifyId, + 'trackNumber': trackNumber, + 'discNumber': discNumber, + 'duration': duration, + 'releaseDate': releaseDate, + 'quality': quality, }; factory DownloadHistoryItem.fromJson(Map json) => DownloadHistoryItem( @@ -50,10 +76,18 @@ class DownloadHistoryItem { trackName: json['trackName'] as String, artistName: json['artistName'] as String, albumName: json['albumName'] as String, + albumArtist: json['albumArtist'] as String?, coverUrl: json['coverUrl'] as String?, filePath: json['filePath'] as String, service: json['service'] as String, downloadedAt: DateTime.parse(json['downloadedAt'] as String), + isrc: json['isrc'] as String?, + spotifyId: json['spotifyId'] as String?, + trackNumber: json['trackNumber'] as int?, + discNumber: json['discNumber'] as int?, + duration: json['duration'] as int?, + releaseDate: json['releaseDate'] as String?, + quality: json['quality'] as String?, ); } @@ -151,6 +185,7 @@ class DownloadQueueState { final bool isProcessing; final String outputDir; final String filenameFormat; + final String audioQuality; // LOSSLESS, HI_RES, HI_RES_LOSSLESS final bool autoFallback; final int concurrentDownloads; // 1 = sequential, max 3 @@ -160,6 +195,7 @@ class DownloadQueueState { this.isProcessing = false, this.outputDir = '', this.filenameFormat = '{artist} - {title}', + this.audioQuality = 'LOSSLESS', this.autoFallback = true, this.concurrentDownloads = 1, }); @@ -170,6 +206,7 @@ class DownloadQueueState { bool? isProcessing, String? outputDir, String? filenameFormat, + String? audioQuality, bool? autoFallback, int? concurrentDownloads, }) { @@ -179,6 +216,7 @@ class DownloadQueueState { isProcessing: isProcessing ?? this.isProcessing, outputDir: outputDir ?? this.outputDir, filenameFormat: filenameFormat ?? this.filenameFormat, + audioQuality: audioQuality ?? this.audioQuality, autoFallback: autoFallback ?? this.autoFallback, concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads, ); @@ -284,12 +322,17 @@ class DownloadQueueNotifier extends Notifier { state = state.copyWith( outputDir: settings.downloadDirectory.isNotEmpty ? settings.downloadDirectory : state.outputDir, filenameFormat: settings.filenameFormat, + audioQuality: settings.audioQuality, autoFallback: settings.autoFallback, concurrentDownloads: settings.concurrentDownloads, ); } String addToQueue(Track track, String service) { + // Sync settings before adding to queue + final settings = ref.read(settingsProvider); + updateSettings(settings); + final id = '${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}'; final item = DownloadItem( id: id, @@ -309,6 +352,10 @@ class DownloadQueueNotifier extends Notifier { } void addMultipleToQueue(List tracks, String service) { + // Sync settings before adding to queue + final settings = ref.read(settingsProvider); + updateSettings(settings); + final newItems = tracks.map((track) { final id = '${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}'; return DownloadItem( @@ -561,6 +608,7 @@ class DownloadQueueNotifier extends Notifier { if (state.autoFallback) { print('[DownloadQueue] Using auto-fallback mode'); + print('[DownloadQueue] Quality: ${state.audioQuality}'); result = await PlatformBridge.downloadWithFallback( isrc: item.track.isrc ?? '', spotifyId: item.track.id, @@ -571,6 +619,7 @@ class DownloadQueueNotifier extends Notifier { coverUrl: item.track.coverUrl, outputDir: state.outputDir, filenameFormat: state.filenameFormat, + quality: state.audioQuality, trackNumber: item.track.trackNumber ?? 1, discNumber: item.track.discNumber ?? 1, releaseDate: item.track.releaseDate, @@ -588,6 +637,7 @@ class DownloadQueueNotifier extends Notifier { coverUrl: item.track.coverUrl, outputDir: state.outputDir, filenameFormat: state.filenameFormat, + quality: state.audioQuality, trackNumber: item.track.trackNumber ?? 1, discNumber: item.track.discNumber ?? 1, releaseDate: item.track.releaseDate, @@ -642,10 +692,19 @@ class DownloadQueueNotifier extends Notifier { trackName: item.track.name, artistName: item.track.artistName, albumName: item.track.albumName, + albumArtist: item.track.albumArtist, coverUrl: item.track.coverUrl, filePath: filePath, service: result['service'] as String? ?? item.service, downloadedAt: DateTime.now(), + // Additional metadata + isrc: item.track.isrc, + spotifyId: item.track.id, + trackNumber: item.track.trackNumber, + discNumber: item.track.discNumber, + duration: item.track.duration, + releaseDate: item.track.releaseDate, + quality: state.audioQuality, ), ); } diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index f92d683..403b3d8 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -71,6 +71,11 @@ class SettingsNotifier extends Notifier { state = state.copyWith(concurrentDownloads: clamped); _saveSettings(); } + + void setCheckForUpdates(bool enabled) { + state = state.copyWith(checkForUpdates: enabled); + _saveSettings(); + } } final settingsProvider = NotifierProvider( diff --git a/lib/screens/history_screen.dart b/lib/screens/history_screen.dart deleted file mode 100644 index b03d840..0000000 --- a/lib/screens/history_screen.dart +++ /dev/null @@ -1,372 +0,0 @@ -import 'dart:io'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:open_filex/open_filex.dart'; -import 'package:spotiflac_android/providers/download_queue_provider.dart'; - -class HistoryScreen extends ConsumerWidget { - const HistoryScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final historyState = ref.watch(downloadHistoryProvider); - final history = historyState.items; - final colorScheme = Theme.of(context).colorScheme; - - return Scaffold( - appBar: AppBar( - title: const Text('Download History'), - actions: [ - if (history.isNotEmpty) - IconButton( - icon: const Icon(Icons.delete_sweep), - onPressed: () => _showClearHistoryDialog(context, ref), - tooltip: 'Clear history', - ), - ], - ), - body: history.isEmpty - ? _buildEmptyState(context, colorScheme) - : ListView.builder( - itemCount: history.length, - itemBuilder: (context, index) { - final item = history[index]; - return _buildHistoryItem(context, ref, item, colorScheme); - }, - ), - ); - } - - Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.history, - size: 64, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 16), - Text( - 'No download history', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Text( - 'Downloaded tracks will appear here', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), - ), - ), - ], - ), - ); - } - - Widget _buildHistoryItem(BuildContext context, WidgetRef ref, DownloadHistoryItem item, ColorScheme colorScheme) { - final fileExists = File(item.filePath).existsSync(); - - return Dismissible( - key: Key(item.id), - direction: DismissDirection.endToStart, - background: Container( - alignment: Alignment.centerRight, - padding: const EdgeInsets.only(right: 16), - color: colorScheme.error, - child: Icon(Icons.delete, color: colorScheme.onError), - ), - onDismissed: (_) { - ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Removed "${item.trackName}" from history')), - ); - }, - child: ListTile( - leading: item.coverUrl != null - ? ClipRRect( - borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage( - imageUrl: item.coverUrl!, - width: 48, - height: 48, - fit: BoxFit.cover, - placeholder: (_, __) => Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ) - : Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), - ), - title: Text( - item.trackName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.artistName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: colorScheme.onSurfaceVariant), - ), - Row( - children: [ - Icon( - _getServiceIcon(item.service), - size: 12, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 4), - Text( - _formatDate(item.downloadedAt), - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - if (!fileExists) ...[ - const SizedBox(width: 8), - Icon( - Icons.warning, - size: 12, - color: colorScheme.error, - ), - const SizedBox(width: 2), - Text( - 'File missing', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.error, - ), - ), - ], - ], - ), - ], - ), - trailing: fileExists - ? IconButton( - icon: Icon(Icons.play_arrow, color: colorScheme.primary), - onPressed: () => _openFile(context, item.filePath), - ) - : Icon(Icons.error_outline, color: colorScheme.onSurfaceVariant), - onTap: fileExists ? () => _openFile(context, item.filePath) : null, - onLongPress: () => _showItemDetails(context, ref, item, colorScheme), - ), - ); - } - - IconData _getServiceIcon(String service) { - switch (service.toLowerCase()) { - case 'tidal': - return Icons.waves; - case 'qobuz': - return Icons.album; - case 'amazon': - return Icons.shopping_cart; - default: - return Icons.cloud_download; - } - } - - String _formatDate(DateTime date) { - final now = DateTime.now(); - final diff = now.difference(date); - - if (diff.inDays == 0) { - if (diff.inHours == 0) { - return '${diff.inMinutes}m ago'; - } - return '${diff.inHours}h ago'; - } else if (diff.inDays == 1) { - return 'Yesterday'; - } else if (diff.inDays < 7) { - return '${diff.inDays}d ago'; - } else { - return '${date.day}/${date.month}/${date.year}'; - } - } - - Future _openFile(BuildContext context, String filePath) async { - try { - final result = await OpenFilex.open(filePath); - - if (result.type != ResultType.done) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Cannot open: ${result.message}'), - action: SnackBarAction( - label: 'Copy Path', - onPressed: () { - Clipboard.setData(ClipboardData(text: filePath)); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Path copied to clipboard')), - ); - }, - ), - ), - ); - } - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Cannot open file: $e')), - ); - } - } - } - - void _showItemDetails(BuildContext context, WidgetRef ref, DownloadHistoryItem item, ColorScheme colorScheme) { - showModalBottomSheet( - context: context, - builder: (context) => Container( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - if (item.coverUrl != null) - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: CachedNetworkImage( - imageUrl: item.coverUrl!, - width: 64, - height: 64, - fit: BoxFit.cover, - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.trackName, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - Text( - item.artistName, - style: TextStyle(color: colorScheme.onSurfaceVariant), - ), - Text( - item.albumName, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 16), - const Divider(), - _buildDetailRow(context, 'Service', item.service.toUpperCase(), colorScheme), - _buildDetailRow(context, 'Downloaded', _formatDate(item.downloadedAt), colorScheme), - _buildDetailRow(context, 'File', item.filePath, colorScheme, isPath: true), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - TextButton.icon( - onPressed: () { - ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id); - Navigator.pop(context); - }, - icon: Icon(Icons.delete, color: colorScheme.error), - label: Text('Remove', style: TextStyle(color: colorScheme.error)), - ), - if (File(item.filePath).existsSync()) - TextButton.icon( - onPressed: () { - Navigator.pop(context); - _openFile(context, item.filePath); - }, - icon: Icon(Icons.play_arrow, color: colorScheme.primary), - label: Text('Play', style: TextStyle(color: colorScheme.primary)), - ), - ], - ), - ], - ), - ), - ); - } - - Widget _buildDetailRow(BuildContext context, String label, String value, ColorScheme colorScheme, {bool isPath = false}) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 80, - child: Text( - label, - style: TextStyle(color: colorScheme.onSurfaceVariant), - ), - ), - Expanded( - child: Text( - value, - style: TextStyle( - fontSize: isPath ? 12 : 14, - fontFamily: isPath ? 'monospace' : null, - ), - ), - ), - ], - ), - ); - } - - void _showClearHistoryDialog(BuildContext context, WidgetRef ref) { - final colorScheme = Theme.of(context).colorScheme; - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Clear History'), - content: const Text( - 'Are you sure you want to clear all download history? ' - 'This will not delete the downloaded files.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () { - ref.read(downloadHistoryProvider.notifier).clearHistory(); - Navigator.pop(context); - }, - child: Text('Clear', style: TextStyle(color: colorScheme.error)), - ), - ], - ), - ); - } -} diff --git a/lib/screens/history_tab.dart b/lib/screens/history_tab.dart deleted file mode 100644 index 4122e23..0000000 --- a/lib/screens/history_tab.dart +++ /dev/null @@ -1,388 +0,0 @@ -import 'dart:io'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:open_filex/open_filex.dart'; -import 'package:spotiflac_android/providers/download_queue_provider.dart'; - -class HistoryTab extends ConsumerWidget { - const HistoryTab({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final historyState = ref.watch(downloadHistoryProvider); - final history = historyState.items; - final colorScheme = Theme.of(context).colorScheme; - - return Column( - children: [ - // Header with clear action - if (history.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '${history.length} downloads', - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - TextButton.icon( - onPressed: () => _showClearHistoryDialog(context, ref), - icon: Icon(Icons.delete_sweep, size: 18, color: colorScheme.error), - label: Text('Clear history', style: TextStyle(color: colorScheme.error)), - ), - ], - ), - ), - - // History list - Expanded( - child: history.isEmpty - ? _buildEmptyState(context, colorScheme) - : ListView.builder( - itemCount: history.length, - itemBuilder: (context, index) { - final item = history[index]; - return _buildHistoryItem(context, ref, item, colorScheme); - }, - ), - ), - ], - ); - } - - Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.history, - size: 64, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 16), - Text( - 'No download history', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Text( - 'Downloaded tracks will appear here', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), - ), - ), - ], - ), - ); - } - - Widget _buildHistoryItem(BuildContext context, WidgetRef ref, DownloadHistoryItem item, ColorScheme colorScheme) { - final fileExists = File(item.filePath).existsSync(); - - return Dismissible( - key: Key(item.id), - direction: DismissDirection.endToStart, - background: Container( - alignment: Alignment.centerRight, - padding: const EdgeInsets.only(right: 16), - color: colorScheme.error, - child: Icon(Icons.delete, color: colorScheme.onError), - ), - onDismissed: (_) { - ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Removed "${item.trackName}" from history')), - ); - }, - child: ListTile( - leading: item.coverUrl != null - ? ClipRRect( - borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage( - imageUrl: item.coverUrl!, - width: 48, - height: 48, - fit: BoxFit.cover, - placeholder: (_, __) => Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ) - : Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), - ), - title: Text( - item.trackName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.artistName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: colorScheme.onSurfaceVariant), - ), - Row( - children: [ - Icon( - _getServiceIcon(item.service), - size: 12, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 4), - Text( - _formatDate(item.downloadedAt), - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - if (!fileExists) ...[ - const SizedBox(width: 8), - Icon( - Icons.warning, - size: 12, - color: colorScheme.error, - ), - const SizedBox(width: 2), - Text( - 'File missing', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.error, - ), - ), - ], - ], - ), - ], - ), - trailing: fileExists - ? IconButton( - icon: Icon(Icons.play_arrow, color: colorScheme.primary), - onPressed: () => _openFile(context, item.filePath), - ) - : Icon(Icons.error_outline, color: colorScheme.onSurfaceVariant), - onTap: fileExists ? () => _openFile(context, item.filePath) : null, - onLongPress: () => _showItemDetails(context, ref, item, colorScheme), - ), - ); - } - - IconData _getServiceIcon(String service) { - switch (service.toLowerCase()) { - case 'tidal': - return Icons.waves; - case 'qobuz': - return Icons.album; - case 'amazon': - return Icons.shopping_cart; - default: - return Icons.cloud_download; - } - } - - String _formatDate(DateTime date) { - final now = DateTime.now(); - final diff = now.difference(date); - - if (diff.inDays == 0) { - if (diff.inHours == 0) { - return '${diff.inMinutes}m ago'; - } - return '${diff.inHours}h ago'; - } else if (diff.inDays == 1) { - return 'Yesterday'; - } else if (diff.inDays < 7) { - return '${diff.inDays}d ago'; - } else { - return '${date.day}/${date.month}/${date.year}'; - } - } - - Future _openFile(BuildContext context, String filePath) async { - try { - final result = await OpenFilex.open(filePath); - - if (result.type != ResultType.done) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Cannot open: ${result.message}'), - action: SnackBarAction( - label: 'Copy Path', - onPressed: () { - Clipboard.setData(ClipboardData(text: filePath)); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Path copied to clipboard')), - ); - }, - ), - ), - ); - } - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Cannot open file: $e')), - ); - } - } - } - - void _showItemDetails(BuildContext context, WidgetRef ref, DownloadHistoryItem item, ColorScheme colorScheme) { - showModalBottomSheet( - context: context, - builder: (context) => Container( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - if (item.coverUrl != null) - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: CachedNetworkImage( - imageUrl: item.coverUrl!, - width: 64, - height: 64, - fit: BoxFit.cover, - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.trackName, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - Text( - item.artistName, - style: TextStyle(color: colorScheme.onSurfaceVariant), - ), - Text( - item.albumName, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 16), - const Divider(), - _buildDetailRow(context, 'Service', item.service.toUpperCase(), colorScheme), - _buildDetailRow(context, 'Downloaded', _formatDate(item.downloadedAt), colorScheme), - _buildDetailRow(context, 'File', item.filePath, colorScheme, isPath: true), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - TextButton.icon( - onPressed: () { - ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id); - Navigator.pop(context); - }, - icon: Icon(Icons.delete, color: colorScheme.error), - label: Text('Remove', style: TextStyle(color: colorScheme.error)), - ), - if (File(item.filePath).existsSync()) - TextButton.icon( - onPressed: () { - Navigator.pop(context); - _openFile(context, item.filePath); - }, - icon: Icon(Icons.play_arrow, color: colorScheme.primary), - label: Text('Play', style: TextStyle(color: colorScheme.primary)), - ), - ], - ), - ], - ), - ), - ); - } - - Widget _buildDetailRow(BuildContext context, String label, String value, ColorScheme colorScheme, {bool isPath = false}) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 80, - child: Text( - label, - style: TextStyle(color: colorScheme.onSurfaceVariant), - ), - ), - Expanded( - child: Text( - value, - style: TextStyle( - fontSize: isPath ? 12 : 14, - fontFamily: isPath ? 'monospace' : null, - ), - ), - ), - ], - ), - ); - } - - void _showClearHistoryDialog(BuildContext context, WidgetRef ref) { - final colorScheme = Theme.of(context).colorScheme; - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Clear History'), - content: const Text( - 'Are you sure you want to clear all download history? ' - 'This will not delete the downloaded files.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () { - ref.read(downloadHistoryProvider.notifier).clearHistory(); - Navigator.pop(context); - }, - child: Text('Clear', style: TextStyle(color: colorScheme.error)), - ), - ], - ), - ); - } -} diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index ba08918..0d4255c 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -7,6 +7,7 @@ import 'package:open_filex/open_filex.dart'; import 'package:spotiflac_android/providers/track_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/screens/track_metadata_screen.dart'; class HomeTab extends ConsumerStatefulWidget { const HomeTab({super.key}); @@ -326,25 +327,28 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient final fileExists = File(item.filePath).existsSync(); return ListTile( - leading: item.coverUrl != null - ? ClipRRect( - borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage( - imageUrl: item.coverUrl!, + leading: Hero( + tag: 'cover_${item.id}', + child: item.coverUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CachedNetworkImage( + imageUrl: item.coverUrl!, + width: 48, + height: 48, + fit: BoxFit.cover, + ), + ) + : Container( width: 48, height: 48, - fit: BoxFit.cover, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), ), - ) - : Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), - ), + ), title: Text(item.trackName, maxLines: 1, overflow: TextOverflow.ellipsis), subtitle: Text( item.artistName, @@ -358,7 +362,26 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient onPressed: () => _openFile(item.filePath), ) : Icon(Icons.error_outline, color: colorScheme.error, size: 20), - onTap: fileExists ? () => _openFile(item.filePath) : null, + // Tap to show metadata details + onTap: () => _navigateToMetadataScreen(item), + ); + } + + void _navigateToMetadataScreen(DownloadHistoryItem item) { + Navigator.push( + context, + PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 300), + reverseTransitionDuration: const Duration(milliseconds: 250), + pageBuilder: (context, animation, secondaryAnimation) => + TrackMetadataScreen(item: item), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeTransition( + opacity: animation, + child: child, + ); + }, + ), ); } @@ -443,10 +466,51 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient child: ListView.builder( controller: scrollController, itemCount: historyState.items.length, - itemBuilder: (context, index) => _buildHistoryTile( - historyState.items[index], - colorScheme, - ), + itemBuilder: (context, index) { + final item = historyState.items[index]; + final fileExists = File(item.filePath).existsSync(); + + return ListTile( + leading: item.coverUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CachedNetworkImage( + imageUrl: item.coverUrl!, + width: 48, + height: 48, + fit: BoxFit.cover, + ), + ) + : Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + ), + title: Text(item.trackName, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text( + item.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + trailing: fileExists + ? IconButton( + icon: Icon(Icons.play_arrow, color: colorScheme.primary), + onPressed: () => _openFile(item.filePath), + ) + : Icon(Icons.error_outline, color: colorScheme.error, size: 20), + onTap: () { + Navigator.pop(context); // Close bottom sheet first + Future.delayed(const Duration(milliseconds: 100), () { + _navigateToMetadataScreen(item); + }); + }, + ); + }, ), ), ], diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index e1f1e1e..695d2a4 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/screens/home_tab.dart'; import 'package:spotiflac_android/screens/queue_tab.dart'; import 'package:spotiflac_android/screens/settings_tab.dart'; +import 'package:spotiflac_android/services/update_checker.dart'; +import 'package:spotiflac_android/widgets/update_dialog.dart'; class MainShell extends ConsumerStatefulWidget { const MainShell({super.key}); @@ -15,11 +18,43 @@ class MainShell extends ConsumerStatefulWidget { class _MainShellState extends ConsumerState { int _currentIndex = 0; late PageController _pageController; + bool _hasCheckedUpdate = false; + bool _isAnimating = false; + + // Cache tab widgets to prevent rebuilds + final List _tabs = const [ + HomeTab(), + QueueTab(), + SettingsTab(), + ]; @override void initState() { super.initState(); _pageController = PageController(initialPage: _currentIndex); + // Check for updates after first frame + WidgetsBinding.instance.addPostFrameCallback((_) { + _checkForUpdates(); + }); + } + + Future _checkForUpdates() async { + if (_hasCheckedUpdate) return; + _hasCheckedUpdate = true; + + final settings = ref.read(settingsProvider); + if (!settings.checkForUpdates) return; + + final updateInfo = await UpdateChecker.checkForUpdate(); + if (updateInfo != null && mounted) { + showUpdateDialog( + context, + updateInfo: updateInfo, + onDisableUpdates: () { + ref.read(settingsProvider.notifier).setCheckForUpdates(false); + }, + ); + } } @override @@ -29,16 +64,21 @@ class _MainShellState extends ConsumerState { } void _onNavTap(int index) { - setState(() => _currentIndex = index); - _pageController.animateToPage( - index, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); + if (_currentIndex != index && !_isAnimating) { + _isAnimating = true; + setState(() => _currentIndex = index); + _pageController.animateToPage( + index, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ).then((_) => _isAnimating = false); + } } void _onPageChanged(int index) { - setState(() => _currentIndex = index); + if (_currentIndex != index) { + setState(() => _currentIndex = index); + } } @override @@ -63,12 +103,8 @@ class _MainShellState extends ConsumerState { body: PageView( controller: _pageController, onPageChanged: _onPageChanged, - physics: const BouncingScrollPhysics(), - children: const [ - HomeTab(), - QueueTab(), - SettingsTab(), - ], + physics: const ClampingScrollPhysics(), + children: _tabs, ), bottomNavigationBar: NavigationBar( selectedIndex: _currentIndex, diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 5edd680..b8f237e 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:file_picker/file_picker.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/theme_provider.dart'; @@ -133,6 +134,15 @@ class SettingsScreen extends ConsumerWidget { : '${settings.concurrentDownloads} parallel downloads'), onTap: () => _showConcurrentDownloadsPicker(context, ref, settings.concurrentDownloads), ), + + // Check for Updates + SwitchListTile( + secondary: Icon(Icons.system_update, color: colorScheme.primary), + title: const Text('Check for Updates'), + subtitle: const Text('Notify when new version is available'), + value: settings.checkForUpdates, + onChanged: (value) => ref.read(settingsProvider.notifier).setCheckForUpdates(value), + ), const Divider(), @@ -141,22 +151,22 @@ class SettingsScreen extends ConsumerWidget { ListTile( leading: Icon(Icons.code, color: colorScheme.primary), - title: const Text('SpotiFLAC Mobile'), - subtitle: const Text('github.com/zarzet/SpotiFLAC-Mobile'), - onTap: () => _launchUrl('https://github.com/zarzet/SpotiFLAC-Mobile'), + title: Text('${AppInfo.appName} Mobile'), + subtitle: Text('github.com/${AppInfo.githubRepo}'), + onTap: () => _launchUrl(AppInfo.githubUrl), ), ListTile( leading: Icon(Icons.computer, color: colorScheme.primary), - title: const Text('Original SpotiFLAC (Desktop)'), - subtitle: const Text('github.com/afkarxyz/SpotiFLAC'), - onTap: () => _launchUrl('https://github.com/afkarxyz/SpotiFLAC'), + title: Text('Original ${AppInfo.appName} (Desktop)'), + subtitle: Text('github.com/${AppInfo.originalAuthor}/SpotiFLAC'), + onTap: () => _launchUrl(AppInfo.originalGithubUrl), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Text( - 'Mobile version maintained by zarzet\nOriginal project by afkarxyz', + 'Mobile version maintained by ${AppInfo.mobileAuthor}\nOriginal project by ${AppInfo.originalAuthor}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -169,7 +179,7 @@ class SettingsScreen extends ConsumerWidget { ListTile( leading: Icon(Icons.info, color: colorScheme.primary), title: const Text('About'), - subtitle: const Text('SpotiFLAC v1.1.1'), + subtitle: Text('${AppInfo.appName} v${AppInfo.version}'), onTap: () => _showAboutDialog(context), ), ], @@ -186,21 +196,21 @@ class SettingsScreen extends ConsumerWidget { children: [ Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, __, ___) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)), const SizedBox(width: 12), - const Text('SpotiFLAC'), + Text(AppInfo.appName), ], ), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildAboutRow('Version', '1.1.1', colorScheme), + _buildAboutRow('Version', AppInfo.version, colorScheme), const SizedBox(height: 8), - _buildAboutRow('Mobile', 'zarzet', colorScheme), + _buildAboutRow('Mobile', AppInfo.mobileAuthor, colorScheme), const SizedBox(height: 8), - _buildAboutRow('Original', 'afkarxyz', colorScheme), + _buildAboutRow('Original', AppInfo.originalAuthor, colorScheme), const SizedBox(height: 16), Text( - '© 2026 SpotiFLAC', + AppInfo.copyright, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), diff --git a/lib/screens/settings_tab.dart b/lib/screens/settings_tab.dart index 5084649..d81eeda 100644 --- a/lib/screens/settings_tab.dart +++ b/lib/screens/settings_tab.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:file_picker/file_picker.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/theme_provider.dart'; @@ -140,6 +141,15 @@ class _SettingsTabState extends ConsumerState with AutomaticKeepAli : '${settings.concurrentDownloads} parallel downloads'), onTap: () => _showConcurrentDownloadsPicker(context, ref, settings.concurrentDownloads), ), + + // Check for Updates + SwitchListTile( + secondary: Icon(Icons.system_update, color: colorScheme.primary), + title: const Text('Check for Updates'), + subtitle: const Text('Notify when new version is available'), + value: settings.checkForUpdates, + onChanged: (value) => ref.read(settingsProvider.notifier).setCheckForUpdates(value), + ), const Divider(), @@ -148,22 +158,22 @@ class _SettingsTabState extends ConsumerState with AutomaticKeepAli ListTile( leading: Icon(Icons.code, color: colorScheme.primary), - title: const Text('SpotiFLAC Mobile'), - subtitle: const Text('github.com/zarzet/SpotiFLAC-Mobile'), - onTap: () => _launchUrl('https://github.com/zarzet/SpotiFLAC-Mobile'), + title: Text('${AppInfo.appName} Mobile'), + subtitle: Text('github.com/${AppInfo.githubRepo}'), + onTap: () => _launchUrl(AppInfo.githubUrl), ), ListTile( leading: Icon(Icons.computer, color: colorScheme.primary), - title: const Text('Original SpotiFLAC (Desktop)'), - subtitle: const Text('github.com/afkarxyz/SpotiFLAC'), - onTap: () => _launchUrl('https://github.com/afkarxyz/SpotiFLAC'), + title: Text('Original ${AppInfo.appName} (Desktop)'), + subtitle: Text('github.com/${AppInfo.originalAuthor}/SpotiFLAC'), + onTap: () => _launchUrl(AppInfo.originalGithubUrl), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Text( - 'Mobile version maintained by zarzet\nOriginal project by afkarxyz', + 'Mobile version maintained by ${AppInfo.mobileAuthor}\nOriginal project by ${AppInfo.originalAuthor}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -176,7 +186,7 @@ class _SettingsTabState extends ConsumerState with AutomaticKeepAli ListTile( leading: Icon(Icons.info, color: colorScheme.primary), title: const Text('About'), - subtitle: const Text('SpotiFLAC v1.1.1'), + subtitle: Text('${AppInfo.appName} v${AppInfo.version}'), onTap: () => _showAboutDialog(context), ), @@ -195,21 +205,21 @@ class _SettingsTabState extends ConsumerState with AutomaticKeepAli children: [ Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, __, ___) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)), const SizedBox(width: 12), - const Text('SpotiFLAC'), + Text(AppInfo.appName), ], ), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildAboutRow('Version', '1.1.1', colorScheme), + _buildAboutRow('Version', AppInfo.version, colorScheme), const SizedBox(height: 8), - _buildAboutRow('Mobile', 'zarzet', colorScheme), + _buildAboutRow('Mobile', AppInfo.mobileAuthor, colorScheme), const SizedBox(height: 8), - _buildAboutRow('Original', 'afkarxyz', colorScheme), + _buildAboutRow('Original', AppInfo.originalAuthor, colorScheme), const SizedBox(height: 16), Text( - '© 2026 SpotiFLAC', + AppInfo.copyright, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -268,8 +278,9 @@ class _SettingsTabState extends ConsumerState with AutomaticKeepAli String _getQualityName(String quality) { switch (quality) { - case 'LOSSLESS': return 'FLAC (Lossless)'; - case 'HI_RES': return 'Hi-Res FLAC (24-bit)'; + case 'LOSSLESS': return 'FLAC (16-bit / 44.1kHz)'; + case 'HI_RES': return 'Hi-Res FLAC (24-bit / 96kHz)'; + case 'HI_RES_LOSSLESS': return 'Hi-Res FLAC (24-bit / 192kHz)'; default: return quality; } } @@ -380,7 +391,8 @@ class _SettingsTabState extends ConsumerState with AutomaticKeepAli mainAxisSize: MainAxisSize.min, children: [ _buildQualityOption(context, ref, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme), - _buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 192kHz', current, colorScheme), + _buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 96kHz', current, colorScheme), + _buildQualityOption(context, ref, 'HI_RES_LOSSLESS', 'Hi-Res FLAC Max', '24-bit / up to 192kHz', current, colorScheme), ], ), ), diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart new file mode 100644 index 0000000..ee0e7e1 --- /dev/null +++ b/lib/screens/track_metadata_screen.dart @@ -0,0 +1,793 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:open_filex/open_filex.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:spotiflac_android/providers/download_queue_provider.dart'; + +/// Screen to display detailed metadata for a downloaded track +/// Designed with Material Expressive 3 style +class TrackMetadataScreen extends ConsumerWidget { + final DownloadHistoryItem item; + + const TrackMetadataScreen({super.key, required this.item}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + final fileExists = File(item.filePath).existsSync(); + + // Get file info + int? fileSize; + if (fileExists) { + try { + fileSize = File(item.filePath).lengthSync(); + } catch (_) {} + } + + return Scaffold( + body: CustomScrollView( + slivers: [ + // App Bar with cover art background + SliverAppBar( + expandedHeight: 280, + pinned: true, + stretch: true, + backgroundColor: colorScheme.surface, + flexibleSpace: FlexibleSpaceBar( + background: _buildHeaderBackground(context, colorScheme), + stretchModes: const [ + StretchMode.zoomBackground, + StretchMode.blurBackground, + ], + ), + leading: IconButton( + icon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: colorScheme.surface.withValues(alpha: 0.8), + shape: BoxShape.circle, + ), + child: Icon(Icons.arrow_back, color: colorScheme.onSurface), + ), + onPressed: () => Navigator.pop(context), + ), + actions: [ + IconButton( + icon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: colorScheme.surface.withValues(alpha: 0.8), + shape: BoxShape.circle, + ), + child: Icon(Icons.more_vert, color: colorScheme.onSurface), + ), + onPressed: () => _showOptionsMenu(context, ref, colorScheme), + ), + ], + ), + + // Content + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Track info card + _buildTrackInfoCard(context, colorScheme, fileExists), + + const SizedBox(height: 16), + + // Metadata card + _buildMetadataCard(context, colorScheme, fileSize), + + const SizedBox(height: 16), + + // File info card + _buildFileInfoCard(context, colorScheme, fileExists, fileSize), + + const SizedBox(height: 24), + + // Action buttons + _buildActionButtons(context, ref, colorScheme, fileExists), + + const SizedBox(height: 32), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildHeaderBackground(BuildContext context, ColorScheme colorScheme) { + return Stack( + fit: StackFit.expand, + children: [ + // Blurred background + if (item.coverUrl != null) + CachedNetworkImage( + imageUrl: item.coverUrl!, + fit: BoxFit.cover, + color: Colors.black.withValues(alpha: 0.5), + colorBlendMode: BlendMode.darken, + ), + + // Gradient overlay + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + colorScheme.surface.withValues(alpha: 0.8), + colorScheme.surface, + ], + stops: const [0.0, 0.7, 1.0], + ), + ), + ), + + // Cover art centered + Center( + child: Padding( + padding: const EdgeInsets.only(top: 60), + child: Hero( + tag: 'cover_${item.id}', + child: Container( + width: 140, + height: 140, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: item.coverUrl != null + ? CachedNetworkImage( + imageUrl: item.coverUrl!, + fit: BoxFit.cover, + placeholder: (_, __) => Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + size: 48, + color: colorScheme.onSurfaceVariant, + ), + ), + ) + : Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + size: 48, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ), + ), + ), + ], + ); + } + + Widget _buildTrackInfoCard(BuildContext context, ColorScheme colorScheme, bool fileExists) { + return 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: [ + // Track name + Text( + item.trackName, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + const SizedBox(height: 4), + + // Artist name + Text( + item.artistName, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: colorScheme.primary, + ), + ), + const SizedBox(height: 8), + + // Album name + Row( + children: [ + Icon( + Icons.album, + size: 16, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + item.albumName, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + + // File status + if (!fileExists) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.warning_rounded, + size: 16, + color: colorScheme.onErrorContainer, + ), + const SizedBox(width: 6), + Text( + 'File not found', + style: TextStyle( + color: colorScheme.onErrorContainer, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ], + ], + ), + ), + ); + } + + Widget _buildMetadataCard(BuildContext context, ColorScheme colorScheme, int? fileSize) { + return 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: [ + Icon( + Icons.info_outline, + size: 20, + color: colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + 'Metadata', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + ], + ), + const SizedBox(height: 16), + + // Metadata grid + _buildMetadataGrid(context, colorScheme), + + // Spotify link button + if (item.spotifyId != null && item.spotifyId!.isNotEmpty) ...[ + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: () => _openSpotifyUrl(context), + icon: const Icon(Icons.open_in_new, size: 18), + label: const Text('Open in Spotify'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ], + ], + ), + ), + ); + } + + Future _openSpotifyUrl(BuildContext context) async { + if (item.spotifyId == null) return; + + final url = 'https://open.spotify.com/track/${item.spotifyId}'; + try { + // Try to open in Spotify app first, fallback to browser + final uri = Uri.parse('spotify:track:${item.spotifyId}'); + // ignore: deprecated_member_use + if (await canLaunchUrl(uri)) { + await launchUrl(uri); + } else { + await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); + } + } catch (e) { + if (context.mounted) { + _copyToClipboard(context, url); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Spotify URL copied to clipboard')), + ); + } + } + } + + Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) { + final items = <_MetadataItem>[ + _MetadataItem('Track name', item.trackName), + _MetadataItem('Artist', item.artistName), + if (item.albumArtist != null && item.albumArtist != item.artistName) + _MetadataItem('Album artist', item.albumArtist!), + _MetadataItem('Album', item.albumName), + if (item.trackNumber != null) + _MetadataItem('Track number', item.trackNumber.toString()), + if (item.discNumber != null && item.discNumber! > 1) + _MetadataItem('Disc number', item.discNumber.toString()), + if (item.duration != null) + _MetadataItem('Duration', _formatDuration(item.duration!)), + if (item.releaseDate != null && item.releaseDate!.isNotEmpty) + _MetadataItem('Release date', item.releaseDate!), + if (item.isrc != null && item.isrc!.isNotEmpty) + _MetadataItem('ISRC', item.isrc!), + if (item.spotifyId != null && item.spotifyId!.isNotEmpty) + _MetadataItem('Spotify ID', item.spotifyId!), + if (item.quality != null && item.quality!.isNotEmpty) + _MetadataItem('Quality', _formatQuality(item.quality!)), + _MetadataItem('Service', item.service.toUpperCase()), + _MetadataItem('Downloaded', _formatFullDate(item.downloadedAt)), + ]; + + return Column( + children: items.map((metadata) { + final isCopyable = metadata.label == 'ISRC' || + metadata.label == 'Spotify ID'; + return InkWell( + onTap: isCopyable ? () => _copyToClipboard(context, metadata.value) : null, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 100, + child: Text( + metadata.label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + metadata.value, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface, + ), + ), + ), + if (isCopyable) + Icon( + Icons.copy, + size: 14, + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + ), + ], + ), + ), + ); + }).toList(), + ); + } + + String _formatDuration(int seconds) { + final minutes = seconds ~/ 60; + final secs = seconds % 60; + return '$minutes:${secs.toString().padLeft(2, '0')}'; + } + + String _formatQuality(String quality) { + switch (quality) { + case 'LOSSLESS': + return 'Lossless (16-bit)'; + case 'HI_RES': + return 'Hi-Res (24-bit)'; + case 'HI_RES_LOSSLESS': + return 'Hi-Res Lossless (24-bit)'; + default: + return quality; + } + } + + String _formatQualityShort(String quality) { + switch (quality) { + case 'LOSSLESS': + return '16-bit'; + case 'HI_RES': + return '24-bit'; + case 'HI_RES_LOSSLESS': + return 'Hi-Res'; + default: + return quality; + } + } + + Widget _buildFileInfoCard(BuildContext context, ColorScheme colorScheme, bool fileExists, int? fileSize) { + final fileName = item.filePath.split(Platform.pathSeparator).last; + final fileExtension = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : 'Unknown'; + + return 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: [ + Icon( + Icons.folder_outlined, + size: 20, + color: colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + 'File Info', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + ], + ), + const SizedBox(height: 16), + + // Format chip + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + fileExtension, + style: TextStyle( + color: colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ), + if (fileSize != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + _formatFileSize(fileSize), + style: TextStyle( + color: colorScheme.onSecondaryContainer, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ), + if (item.quality != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + _formatQualityShort(item.quality!), + style: TextStyle( + color: colorScheme.onTertiaryContainer, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: _getServiceColor(item.service, colorScheme), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _getServiceIcon(item.service), + size: 14, + color: Colors.white, + ), + const SizedBox(width: 4), + Text( + item.service.toUpperCase(), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ], + ), + ), + ], + ), + + const SizedBox(height: 16), + + // File path + InkWell( + onTap: () => _copyToClipboard(context, item.filePath), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Expanded( + child: Text( + item.filePath, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + color: colorScheme.onSurfaceVariant, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + Icon( + Icons.copy, + size: 18, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildActionButtons(BuildContext context, WidgetRef ref, ColorScheme colorScheme, bool fileExists) { + return Row( + children: [ + // Play button + Expanded( + flex: 2, + child: FilledButton.icon( + onPressed: fileExists ? () => _openFile(context, item.filePath) : null, + icon: const Icon(Icons.play_arrow), + label: const Text('Play'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ), + const SizedBox(width: 12), + + // Delete button + Expanded( + child: OutlinedButton.icon( + onPressed: () => _confirmDelete(context, ref, colorScheme), + icon: Icon(Icons.delete_outline, color: colorScheme.error), + label: Text('Delete', style: TextStyle(color: colorScheme.error)), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + side: BorderSide(color: colorScheme.error.withValues(alpha: 0.5)), + ), + ), + ), + ], + ); + } + + void _showOptionsMenu(BuildContext context, WidgetRef ref, ColorScheme colorScheme) { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 16), + ListTile( + leading: const Icon(Icons.copy), + title: const Text('Copy file path'), + onTap: () { + Navigator.pop(context); + _copyToClipboard(context, item.filePath); + }, + ), + ListTile( + leading: const Icon(Icons.share), + title: const Text('Share'), + onTap: () { + Navigator.pop(context); + // TODO: Implement share + }, + ), + ListTile( + leading: Icon(Icons.delete, color: colorScheme.error), + title: Text('Remove from history', style: TextStyle(color: colorScheme.error)), + onTap: () { + Navigator.pop(context); + _confirmDelete(context, ref, colorScheme); + }, + ), + const SizedBox(height: 16), + ], + ), + ), + ); + } + + void _confirmDelete(BuildContext context, WidgetRef ref, ColorScheme colorScheme) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Remove from history?'), + content: const Text( + 'This will remove the track from your download history. ' + 'The downloaded file will not be deleted.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id); + Navigator.pop(context); // Close dialog + Navigator.pop(context); // Go back to history + }, + child: Text('Remove', style: TextStyle(color: colorScheme.error)), + ), + ], + ), + ); + } + + Future _openFile(BuildContext context, String filePath) async { + try { + final result = await OpenFilex.open(filePath); + if (result.type != ResultType.done && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Cannot open: ${result.message}')), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Cannot open file: $e')), + ); + } + } + } + + void _copyToClipboard(BuildContext context, String text) { + Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Copied to clipboard'), + duration: Duration(seconds: 2), + ), + ); + } + + String _formatFullDate(DateTime date) { + final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + return '${date.day} ${months[date.month - 1]} ${date.year}, ' + '${date.hour.toString().padLeft(2, '0')}:' + '${date.minute.toString().padLeft(2, '0')}'; + } + + String _formatFileSize(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB'; + } + + IconData _getServiceIcon(String service) { + switch (service.toLowerCase()) { + case 'tidal': + return Icons.waves; + case 'qobuz': + return Icons.album; + case 'amazon': + return Icons.shopping_cart; + default: + return Icons.cloud_download; + } + } + + Color _getServiceColor(String service, ColorScheme colorScheme) { + switch (service.toLowerCase()) { + case 'tidal': + return const Color(0xFF0077B5); // Tidal blue (darker, more readable) + case 'qobuz': + return const Color(0xFF0052CC); // Qobuz blue + case 'amazon': + return const Color(0xFFFF9900); // Amazon orange + default: + return colorScheme.primary; + } + } +} + +class _MetadataItem { + final String label; + final String value; + + _MetadataItem(this.label, this.value); +} diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 5fdaa09..c3fe92e 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -47,6 +47,7 @@ class PlatformBridge { String? coverUrl, required String outputDir, required String filenameFormat, + String quality = 'LOSSLESS', bool embedLyrics = true, bool embedMaxQualityCover = true, int trackNumber = 1, @@ -65,6 +66,7 @@ class PlatformBridge { 'cover_url': coverUrl, 'output_dir': outputDir, 'filename_format': filenameFormat, + 'quality': quality, 'embed_lyrics': embedLyrics, 'embed_max_quality_cover': embedMaxQualityCover, 'track_number': trackNumber, @@ -88,6 +90,7 @@ class PlatformBridge { String? coverUrl, required String outputDir, required String filenameFormat, + String quality = 'LOSSLESS', bool embedLyrics = true, bool embedMaxQualityCover = true, int trackNumber = 1, @@ -107,6 +110,7 @@ class PlatformBridge { 'cover_url': coverUrl, 'output_dir': outputDir, 'filename_format': filenameFormat, + 'quality': quality, 'embed_lyrics': embedLyrics, 'embed_max_quality_cover': embedMaxQualityCover, 'track_number': trackNumber, diff --git a/lib/services/update_checker.dart b/lib/services/update_checker.dart new file mode 100644 index 0000000..6d85e97 --- /dev/null +++ b/lib/services/update_checker.dart @@ -0,0 +1,88 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:spotiflac_android/constants/app_info.dart'; + +class UpdateInfo { + final String version; + final String changelog; + final String downloadUrl; + final DateTime publishedAt; + + const UpdateInfo({ + required this.version, + required this.changelog, + required this.downloadUrl, + required this.publishedAt, + }); +} + +class UpdateChecker { + static const String _apiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest'; + + /// Check for updates from GitHub releases + static Future checkForUpdate() async { + try { + final response = await http.get( + Uri.parse(_apiUrl), + headers: {'Accept': 'application/vnd.github.v3+json'}, + ).timeout(const Duration(seconds: 10)); + + if (response.statusCode != 200) { + print('[UpdateChecker] GitHub API returned ${response.statusCode}'); + return null; + } + + final data = jsonDecode(response.body) as Map; + final tagName = data['tag_name'] as String? ?? ''; + final latestVersion = tagName.replaceFirst('v', ''); + + if (!_isNewerVersion(latestVersion, AppInfo.version)) { + print('[UpdateChecker] No update available (current: ${AppInfo.version}, latest: $latestVersion)'); + return null; + } + + // Get changelog from release body + final body = data['body'] as String? ?? 'No changelog available'; + final htmlUrl = data['html_url'] as String? ?? '${AppInfo.githubUrl}/releases'; + final publishedAt = DateTime.tryParse(data['published_at'] as String? ?? '') ?? DateTime.now(); + + print('[UpdateChecker] Update available: $latestVersion'); + + return UpdateInfo( + version: latestVersion, + changelog: body, + downloadUrl: htmlUrl, + publishedAt: publishedAt, + ); + } catch (e) { + print('[UpdateChecker] Error checking for updates: $e'); + return null; + } + } + + /// Compare version strings (e.g., "1.1.1" vs "1.1.0") + static bool _isNewerVersion(String latest, String current) { + try { + final latestParts = latest.split('.').map(int.parse).toList(); + final currentParts = current.split('.').map(int.parse).toList(); + + // Pad with zeros if needed + while (latestParts.length < 3) { + latestParts.add(0); + } + while (currentParts.length < 3) { + currentParts.add(0); + } + + for (int i = 0; i < 3; i++) { + if (latestParts[i] > currentParts[i]) return true; + if (latestParts[i] < currentParts[i]) return false; + } + return false; // Same version + } catch (e) { + return false; + } + } + + static String get currentVersion => AppInfo.version; +} diff --git a/lib/widgets/update_dialog.dart b/lib/widgets/update_dialog.dart new file mode 100644 index 0000000..561c372 --- /dev/null +++ b/lib/widgets/update_dialog.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:spotiflac_android/constants/app_info.dart'; +import 'package:spotiflac_android/services/update_checker.dart'; + +class UpdateDialog extends StatelessWidget { + final UpdateInfo updateInfo; + final VoidCallback onDismiss; + final VoidCallback onDisableUpdates; + + const UpdateDialog({ + super.key, + required this.updateInfo, + required this.onDismiss, + required this.onDisableUpdates, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return AlertDialog( + title: Row( + children: [ + Icon(Icons.system_update, color: colorScheme.primary), + const SizedBox(width: 12), + const Text('Update Available'), + ], + ), + content: SizedBox( + width: double.maxFinite, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Version info + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Text( + 'v${AppInfo.version}', + style: TextStyle(color: colorScheme.onPrimaryContainer), + ), + const SizedBox(width: 8), + Icon(Icons.arrow_forward, size: 16, color: colorScheme.onPrimaryContainer), + const SizedBox(width: 8), + Text( + 'v${updateInfo.version}', + style: TextStyle( + color: colorScheme.onPrimaryContainer, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // Changelog header + Text( + 'What\'s New:', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + + // Changelog content (scrollable) + Flexible( + child: Container( + constraints: const BoxConstraints(maxHeight: 200), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: SingleChildScrollView( + padding: const EdgeInsets.all(12), + child: Text( + _formatChangelog(updateInfo.changelog), + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ), + ), + ], + ), + ), + actions: [ + // Don't remind again button + TextButton( + onPressed: () { + onDisableUpdates(); + Navigator.pop(context); + }, + child: Text( + 'Don\'t remind', + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + ), + // Later button + TextButton( + onPressed: () { + onDismiss(); + Navigator.pop(context); + }, + child: const Text('Later'), + ), + // Download button + FilledButton( + onPressed: () async { + final uri = Uri.parse(updateInfo.downloadUrl); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + if (context.mounted) { + Navigator.pop(context); + } + }, + child: const Text('Download'), + ), + ], + ); + } + + /// Format changelog - clean up markdown + String _formatChangelog(String changelog) { + // Remove markdown headers but keep content + var formatted = changelog + .replaceAll(RegExp(r'^#{1,6}\s*', multiLine: true), '') + .replaceAll(RegExp(r'\*\*([^*]+)\*\*'), r'$1') // Remove bold + .replaceAll(RegExp(r'`([^`]+)`'), r'$1') // Remove code + .trim(); + + // Limit length + if (formatted.length > 1000) { + formatted = '${formatted.substring(0, 1000)}...'; + } + + return formatted; + } +} + +/// Show update dialog +Future showUpdateDialog( + BuildContext context, { + required UpdateInfo updateInfo, + required VoidCallback onDisableUpdates, +}) async { + return showDialog( + context: context, + builder: (context) => UpdateDialog( + updateInfo: updateInfo, + onDismiss: () {}, + onDisableUpdates: onDisableUpdates, + ), + ); +} diff --git a/pubspec.yaml b/pubspec.yaml index 5bdab73..21e5e03 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: 1.1.1+8 +version: 1.2.0+10 environment: sdk: ^3.10.0 diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index d722c58..0000000 --- a/test/widget_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:spotiflac_android/main.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -}