From ad606cca539315c0ffb6c45be7c04f46c04a8cbf Mon Sep 17 00:00:00 2001 From: zarzet Date: Sat, 7 Feb 2026 11:48:37 +0700 Subject: [PATCH] feat: v3.5.0 - SAF storage, onboarding redesign, library scan fixes - SAF Storage Access Framework for Android 10+ downloads - Redesigned Setup/Tutorial screens with Material 3 Expressive - Library scan hero card now shows real-time scanned count - Library folder picker uses SAF (no MANAGE_EXTERNAL_STORAGE needed) - SAF migration prompt for users updating from pre-SAF versions - Home feed caching, donate page, per-app language support - Merged 3.6.0-beta.1 changelog entries into 3.5.0 --- CHANGELOG.md | 27 +- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 20 + go_backend/qobuz.go | 26 + lib/app.dart | 18 +- lib/constants/app_info.dart | 5 +- lib/l10n/app_localizations.dart | 300 ++++ lib/l10n/app_localizations_de.dart | 186 +++ lib/l10n/app_localizations_en.dart | 186 +++ lib/l10n/app_localizations_es.dart | 186 +++ lib/l10n/app_localizations_fr.dart | 186 +++ lib/l10n/app_localizations_hi.dart | 186 +++ lib/l10n/app_localizations_id.dart | 186 +++ lib/l10n/app_localizations_ja.dart | 186 +++ lib/l10n/app_localizations_ko.dart | 186 +++ lib/l10n/app_localizations_nl.dart | 186 +++ lib/l10n/app_localizations_pt.dart | 186 +++ lib/l10n/app_localizations_ru.dart | 186 +++ lib/l10n/app_localizations_tr.dart | 186 +++ lib/l10n/app_localizations_zh.dart | 186 +++ lib/l10n/arb/app_en.arb | 136 +- lib/l10n/arb/app_id.arb | 135 ++ lib/models/settings.dart | 9 + lib/models/settings.g.dart | 2 + lib/providers/local_library_provider.dart | 6 + lib/providers/settings_provider.dart | 5 + lib/screens/home_tab.dart | 16 + lib/screens/main_shell.dart | 87 ++ lib/screens/settings/about_page.dart | 17 - lib/screens/settings/donate_page.dart | 382 ++++++ .../settings/library_settings_page.dart | 94 +- lib/screens/settings/settings_tab.dart | 7 + lib/screens/setup_screen.dart | 1220 ++++++++--------- lib/screens/track_metadata_screen.dart | 15 +- lib/screens/tutorial_screen.dart | 712 ++++++++++ lib/services/platform_bridge.dart | 8 + pubspec.yaml | 2 +- 36 files changed, 4948 insertions(+), 719 deletions(-) create mode 100644 lib/screens/settings/donate_page.dart create mode 100644 lib/screens/tutorial_screen.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index fb5deb2d..2875edd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,14 @@ # Changelog -## [3.5.0-beta.1] - 2026-02-06 +## [3.5.0] - 2026-02-07 ### Highlights + - **SAF Storage (Android 10+)**: Proper Storage Access Framework support for download destination (content URIs) - Select download folder via SAF tree picker - Downloads now write to SAF file descriptors (`/proc/self/fd/*`) instead of raw filesystem paths - Works around Android 10+ scoped storage permission errors +- **Modern Onboarding Experience**: Completely redesigned Setup and Tutorial screens ### Added @@ -19,6 +21,11 @@ - Scan cancelled banner + retry action for library scans - Android DocumentFile dependency for SAF operations - Post-processing API v2 (SAF-aware, ready to replace v1) +- Donate page in Settings with Ko-fi and Buy Me a Coffee links +- Per-App Language support on Android 13+ (locale_config.xml) +- Interactive tutorial with working search bar simulation and clickable download buttons +- Visual feedback animations for page transitions, entrance effects, and feature lists +- New dedicated welcome step in setup wizard with improved branding ### Changed @@ -31,6 +38,13 @@ - SAF tree picker flow in `MainActivity` migrated to Activity Result API (`registerForActivityResult`) - `MainActivity` host migrated to `FlutterFragmentActivity` for SAF picker compatibility - Legacy `startActivityForResult` / `onActivityResult` SAF picker path removed +- Setup screen UI polish: smaller logo, thin outline borders on text fields +- Removed support section from About page (moved to Donate page) +- Qobuz squid.wtf region fallback for blocked regions +- Setup screen converted to PageView flow with animated progress bar and modern card layouts +- Tutorial screen aligned with Setup Screen design, updated typography and softened UI shapes +- Larger, more accessible navigation buttons for onboarding flow +- Reduced visual noise by removing unnecessary glow effects ### Fixed @@ -42,6 +56,11 @@ - Restored old-device renderer fallback while using `FlutterFragmentActivity` by injecting shell args from a custom `FlutterFragment` (`--enable-impeller=false` on problematic devices) - Preserved Flutter fragment creation behavior (cached engine, engine group, new engine) while adding Impeller fallback support - SAF tree picker result now consistently returns `tree_uri` payload with persisted URI permission handling +- SAF share file now copies to temp before sharing (fixes share from SAF content URI) +- Home feed not updating after installing extension with homeFeed capability (no longer requires app restart) +- Library scan hero card showing 0 tracks during scan (now shows scanned file count in real-time) +- Library folder picker no longer requires MANAGE_EXTERNAL_STORAGE on Android 10+ (uses SAF tree picker) +- One-time SAF migration prompt for users updating from pre-SAF versions --- @@ -150,7 +169,7 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg. - **Lossy Bitrate Options**: MP3 (320/256/192/128kbps), Opus (128/96/64kbps) - **Search Filters**: Filter results by type (Tracks, Artists, Albums, Playlists) - **Album/Playlist Search**: Deezer search now includes albums and playlists -- **New Languages**: Turkish (Kaan, BedirhanGltkn), Japanese (Re*Index.(ot_inc)) +- **New Languages**: Turkish (Kaan, BedirhanGltkn), Japanese (Re\*Index.(ot_inc)) - **Optional All Files Access**: Android 13+ no longer requires full storage access; enable in Settings if needed - **Improved VPN Compatibility**: Better HTTP/2 support for users behind VPN or restricted networks @@ -270,7 +289,7 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg. - Added `genre`, `label`, `copyright` fields to `DownloadHistoryItem` model - Metadata is stored in download history and persists across app restarts - New localization strings: `trackGenre`, `trackLabel`, `trackCopyright` -- `**utils.randomUserAgent()` for Extensions**: New utility function for extensions to get random browser User-Agent strings +- `**utils.randomUserAgent()` for Extensions\*\*: New utility function for extensions to get random browser User-Agent strings - Returns modern Chrome User-Agent format: `Chrome/{120-145}.0.{6000-7499}.{100-299}` with `Windows NT 10.0` - Useful for extensions that need to rotate User-Agents to avoid detection @@ -567,4 +586,4 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int --- -*For older versions, see [GitHub Releases*](https://github.com/zarzet/SpotiFLAC-Mobile/releases) +_For older versions, see [GitHub Releases_](https://github.com/zarzet/SpotiFLAC-Mobile/releases) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index c5298387..8c8c4ca3 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -1039,6 +1039,26 @@ class MainActivity: FlutterFragmentActivity() { result.error("open_failed", e.message, null) } } + "shareContentUri" -> { + val uriStr = call.argument("uri") ?: "" + val title = call.argument("title") ?: "" + try { + val uri = Uri.parse(uriStr) + val type = contentResolver.getType(uri) ?: "audio/*" + val shareIntent = Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_STREAM, uri) + setType(type) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + if (title.isNotBlank()) { + putExtra(Intent.EXTRA_SUBJECT, title) + } + } + startActivity(Intent.createChooser(shareIntent, title.ifBlank { "Share" })) + result.success(true) + } catch (e: Exception) { + result.error("share_failed", e.message, null) + } + } "fetchLyrics" -> { val spotifyId = call.argument("spotify_id") ?: "" val trackName = call.argument("track_name") ?: "" diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index d8bece89..140e8ffd 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -778,8 +778,31 @@ func getQobuzAPITimeout() time.Duration { return qobuzAPITimeoutMobile } +// qobuzSquidCountries defines the region fallback order for squid.wtf API +var qobuzSquidCountries = []string{"US", "FR"} + // fetchQobuzURLWithRetry fetches download URL from a single Qobuz API with retry logic +// For squid.wtf APIs, it tries US region first, then falls back to FR func fetchQobuzURLWithRetry(api string, trackID int64, quality string, timeout time.Duration) (string, error) { + isSquid := strings.Contains(api, "squid.wtf") + + if isSquid { + for _, country := range qobuzSquidCountries { + GoLog("[Qobuz] Trying squid.wtf with country=%s\n", country) + result, err := fetchQobuzURLSingleAttempt(api, trackID, quality, timeout, country) + if err == nil { + return result, nil + } + GoLog("[Qobuz] squid.wtf country=%s failed: %v\n", country, err) + } + return "", fmt.Errorf("squid.wtf failed for all regions (US, FR)") + } + + return fetchQobuzURLSingleAttempt(api, trackID, quality, timeout, "") +} + +// fetchQobuzURLSingleAttempt fetches download URL with retry logic for a single API+country combination +func fetchQobuzURLSingleAttempt(api string, trackID int64, quality string, timeout time.Duration, country string) (string, error) { var lastErr error retryDelay := qobuzRetryDelay @@ -792,6 +815,9 @@ func fetchQobuzURLWithRetry(api string, trackID int64, quality string, timeout t client := NewHTTPClientWithTimeout(timeout) reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality) + if country != "" { + reqURL += "&country=" + country + } req, err := http.NewRequest("GET", reqURL, nil) if err != nil { diff --git a/lib/app.dart b/lib/app.dart index 9d6c7b8b..013d59ec 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -4,15 +4,27 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:go_router/go_router.dart'; import 'package:spotiflac_android/screens/main_shell.dart'; import 'package:spotiflac_android/screens/setup_screen.dart'; +import 'package:spotiflac_android/screens/tutorial_screen.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart'; import 'package:spotiflac_android/l10n/app_localizations.dart'; final _routerProvider = Provider((ref) { final isFirstLaunch = ref.watch(settingsProvider.select((s) => s.isFirstLaunch)); + final hasCompletedTutorial = ref.watch(settingsProvider.select((s) => s.hasCompletedTutorial)); + + // Determine initial location based on app state + String initialLocation; + if (isFirstLaunch) { + initialLocation = '/setup'; + } else if (!hasCompletedTutorial) { + initialLocation = '/tutorial'; + } else { + initialLocation = '/'; + } return GoRouter( - initialLocation: isFirstLaunch ? '/setup' : '/', + initialLocation: initialLocation, routes: [ GoRoute( path: '/', @@ -22,6 +34,10 @@ final _routerProvider = Provider((ref) { path: '/setup', builder: (context, state) => const SetupScreen(), ), + GoRoute( + path: '/tutorial', + builder: (context, state) => const TutorialScreen(), + ), ], ); }); diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 3e523f37..1ed07b06 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,8 +1,8 @@ /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '3.5.0-beta.1'; - static const String buildNumber = '73'; + static const String version = '3.5.0'; + static const String buildNumber = '74'; static const String fullVersion = '$version+$buildNumber'; @@ -17,4 +17,5 @@ class AppInfo { static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC'; static const String kofiUrl = 'https://ko-fi.com/zarzet'; + static const String bmacUrl = 'https://buymeacoffee.com/zarzet'; } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 8b48af34..6481c841 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4419,6 +4419,306 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'{count, plural, =1{1 hour ago} other{{count} hours ago}}'** String timeHoursAgo(int count); + + /// Dialog title when switching storage mode + /// + /// In en, this message translates to: + /// **'Switch Storage Mode'** + String get storageSwitchTitle; + + /// Dialog title when switching to SAF + /// + /// In en, this message translates to: + /// **'Switch to SAF Storage?'** + String get storageSwitchToSafTitle; + + /// Dialog title when switching to app storage + /// + /// In en, this message translates to: + /// **'Switch to App Storage?'** + String get storageSwitchToAppTitle; + + /// Explanation when switching to SAF + /// + /// In en, this message translates to: + /// **'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'** + String get storageSwitchToSafMessage; + + /// Explanation when switching to app storage + /// + /// In en, this message translates to: + /// **'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'** + String get storageSwitchToAppMessage; + + /// Section header for existing downloads info + /// + /// In en, this message translates to: + /// **'Existing Downloads'** + String get storageSwitchExistingDownloads; + + /// Info about existing downloads count + /// + /// In en, this message translates to: + /// **'{count} tracks in {mode} storage'** + String storageSwitchExistingDownloadsInfo(int count, String mode); + + /// Section header for new downloads info + /// + /// In en, this message translates to: + /// **'New Downloads'** + String get storageSwitchNewDownloads; + + /// Shows where new downloads will go + /// + /// In en, this message translates to: + /// **'Will be saved to: {location}'** + String storageSwitchNewDownloadsLocation(String location); + + /// Button to proceed with storage switch + /// + /// In en, this message translates to: + /// **'Continue'** + String get storageSwitchContinue; + + /// Button to select SAF folder + /// + /// In en, this message translates to: + /// **'Select SAF Folder'** + String get storageSwitchSelectFolder; + + /// Label for app storage mode + /// + /// In en, this message translates to: + /// **'App Storage'** + String get storageAppStorage; + + /// Label for SAF storage mode + /// + /// In en, this message translates to: + /// **'SAF Storage'** + String get storageSafStorage; + + /// Badge showing storage mode for a track + /// + /// In en, this message translates to: + /// **'Storage: {mode}'** + String storageModeBadge(String mode); + + /// Section title for storage stats + /// + /// In en, this message translates to: + /// **'Storage Statistics'** + String get storageStatsTitle; + + /// Count of tracks in app storage + /// + /// In en, this message translates to: + /// **'{count} tracks in App Storage'** + String storageStatsAppCount(int count); + + /// Count of tracks in SAF storage + /// + /// In en, this message translates to: + /// **'{count} tracks in SAF Storage'** + String storageStatsSafCount(int count); + + /// Info when user has files in both storage modes + /// + /// In en, this message translates to: + /// **'Your files are stored in multiple locations'** + String get storageModeInfo; + + /// Tutorial welcome page title + /// + /// In en, this message translates to: + /// **'Welcome to SpotiFLAC!'** + String get tutorialWelcomeTitle; + + /// Tutorial welcome page description + /// + /// In en, this message translates to: + /// **'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.'** + String get tutorialWelcomeDesc; + + /// Tutorial welcome tip 1 + /// + /// In en, this message translates to: + /// **'Download music from Spotify, Deezer, or paste any supported URL'** + String get tutorialWelcomeTip1; + + /// Tutorial welcome tip 2 + /// + /// In en, this message translates to: + /// **'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'** + String get tutorialWelcomeTip2; + + /// Tutorial welcome tip 3 + /// + /// In en, this message translates to: + /// **'Automatic metadata, cover art, and lyrics embedding'** + String get tutorialWelcomeTip3; + + /// Tutorial search page title + /// + /// In en, this message translates to: + /// **'Finding Music'** + String get tutorialSearchTitle; + + /// Tutorial search page description + /// + /// In en, this message translates to: + /// **'There are two easy ways to find music you want to download.'** + String get tutorialSearchDesc; + + /// Tutorial search tip 1 + /// + /// In en, this message translates to: + /// **'Paste a Spotify or Deezer URL directly in the search box'** + String get tutorialSearchTip1; + + /// Tutorial search tip 2 + /// + /// In en, this message translates to: + /// **'Or type the song name, artist, or album to search'** + String get tutorialSearchTip2; + + /// Tutorial search tip 3 + /// + /// In en, this message translates to: + /// **'Supports tracks, albums, playlists, and artist pages'** + String get tutorialSearchTip3; + + /// Tutorial download page title + /// + /// In en, this message translates to: + /// **'Downloading Music'** + String get tutorialDownloadTitle; + + /// Tutorial download page description + /// + /// In en, this message translates to: + /// **'Downloading music is simple and fast. Here\'s how it works.'** + String get tutorialDownloadDesc; + + /// Tutorial download tip 1 + /// + /// In en, this message translates to: + /// **'Tap the download button next to any track to start downloading'** + String get tutorialDownloadTip1; + + /// Tutorial download tip 2 + /// + /// In en, this message translates to: + /// **'Choose your preferred quality (FLAC, Hi-Res, or MP3)'** + String get tutorialDownloadTip2; + + /// Tutorial download tip 3 + /// + /// In en, this message translates to: + /// **'Download entire albums or playlists with one tap'** + String get tutorialDownloadTip3; + + /// Tutorial library page title + /// + /// In en, this message translates to: + /// **'Your Library'** + String get tutorialLibraryTitle; + + /// Tutorial library page description + /// + /// In en, this message translates to: + /// **'All your downloaded music is organized in the Library tab.'** + String get tutorialLibraryDesc; + + /// Tutorial library tip 1 + /// + /// In en, this message translates to: + /// **'View download progress and queue in the Library tab'** + String get tutorialLibraryTip1; + + /// Tutorial library tip 2 + /// + /// In en, this message translates to: + /// **'Tap any track to play it with your music player'** + String get tutorialLibraryTip2; + + /// Tutorial library tip 3 + /// + /// In en, this message translates to: + /// **'Switch between list and grid view for better browsing'** + String get tutorialLibraryTip3; + + /// Tutorial extensions page title + /// + /// In en, this message translates to: + /// **'Extensions'** + String get tutorialExtensionsTitle; + + /// Tutorial extensions page description + /// + /// In en, this message translates to: + /// **'Extend the app\'s capabilities with community extensions.'** + String get tutorialExtensionsDesc; + + /// Tutorial extensions tip 1 + /// + /// In en, this message translates to: + /// **'Browse the Store tab to discover useful extensions'** + String get tutorialExtensionsTip1; + + /// Tutorial extensions tip 2 + /// + /// In en, this message translates to: + /// **'Add new download providers or search sources'** + String get tutorialExtensionsTip2; + + /// Tutorial extensions tip 3 + /// + /// In en, this message translates to: + /// **'Get lyrics, enhanced metadata, and more features'** + String get tutorialExtensionsTip3; + + /// Tutorial settings page title + /// + /// In en, this message translates to: + /// **'Customize Your Experience'** + String get tutorialSettingsTitle; + + /// Tutorial settings page description + /// + /// In en, this message translates to: + /// **'Personalize the app in Settings to match your preferences.'** + String get tutorialSettingsDesc; + + /// Tutorial settings tip 1 + /// + /// In en, this message translates to: + /// **'Change download location and folder organization'** + String get tutorialSettingsTip1; + + /// Tutorial settings tip 2 + /// + /// In en, this message translates to: + /// **'Set default audio quality and format preferences'** + String get tutorialSettingsTip2; + + /// Tutorial settings tip 3 + /// + /// In en, this message translates to: + /// **'Customize app theme and appearance'** + String get tutorialSettingsTip3; + + /// Tutorial completion message + /// + /// In en, this message translates to: + /// **'You\'re all set! Start downloading your favorite music now.'** + String get tutorialReadyMessage; + + /// Example label in tutorial + /// + /// In en, this message translates to: + /// **'EXAMPLE'** + String get tutorialExample; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 06e5a87d..71de618a 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2471,4 +2471,190 @@ class AppLocalizationsDe extends AppLocalizations { ); return '$_temp0'; } + + @override + String get storageSwitchTitle => 'Switch Storage Mode'; + + @override + String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; + + @override + String get storageSwitchToAppTitle => 'Switch to App Storage?'; + + @override + String get storageSwitchToSafMessage => + 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; + + @override + String get storageSwitchToAppMessage => + 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; + + @override + String get storageSwitchExistingDownloads => 'Existing Downloads'; + + @override + String storageSwitchExistingDownloadsInfo(int count, String mode) { + return '$count tracks in $mode storage'; + } + + @override + String get storageSwitchNewDownloads => 'New Downloads'; + + @override + String storageSwitchNewDownloadsLocation(String location) { + return 'Will be saved to: $location'; + } + + @override + String get storageSwitchContinue => 'Continue'; + + @override + String get storageSwitchSelectFolder => 'Select SAF Folder'; + + @override + String get storageAppStorage => 'App Storage'; + + @override + String get storageSafStorage => 'SAF Storage'; + + @override + String storageModeBadge(String mode) { + return 'Storage: $mode'; + } + + @override + String get storageStatsTitle => 'Storage Statistics'; + + @override + String storageStatsAppCount(int count) { + return '$count tracks in App Storage'; + } + + @override + String storageStatsSafCount(int count) { + return '$count tracks in SAF Storage'; + } + + @override + String get storageModeInfo => 'Your files are stored in multiple locations'; + + @override + String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; + + @override + String get tutorialWelcomeDesc => + 'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.'; + + @override + String get tutorialWelcomeTip1 => + 'Download music from Spotify, Deezer, or paste any supported URL'; + + @override + String get tutorialWelcomeTip2 => + 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; + + @override + String get tutorialWelcomeTip3 => + 'Automatic metadata, cover art, and lyrics embedding'; + + @override + String get tutorialSearchTitle => 'Finding Music'; + + @override + String get tutorialSearchDesc => + 'There are two easy ways to find music you want to download.'; + + @override + String get tutorialSearchTip1 => + 'Paste a Spotify or Deezer URL directly in the search box'; + + @override + String get tutorialSearchTip2 => + 'Or type the song name, artist, or album to search'; + + @override + String get tutorialSearchTip3 => + 'Supports tracks, albums, playlists, and artist pages'; + + @override + String get tutorialDownloadTitle => 'Downloading Music'; + + @override + String get tutorialDownloadDesc => + 'Downloading music is simple and fast. Here\'s how it works.'; + + @override + String get tutorialDownloadTip1 => + 'Tap the download button next to any track to start downloading'; + + @override + String get tutorialDownloadTip2 => + 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; + + @override + String get tutorialDownloadTip3 => + 'Download entire albums or playlists with one tap'; + + @override + String get tutorialLibraryTitle => 'Your Library'; + + @override + String get tutorialLibraryDesc => + 'All your downloaded music is organized in the Library tab.'; + + @override + String get tutorialLibraryTip1 => + 'View download progress and queue in the Library tab'; + + @override + String get tutorialLibraryTip2 => + 'Tap any track to play it with your music player'; + + @override + String get tutorialLibraryTip3 => + 'Switch between list and grid view for better browsing'; + + @override + String get tutorialExtensionsTitle => 'Extensions'; + + @override + String get tutorialExtensionsDesc => + 'Extend the app\'s capabilities with community extensions.'; + + @override + String get tutorialExtensionsTip1 => + 'Browse the Store tab to discover useful extensions'; + + @override + String get tutorialExtensionsTip2 => + 'Add new download providers or search sources'; + + @override + String get tutorialExtensionsTip3 => + 'Get lyrics, enhanced metadata, and more features'; + + @override + String get tutorialSettingsTitle => 'Customize Your Experience'; + + @override + String get tutorialSettingsDesc => + 'Personalize the app in Settings to match your preferences.'; + + @override + String get tutorialSettingsTip1 => + 'Change download location and folder organization'; + + @override + String get tutorialSettingsTip2 => + 'Set default audio quality and format preferences'; + + @override + String get tutorialSettingsTip3 => 'Customize app theme and appearance'; + + @override + String get tutorialReadyMessage => + 'You\'re all set! Start downloading your favorite music now.'; + + @override + String get tutorialExample => 'EXAMPLE'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 8f150ce6..fb47feb6 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2456,4 +2456,190 @@ class AppLocalizationsEn extends AppLocalizations { ); return '$_temp0'; } + + @override + String get storageSwitchTitle => 'Switch Storage Mode'; + + @override + String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; + + @override + String get storageSwitchToAppTitle => 'Switch to App Storage?'; + + @override + String get storageSwitchToSafMessage => + 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; + + @override + String get storageSwitchToAppMessage => + 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; + + @override + String get storageSwitchExistingDownloads => 'Existing Downloads'; + + @override + String storageSwitchExistingDownloadsInfo(int count, String mode) { + return '$count tracks in $mode storage'; + } + + @override + String get storageSwitchNewDownloads => 'New Downloads'; + + @override + String storageSwitchNewDownloadsLocation(String location) { + return 'Will be saved to: $location'; + } + + @override + String get storageSwitchContinue => 'Continue'; + + @override + String get storageSwitchSelectFolder => 'Select SAF Folder'; + + @override + String get storageAppStorage => 'App Storage'; + + @override + String get storageSafStorage => 'SAF Storage'; + + @override + String storageModeBadge(String mode) { + return 'Storage: $mode'; + } + + @override + String get storageStatsTitle => 'Storage Statistics'; + + @override + String storageStatsAppCount(int count) { + return '$count tracks in App Storage'; + } + + @override + String storageStatsSafCount(int count) { + return '$count tracks in SAF Storage'; + } + + @override + String get storageModeInfo => 'Your files are stored in multiple locations'; + + @override + String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; + + @override + String get tutorialWelcomeDesc => + 'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.'; + + @override + String get tutorialWelcomeTip1 => + 'Download music from Spotify, Deezer, or paste any supported URL'; + + @override + String get tutorialWelcomeTip2 => + 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; + + @override + String get tutorialWelcomeTip3 => + 'Automatic metadata, cover art, and lyrics embedding'; + + @override + String get tutorialSearchTitle => 'Finding Music'; + + @override + String get tutorialSearchDesc => + 'There are two easy ways to find music you want to download.'; + + @override + String get tutorialSearchTip1 => + 'Paste a Spotify or Deezer URL directly in the search box'; + + @override + String get tutorialSearchTip2 => + 'Or type the song name, artist, or album to search'; + + @override + String get tutorialSearchTip3 => + 'Supports tracks, albums, playlists, and artist pages'; + + @override + String get tutorialDownloadTitle => 'Downloading Music'; + + @override + String get tutorialDownloadDesc => + 'Downloading music is simple and fast. Here\'s how it works.'; + + @override + String get tutorialDownloadTip1 => + 'Tap the download button next to any track to start downloading'; + + @override + String get tutorialDownloadTip2 => + 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; + + @override + String get tutorialDownloadTip3 => + 'Download entire albums or playlists with one tap'; + + @override + String get tutorialLibraryTitle => 'Your Library'; + + @override + String get tutorialLibraryDesc => + 'All your downloaded music is organized in the Library tab.'; + + @override + String get tutorialLibraryTip1 => + 'View download progress and queue in the Library tab'; + + @override + String get tutorialLibraryTip2 => + 'Tap any track to play it with your music player'; + + @override + String get tutorialLibraryTip3 => + 'Switch between list and grid view for better browsing'; + + @override + String get tutorialExtensionsTitle => 'Extensions'; + + @override + String get tutorialExtensionsDesc => + 'Extend the app\'s capabilities with community extensions.'; + + @override + String get tutorialExtensionsTip1 => + 'Browse the Store tab to discover useful extensions'; + + @override + String get tutorialExtensionsTip2 => + 'Add new download providers or search sources'; + + @override + String get tutorialExtensionsTip3 => + 'Get lyrics, enhanced metadata, and more features'; + + @override + String get tutorialSettingsTitle => 'Customize Your Experience'; + + @override + String get tutorialSettingsDesc => + 'Personalize the app in Settings to match your preferences.'; + + @override + String get tutorialSettingsTip1 => + 'Change download location and folder organization'; + + @override + String get tutorialSettingsTip2 => + 'Set default audio quality and format preferences'; + + @override + String get tutorialSettingsTip3 => 'Customize app theme and appearance'; + + @override + String get tutorialReadyMessage => + 'You\'re all set! Start downloading your favorite music now.'; + + @override + String get tutorialExample => 'EXAMPLE'; } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 73c92940..7a15e7e1 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -2456,6 +2456,192 @@ class AppLocalizationsEs extends AppLocalizations { ); return '$_temp0'; } + + @override + String get storageSwitchTitle => 'Switch Storage Mode'; + + @override + String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; + + @override + String get storageSwitchToAppTitle => 'Switch to App Storage?'; + + @override + String get storageSwitchToSafMessage => + 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; + + @override + String get storageSwitchToAppMessage => + 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; + + @override + String get storageSwitchExistingDownloads => 'Existing Downloads'; + + @override + String storageSwitchExistingDownloadsInfo(int count, String mode) { + return '$count tracks in $mode storage'; + } + + @override + String get storageSwitchNewDownloads => 'New Downloads'; + + @override + String storageSwitchNewDownloadsLocation(String location) { + return 'Will be saved to: $location'; + } + + @override + String get storageSwitchContinue => 'Continue'; + + @override + String get storageSwitchSelectFolder => 'Select SAF Folder'; + + @override + String get storageAppStorage => 'App Storage'; + + @override + String get storageSafStorage => 'SAF Storage'; + + @override + String storageModeBadge(String mode) { + return 'Storage: $mode'; + } + + @override + String get storageStatsTitle => 'Storage Statistics'; + + @override + String storageStatsAppCount(int count) { + return '$count tracks in App Storage'; + } + + @override + String storageStatsSafCount(int count) { + return '$count tracks in SAF Storage'; + } + + @override + String get storageModeInfo => 'Your files are stored in multiple locations'; + + @override + String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; + + @override + String get tutorialWelcomeDesc => + 'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.'; + + @override + String get tutorialWelcomeTip1 => + 'Download music from Spotify, Deezer, or paste any supported URL'; + + @override + String get tutorialWelcomeTip2 => + 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; + + @override + String get tutorialWelcomeTip3 => + 'Automatic metadata, cover art, and lyrics embedding'; + + @override + String get tutorialSearchTitle => 'Finding Music'; + + @override + String get tutorialSearchDesc => + 'There are two easy ways to find music you want to download.'; + + @override + String get tutorialSearchTip1 => + 'Paste a Spotify or Deezer URL directly in the search box'; + + @override + String get tutorialSearchTip2 => + 'Or type the song name, artist, or album to search'; + + @override + String get tutorialSearchTip3 => + 'Supports tracks, albums, playlists, and artist pages'; + + @override + String get tutorialDownloadTitle => 'Downloading Music'; + + @override + String get tutorialDownloadDesc => + 'Downloading music is simple and fast. Here\'s how it works.'; + + @override + String get tutorialDownloadTip1 => + 'Tap the download button next to any track to start downloading'; + + @override + String get tutorialDownloadTip2 => + 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; + + @override + String get tutorialDownloadTip3 => + 'Download entire albums or playlists with one tap'; + + @override + String get tutorialLibraryTitle => 'Your Library'; + + @override + String get tutorialLibraryDesc => + 'All your downloaded music is organized in the Library tab.'; + + @override + String get tutorialLibraryTip1 => + 'View download progress and queue in the Library tab'; + + @override + String get tutorialLibraryTip2 => + 'Tap any track to play it with your music player'; + + @override + String get tutorialLibraryTip3 => + 'Switch between list and grid view for better browsing'; + + @override + String get tutorialExtensionsTitle => 'Extensions'; + + @override + String get tutorialExtensionsDesc => + 'Extend the app\'s capabilities with community extensions.'; + + @override + String get tutorialExtensionsTip1 => + 'Browse the Store tab to discover useful extensions'; + + @override + String get tutorialExtensionsTip2 => + 'Add new download providers or search sources'; + + @override + String get tutorialExtensionsTip3 => + 'Get lyrics, enhanced metadata, and more features'; + + @override + String get tutorialSettingsTitle => 'Customize Your Experience'; + + @override + String get tutorialSettingsDesc => + 'Personalize the app in Settings to match your preferences.'; + + @override + String get tutorialSettingsTip1 => + 'Change download location and folder organization'; + + @override + String get tutorialSettingsTip2 => + 'Set default audio quality and format preferences'; + + @override + String get tutorialSettingsTip3 => 'Customize app theme and appearance'; + + @override + String get tutorialReadyMessage => + 'You\'re all set! Start downloading your favorite music now.'; + + @override + String get tutorialExample => 'EXAMPLE'; } /// The translations for Spanish Castilian, as used in Spain (`es_ES`). diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index e024f2dc..60c567de 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2456,4 +2456,190 @@ class AppLocalizationsFr extends AppLocalizations { ); return '$_temp0'; } + + @override + String get storageSwitchTitle => 'Switch Storage Mode'; + + @override + String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; + + @override + String get storageSwitchToAppTitle => 'Switch to App Storage?'; + + @override + String get storageSwitchToSafMessage => + 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; + + @override + String get storageSwitchToAppMessage => + 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; + + @override + String get storageSwitchExistingDownloads => 'Existing Downloads'; + + @override + String storageSwitchExistingDownloadsInfo(int count, String mode) { + return '$count tracks in $mode storage'; + } + + @override + String get storageSwitchNewDownloads => 'New Downloads'; + + @override + String storageSwitchNewDownloadsLocation(String location) { + return 'Will be saved to: $location'; + } + + @override + String get storageSwitchContinue => 'Continue'; + + @override + String get storageSwitchSelectFolder => 'Select SAF Folder'; + + @override + String get storageAppStorage => 'App Storage'; + + @override + String get storageSafStorage => 'SAF Storage'; + + @override + String storageModeBadge(String mode) { + return 'Storage: $mode'; + } + + @override + String get storageStatsTitle => 'Storage Statistics'; + + @override + String storageStatsAppCount(int count) { + return '$count tracks in App Storage'; + } + + @override + String storageStatsSafCount(int count) { + return '$count tracks in SAF Storage'; + } + + @override + String get storageModeInfo => 'Your files are stored in multiple locations'; + + @override + String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; + + @override + String get tutorialWelcomeDesc => + 'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.'; + + @override + String get tutorialWelcomeTip1 => + 'Download music from Spotify, Deezer, or paste any supported URL'; + + @override + String get tutorialWelcomeTip2 => + 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; + + @override + String get tutorialWelcomeTip3 => + 'Automatic metadata, cover art, and lyrics embedding'; + + @override + String get tutorialSearchTitle => 'Finding Music'; + + @override + String get tutorialSearchDesc => + 'There are two easy ways to find music you want to download.'; + + @override + String get tutorialSearchTip1 => + 'Paste a Spotify or Deezer URL directly in the search box'; + + @override + String get tutorialSearchTip2 => + 'Or type the song name, artist, or album to search'; + + @override + String get tutorialSearchTip3 => + 'Supports tracks, albums, playlists, and artist pages'; + + @override + String get tutorialDownloadTitle => 'Downloading Music'; + + @override + String get tutorialDownloadDesc => + 'Downloading music is simple and fast. Here\'s how it works.'; + + @override + String get tutorialDownloadTip1 => + 'Tap the download button next to any track to start downloading'; + + @override + String get tutorialDownloadTip2 => + 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; + + @override + String get tutorialDownloadTip3 => + 'Download entire albums or playlists with one tap'; + + @override + String get tutorialLibraryTitle => 'Your Library'; + + @override + String get tutorialLibraryDesc => + 'All your downloaded music is organized in the Library tab.'; + + @override + String get tutorialLibraryTip1 => + 'View download progress and queue in the Library tab'; + + @override + String get tutorialLibraryTip2 => + 'Tap any track to play it with your music player'; + + @override + String get tutorialLibraryTip3 => + 'Switch between list and grid view for better browsing'; + + @override + String get tutorialExtensionsTitle => 'Extensions'; + + @override + String get tutorialExtensionsDesc => + 'Extend the app\'s capabilities with community extensions.'; + + @override + String get tutorialExtensionsTip1 => + 'Browse the Store tab to discover useful extensions'; + + @override + String get tutorialExtensionsTip2 => + 'Add new download providers or search sources'; + + @override + String get tutorialExtensionsTip3 => + 'Get lyrics, enhanced metadata, and more features'; + + @override + String get tutorialSettingsTitle => 'Customize Your Experience'; + + @override + String get tutorialSettingsDesc => + 'Personalize the app in Settings to match your preferences.'; + + @override + String get tutorialSettingsTip1 => + 'Change download location and folder organization'; + + @override + String get tutorialSettingsTip2 => + 'Set default audio quality and format preferences'; + + @override + String get tutorialSettingsTip3 => 'Customize app theme and appearance'; + + @override + String get tutorialReadyMessage => + 'You\'re all set! Start downloading your favorite music now.'; + + @override + String get tutorialExample => 'EXAMPLE'; } diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index e75ab302..630c8cc7 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -2456,4 +2456,190 @@ class AppLocalizationsHi extends AppLocalizations { ); return '$_temp0'; } + + @override + String get storageSwitchTitle => 'Switch Storage Mode'; + + @override + String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; + + @override + String get storageSwitchToAppTitle => 'Switch to App Storage?'; + + @override + String get storageSwitchToSafMessage => + 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; + + @override + String get storageSwitchToAppMessage => + 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; + + @override + String get storageSwitchExistingDownloads => 'Existing Downloads'; + + @override + String storageSwitchExistingDownloadsInfo(int count, String mode) { + return '$count tracks in $mode storage'; + } + + @override + String get storageSwitchNewDownloads => 'New Downloads'; + + @override + String storageSwitchNewDownloadsLocation(String location) { + return 'Will be saved to: $location'; + } + + @override + String get storageSwitchContinue => 'Continue'; + + @override + String get storageSwitchSelectFolder => 'Select SAF Folder'; + + @override + String get storageAppStorage => 'App Storage'; + + @override + String get storageSafStorage => 'SAF Storage'; + + @override + String storageModeBadge(String mode) { + return 'Storage: $mode'; + } + + @override + String get storageStatsTitle => 'Storage Statistics'; + + @override + String storageStatsAppCount(int count) { + return '$count tracks in App Storage'; + } + + @override + String storageStatsSafCount(int count) { + return '$count tracks in SAF Storage'; + } + + @override + String get storageModeInfo => 'Your files are stored in multiple locations'; + + @override + String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; + + @override + String get tutorialWelcomeDesc => + 'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.'; + + @override + String get tutorialWelcomeTip1 => + 'Download music from Spotify, Deezer, or paste any supported URL'; + + @override + String get tutorialWelcomeTip2 => + 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; + + @override + String get tutorialWelcomeTip3 => + 'Automatic metadata, cover art, and lyrics embedding'; + + @override + String get tutorialSearchTitle => 'Finding Music'; + + @override + String get tutorialSearchDesc => + 'There are two easy ways to find music you want to download.'; + + @override + String get tutorialSearchTip1 => + 'Paste a Spotify or Deezer URL directly in the search box'; + + @override + String get tutorialSearchTip2 => + 'Or type the song name, artist, or album to search'; + + @override + String get tutorialSearchTip3 => + 'Supports tracks, albums, playlists, and artist pages'; + + @override + String get tutorialDownloadTitle => 'Downloading Music'; + + @override + String get tutorialDownloadDesc => + 'Downloading music is simple and fast. Here\'s how it works.'; + + @override + String get tutorialDownloadTip1 => + 'Tap the download button next to any track to start downloading'; + + @override + String get tutorialDownloadTip2 => + 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; + + @override + String get tutorialDownloadTip3 => + 'Download entire albums or playlists with one tap'; + + @override + String get tutorialLibraryTitle => 'Your Library'; + + @override + String get tutorialLibraryDesc => + 'All your downloaded music is organized in the Library tab.'; + + @override + String get tutorialLibraryTip1 => + 'View download progress and queue in the Library tab'; + + @override + String get tutorialLibraryTip2 => + 'Tap any track to play it with your music player'; + + @override + String get tutorialLibraryTip3 => + 'Switch between list and grid view for better browsing'; + + @override + String get tutorialExtensionsTitle => 'Extensions'; + + @override + String get tutorialExtensionsDesc => + 'Extend the app\'s capabilities with community extensions.'; + + @override + String get tutorialExtensionsTip1 => + 'Browse the Store tab to discover useful extensions'; + + @override + String get tutorialExtensionsTip2 => + 'Add new download providers or search sources'; + + @override + String get tutorialExtensionsTip3 => + 'Get lyrics, enhanced metadata, and more features'; + + @override + String get tutorialSettingsTitle => 'Customize Your Experience'; + + @override + String get tutorialSettingsDesc => + 'Personalize the app in Settings to match your preferences.'; + + @override + String get tutorialSettingsTip1 => + 'Change download location and folder organization'; + + @override + String get tutorialSettingsTip2 => + 'Set default audio quality and format preferences'; + + @override + String get tutorialSettingsTip3 => 'Customize app theme and appearance'; + + @override + String get tutorialReadyMessage => + 'You\'re all set! Start downloading your favorite music now.'; + + @override + String get tutorialExample => 'EXAMPLE'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 48732f0d..a848324c 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -2469,4 +2469,190 @@ class AppLocalizationsId extends AppLocalizations { ); return '$_temp0'; } + + @override + String get storageSwitchTitle => 'Switch Storage Mode'; + + @override + String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; + + @override + String get storageSwitchToAppTitle => 'Switch to App Storage?'; + + @override + String get storageSwitchToSafMessage => + 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; + + @override + String get storageSwitchToAppMessage => + 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; + + @override + String get storageSwitchExistingDownloads => 'Existing Downloads'; + + @override + String storageSwitchExistingDownloadsInfo(int count, String mode) { + return '$count tracks in $mode storage'; + } + + @override + String get storageSwitchNewDownloads => 'New Downloads'; + + @override + String storageSwitchNewDownloadsLocation(String location) { + return 'Will be saved to: $location'; + } + + @override + String get storageSwitchContinue => 'Continue'; + + @override + String get storageSwitchSelectFolder => 'Select SAF Folder'; + + @override + String get storageAppStorage => 'App Storage'; + + @override + String get storageSafStorage => 'SAF Storage'; + + @override + String storageModeBadge(String mode) { + return 'Storage: $mode'; + } + + @override + String get storageStatsTitle => 'Storage Statistics'; + + @override + String storageStatsAppCount(int count) { + return '$count tracks in App Storage'; + } + + @override + String storageStatsSafCount(int count) { + return '$count tracks in SAF Storage'; + } + + @override + String get storageModeInfo => 'Your files are stored in multiple locations'; + + @override + String get tutorialWelcomeTitle => 'Selamat Datang di SpotiFLAC!'; + + @override + String get tutorialWelcomeDesc => + 'Mari pelajari cara mengunduh musik favorit Anda dalam kualitas lossless. Tutorial singkat ini akan menunjukkan dasar-dasarnya.'; + + @override + String get tutorialWelcomeTip1 => + 'Unduh musik dari Spotify, Deezer, atau tempel URL yang didukung'; + + @override + String get tutorialWelcomeTip2 => + 'Dapatkan audio kualitas FLAC dari Tidal, Qobuz, atau Amazon Music'; + + @override + String get tutorialWelcomeTip3 => + 'Metadata, cover art, dan lirik otomatis tertanam'; + + @override + String get tutorialSearchTitle => 'Mencari Musik'; + + @override + String get tutorialSearchDesc => + 'Ada dua cara mudah untuk menemukan musik yang ingin Anda unduh.'; + + @override + String get tutorialSearchTip1 => + 'Tempel URL Spotify atau Deezer langsung di kotak pencarian'; + + @override + String get tutorialSearchTip2 => + 'Atau ketik nama lagu, artis, atau album untuk mencari'; + + @override + String get tutorialSearchTip3 => + 'Mendukung lagu, album, playlist, dan halaman artis'; + + @override + String get tutorialDownloadTitle => 'Mengunduh Musik'; + + @override + String get tutorialDownloadDesc => + 'Mengunduh musik itu mudah dan cepat. Begini caranya.'; + + @override + String get tutorialDownloadTip1 => + 'Ketuk tombol unduh di samping lagu mana pun untuk mulai mengunduh'; + + @override + String get tutorialDownloadTip2 => + 'Pilih kualitas yang Anda inginkan (FLAC, Hi-Res, atau MP3)'; + + @override + String get tutorialDownloadTip3 => + 'Unduh seluruh album atau playlist dengan satu ketukan'; + + @override + String get tutorialLibraryTitle => 'Perpustakaan Anda'; + + @override + String get tutorialLibraryDesc => + 'Semua musik yang Anda unduh terorganisir di tab Perpustakaan.'; + + @override + String get tutorialLibraryTip1 => + 'Lihat progres unduhan dan antrian di tab Perpustakaan'; + + @override + String get tutorialLibraryTip2 => + 'Ketuk lagu mana pun untuk memutarnya dengan pemutar musik'; + + @override + String get tutorialLibraryTip3 => + 'Beralih antara tampilan daftar dan grid untuk penjelajahan lebih baik'; + + @override + String get tutorialExtensionsTitle => 'Ekstensi'; + + @override + String get tutorialExtensionsDesc => + 'Tingkatkan kemampuan aplikasi dengan ekstensi komunitas.'; + + @override + String get tutorialExtensionsTip1 => + 'Jelajahi tab Toko untuk menemukan ekstensi berguna'; + + @override + String get tutorialExtensionsTip2 => + 'Tambahkan provider unduhan atau sumber pencarian baru'; + + @override + String get tutorialExtensionsTip3 => + 'Dapatkan lirik, metadata lebih baik, dan fitur lainnya'; + + @override + String get tutorialSettingsTitle => 'Sesuaikan Pengalaman Anda'; + + @override + String get tutorialSettingsDesc => + 'Personalisasi aplikasi di Pengaturan sesuai preferensi Anda.'; + + @override + String get tutorialSettingsTip1 => + 'Ubah lokasi unduhan dan organisasi folder'; + + @override + String get tutorialSettingsTip2 => + 'Atur kualitas audio dan preferensi format default'; + + @override + String get tutorialSettingsTip3 => 'Sesuaikan tema dan tampilan aplikasi'; + + @override + String get tutorialReadyMessage => + 'Anda siap! Mulai unduh musik favorit Anda sekarang.'; + + @override + String get tutorialExample => 'CONTOH'; } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 25658b7d..1e6cf6db 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -2442,4 +2442,190 @@ class AppLocalizationsJa extends AppLocalizations { ); return '$_temp0'; } + + @override + String get storageSwitchTitle => 'Switch Storage Mode'; + + @override + String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; + + @override + String get storageSwitchToAppTitle => 'Switch to App Storage?'; + + @override + String get storageSwitchToSafMessage => + 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; + + @override + String get storageSwitchToAppMessage => + 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; + + @override + String get storageSwitchExistingDownloads => 'Existing Downloads'; + + @override + String storageSwitchExistingDownloadsInfo(int count, String mode) { + return '$count tracks in $mode storage'; + } + + @override + String get storageSwitchNewDownloads => 'New Downloads'; + + @override + String storageSwitchNewDownloadsLocation(String location) { + return 'Will be saved to: $location'; + } + + @override + String get storageSwitchContinue => 'Continue'; + + @override + String get storageSwitchSelectFolder => 'Select SAF Folder'; + + @override + String get storageAppStorage => 'App Storage'; + + @override + String get storageSafStorage => 'SAF Storage'; + + @override + String storageModeBadge(String mode) { + return 'Storage: $mode'; + } + + @override + String get storageStatsTitle => 'Storage Statistics'; + + @override + String storageStatsAppCount(int count) { + return '$count tracks in App Storage'; + } + + @override + String storageStatsSafCount(int count) { + return '$count tracks in SAF Storage'; + } + + @override + String get storageModeInfo => 'Your files are stored in multiple locations'; + + @override + String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; + + @override + String get tutorialWelcomeDesc => + 'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.'; + + @override + String get tutorialWelcomeTip1 => + 'Download music from Spotify, Deezer, or paste any supported URL'; + + @override + String get tutorialWelcomeTip2 => + 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; + + @override + String get tutorialWelcomeTip3 => + 'Automatic metadata, cover art, and lyrics embedding'; + + @override + String get tutorialSearchTitle => 'Finding Music'; + + @override + String get tutorialSearchDesc => + 'There are two easy ways to find music you want to download.'; + + @override + String get tutorialSearchTip1 => + 'Paste a Spotify or Deezer URL directly in the search box'; + + @override + String get tutorialSearchTip2 => + 'Or type the song name, artist, or album to search'; + + @override + String get tutorialSearchTip3 => + 'Supports tracks, albums, playlists, and artist pages'; + + @override + String get tutorialDownloadTitle => 'Downloading Music'; + + @override + String get tutorialDownloadDesc => + 'Downloading music is simple and fast. Here\'s how it works.'; + + @override + String get tutorialDownloadTip1 => + 'Tap the download button next to any track to start downloading'; + + @override + String get tutorialDownloadTip2 => + 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; + + @override + String get tutorialDownloadTip3 => + 'Download entire albums or playlists with one tap'; + + @override + String get tutorialLibraryTitle => 'Your Library'; + + @override + String get tutorialLibraryDesc => + 'All your downloaded music is organized in the Library tab.'; + + @override + String get tutorialLibraryTip1 => + 'View download progress and queue in the Library tab'; + + @override + String get tutorialLibraryTip2 => + 'Tap any track to play it with your music player'; + + @override + String get tutorialLibraryTip3 => + 'Switch between list and grid view for better browsing'; + + @override + String get tutorialExtensionsTitle => 'Extensions'; + + @override + String get tutorialExtensionsDesc => + 'Extend the app\'s capabilities with community extensions.'; + + @override + String get tutorialExtensionsTip1 => + 'Browse the Store tab to discover useful extensions'; + + @override + String get tutorialExtensionsTip2 => + 'Add new download providers or search sources'; + + @override + String get tutorialExtensionsTip3 => + 'Get lyrics, enhanced metadata, and more features'; + + @override + String get tutorialSettingsTitle => 'Customize Your Experience'; + + @override + String get tutorialSettingsDesc => + 'Personalize the app in Settings to match your preferences.'; + + @override + String get tutorialSettingsTip1 => + 'Change download location and folder organization'; + + @override + String get tutorialSettingsTip2 => + 'Set default audio quality and format preferences'; + + @override + String get tutorialSettingsTip3 => 'Customize app theme and appearance'; + + @override + String get tutorialReadyMessage => + 'You\'re all set! Start downloading your favorite music now.'; + + @override + String get tutorialExample => 'EXAMPLE'; } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 290d94c2..a95dbc39 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -2456,4 +2456,190 @@ class AppLocalizationsKo extends AppLocalizations { ); return '$_temp0'; } + + @override + String get storageSwitchTitle => 'Switch Storage Mode'; + + @override + String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; + + @override + String get storageSwitchToAppTitle => 'Switch to App Storage?'; + + @override + String get storageSwitchToSafMessage => + 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; + + @override + String get storageSwitchToAppMessage => + 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; + + @override + String get storageSwitchExistingDownloads => 'Existing Downloads'; + + @override + String storageSwitchExistingDownloadsInfo(int count, String mode) { + return '$count tracks in $mode storage'; + } + + @override + String get storageSwitchNewDownloads => 'New Downloads'; + + @override + String storageSwitchNewDownloadsLocation(String location) { + return 'Will be saved to: $location'; + } + + @override + String get storageSwitchContinue => 'Continue'; + + @override + String get storageSwitchSelectFolder => 'Select SAF Folder'; + + @override + String get storageAppStorage => 'App Storage'; + + @override + String get storageSafStorage => 'SAF Storage'; + + @override + String storageModeBadge(String mode) { + return 'Storage: $mode'; + } + + @override + String get storageStatsTitle => 'Storage Statistics'; + + @override + String storageStatsAppCount(int count) { + return '$count tracks in App Storage'; + } + + @override + String storageStatsSafCount(int count) { + return '$count tracks in SAF Storage'; + } + + @override + String get storageModeInfo => 'Your files are stored in multiple locations'; + + @override + String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; + + @override + String get tutorialWelcomeDesc => + 'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.'; + + @override + String get tutorialWelcomeTip1 => + 'Download music from Spotify, Deezer, or paste any supported URL'; + + @override + String get tutorialWelcomeTip2 => + 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; + + @override + String get tutorialWelcomeTip3 => + 'Automatic metadata, cover art, and lyrics embedding'; + + @override + String get tutorialSearchTitle => 'Finding Music'; + + @override + String get tutorialSearchDesc => + 'There are two easy ways to find music you want to download.'; + + @override + String get tutorialSearchTip1 => + 'Paste a Spotify or Deezer URL directly in the search box'; + + @override + String get tutorialSearchTip2 => + 'Or type the song name, artist, or album to search'; + + @override + String get tutorialSearchTip3 => + 'Supports tracks, albums, playlists, and artist pages'; + + @override + String get tutorialDownloadTitle => 'Downloading Music'; + + @override + String get tutorialDownloadDesc => + 'Downloading music is simple and fast. Here\'s how it works.'; + + @override + String get tutorialDownloadTip1 => + 'Tap the download button next to any track to start downloading'; + + @override + String get tutorialDownloadTip2 => + 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; + + @override + String get tutorialDownloadTip3 => + 'Download entire albums or playlists with one tap'; + + @override + String get tutorialLibraryTitle => 'Your Library'; + + @override + String get tutorialLibraryDesc => + 'All your downloaded music is organized in the Library tab.'; + + @override + String get tutorialLibraryTip1 => + 'View download progress and queue in the Library tab'; + + @override + String get tutorialLibraryTip2 => + 'Tap any track to play it with your music player'; + + @override + String get tutorialLibraryTip3 => + 'Switch between list and grid view for better browsing'; + + @override + String get tutorialExtensionsTitle => 'Extensions'; + + @override + String get tutorialExtensionsDesc => + 'Extend the app\'s capabilities with community extensions.'; + + @override + String get tutorialExtensionsTip1 => + 'Browse the Store tab to discover useful extensions'; + + @override + String get tutorialExtensionsTip2 => + 'Add new download providers or search sources'; + + @override + String get tutorialExtensionsTip3 => + 'Get lyrics, enhanced metadata, and more features'; + + @override + String get tutorialSettingsTitle => 'Customize Your Experience'; + + @override + String get tutorialSettingsDesc => + 'Personalize the app in Settings to match your preferences.'; + + @override + String get tutorialSettingsTip1 => + 'Change download location and folder organization'; + + @override + String get tutorialSettingsTip2 => + 'Set default audio quality and format preferences'; + + @override + String get tutorialSettingsTip3 => 'Customize app theme and appearance'; + + @override + String get tutorialReadyMessage => + 'You\'re all set! Start downloading your favorite music now.'; + + @override + String get tutorialExample => 'EXAMPLE'; } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 1a6e1e00..d4c4b68f 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -2456,4 +2456,190 @@ class AppLocalizationsNl extends AppLocalizations { ); return '$_temp0'; } + + @override + String get storageSwitchTitle => 'Switch Storage Mode'; + + @override + String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; + + @override + String get storageSwitchToAppTitle => 'Switch to App Storage?'; + + @override + String get storageSwitchToSafMessage => + 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; + + @override + String get storageSwitchToAppMessage => + 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; + + @override + String get storageSwitchExistingDownloads => 'Existing Downloads'; + + @override + String storageSwitchExistingDownloadsInfo(int count, String mode) { + return '$count tracks in $mode storage'; + } + + @override + String get storageSwitchNewDownloads => 'New Downloads'; + + @override + String storageSwitchNewDownloadsLocation(String location) { + return 'Will be saved to: $location'; + } + + @override + String get storageSwitchContinue => 'Continue'; + + @override + String get storageSwitchSelectFolder => 'Select SAF Folder'; + + @override + String get storageAppStorage => 'App Storage'; + + @override + String get storageSafStorage => 'SAF Storage'; + + @override + String storageModeBadge(String mode) { + return 'Storage: $mode'; + } + + @override + String get storageStatsTitle => 'Storage Statistics'; + + @override + String storageStatsAppCount(int count) { + return '$count tracks in App Storage'; + } + + @override + String storageStatsSafCount(int count) { + return '$count tracks in SAF Storage'; + } + + @override + String get storageModeInfo => 'Your files are stored in multiple locations'; + + @override + String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; + + @override + String get tutorialWelcomeDesc => + 'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.'; + + @override + String get tutorialWelcomeTip1 => + 'Download music from Spotify, Deezer, or paste any supported URL'; + + @override + String get tutorialWelcomeTip2 => + 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; + + @override + String get tutorialWelcomeTip3 => + 'Automatic metadata, cover art, and lyrics embedding'; + + @override + String get tutorialSearchTitle => 'Finding Music'; + + @override + String get tutorialSearchDesc => + 'There are two easy ways to find music you want to download.'; + + @override + String get tutorialSearchTip1 => + 'Paste a Spotify or Deezer URL directly in the search box'; + + @override + String get tutorialSearchTip2 => + 'Or type the song name, artist, or album to search'; + + @override + String get tutorialSearchTip3 => + 'Supports tracks, albums, playlists, and artist pages'; + + @override + String get tutorialDownloadTitle => 'Downloading Music'; + + @override + String get tutorialDownloadDesc => + 'Downloading music is simple and fast. Here\'s how it works.'; + + @override + String get tutorialDownloadTip1 => + 'Tap the download button next to any track to start downloading'; + + @override + String get tutorialDownloadTip2 => + 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; + + @override + String get tutorialDownloadTip3 => + 'Download entire albums or playlists with one tap'; + + @override + String get tutorialLibraryTitle => 'Your Library'; + + @override + String get tutorialLibraryDesc => + 'All your downloaded music is organized in the Library tab.'; + + @override + String get tutorialLibraryTip1 => + 'View download progress and queue in the Library tab'; + + @override + String get tutorialLibraryTip2 => + 'Tap any track to play it with your music player'; + + @override + String get tutorialLibraryTip3 => + 'Switch between list and grid view for better browsing'; + + @override + String get tutorialExtensionsTitle => 'Extensions'; + + @override + String get tutorialExtensionsDesc => + 'Extend the app\'s capabilities with community extensions.'; + + @override + String get tutorialExtensionsTip1 => + 'Browse the Store tab to discover useful extensions'; + + @override + String get tutorialExtensionsTip2 => + 'Add new download providers or search sources'; + + @override + String get tutorialExtensionsTip3 => + 'Get lyrics, enhanced metadata, and more features'; + + @override + String get tutorialSettingsTitle => 'Customize Your Experience'; + + @override + String get tutorialSettingsDesc => + 'Personalize the app in Settings to match your preferences.'; + + @override + String get tutorialSettingsTip1 => + 'Change download location and folder organization'; + + @override + String get tutorialSettingsTip2 => + 'Set default audio quality and format preferences'; + + @override + String get tutorialSettingsTip3 => 'Customize app theme and appearance'; + + @override + String get tutorialReadyMessage => + 'You\'re all set! Start downloading your favorite music now.'; + + @override + String get tutorialExample => 'EXAMPLE'; } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index a442b37f..12ac3280 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -2456,6 +2456,192 @@ class AppLocalizationsPt extends AppLocalizations { ); return '$_temp0'; } + + @override + String get storageSwitchTitle => 'Switch Storage Mode'; + + @override + String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; + + @override + String get storageSwitchToAppTitle => 'Switch to App Storage?'; + + @override + String get storageSwitchToSafMessage => + 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; + + @override + String get storageSwitchToAppMessage => + 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; + + @override + String get storageSwitchExistingDownloads => 'Existing Downloads'; + + @override + String storageSwitchExistingDownloadsInfo(int count, String mode) { + return '$count tracks in $mode storage'; + } + + @override + String get storageSwitchNewDownloads => 'New Downloads'; + + @override + String storageSwitchNewDownloadsLocation(String location) { + return 'Will be saved to: $location'; + } + + @override + String get storageSwitchContinue => 'Continue'; + + @override + String get storageSwitchSelectFolder => 'Select SAF Folder'; + + @override + String get storageAppStorage => 'App Storage'; + + @override + String get storageSafStorage => 'SAF Storage'; + + @override + String storageModeBadge(String mode) { + return 'Storage: $mode'; + } + + @override + String get storageStatsTitle => 'Storage Statistics'; + + @override + String storageStatsAppCount(int count) { + return '$count tracks in App Storage'; + } + + @override + String storageStatsSafCount(int count) { + return '$count tracks in SAF Storage'; + } + + @override + String get storageModeInfo => 'Your files are stored in multiple locations'; + + @override + String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; + + @override + String get tutorialWelcomeDesc => + 'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.'; + + @override + String get tutorialWelcomeTip1 => + 'Download music from Spotify, Deezer, or paste any supported URL'; + + @override + String get tutorialWelcomeTip2 => + 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; + + @override + String get tutorialWelcomeTip3 => + 'Automatic metadata, cover art, and lyrics embedding'; + + @override + String get tutorialSearchTitle => 'Finding Music'; + + @override + String get tutorialSearchDesc => + 'There are two easy ways to find music you want to download.'; + + @override + String get tutorialSearchTip1 => + 'Paste a Spotify or Deezer URL directly in the search box'; + + @override + String get tutorialSearchTip2 => + 'Or type the song name, artist, or album to search'; + + @override + String get tutorialSearchTip3 => + 'Supports tracks, albums, playlists, and artist pages'; + + @override + String get tutorialDownloadTitle => 'Downloading Music'; + + @override + String get tutorialDownloadDesc => + 'Downloading music is simple and fast. Here\'s how it works.'; + + @override + String get tutorialDownloadTip1 => + 'Tap the download button next to any track to start downloading'; + + @override + String get tutorialDownloadTip2 => + 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; + + @override + String get tutorialDownloadTip3 => + 'Download entire albums or playlists with one tap'; + + @override + String get tutorialLibraryTitle => 'Your Library'; + + @override + String get tutorialLibraryDesc => + 'All your downloaded music is organized in the Library tab.'; + + @override + String get tutorialLibraryTip1 => + 'View download progress and queue in the Library tab'; + + @override + String get tutorialLibraryTip2 => + 'Tap any track to play it with your music player'; + + @override + String get tutorialLibraryTip3 => + 'Switch between list and grid view for better browsing'; + + @override + String get tutorialExtensionsTitle => 'Extensions'; + + @override + String get tutorialExtensionsDesc => + 'Extend the app\'s capabilities with community extensions.'; + + @override + String get tutorialExtensionsTip1 => + 'Browse the Store tab to discover useful extensions'; + + @override + String get tutorialExtensionsTip2 => + 'Add new download providers or search sources'; + + @override + String get tutorialExtensionsTip3 => + 'Get lyrics, enhanced metadata, and more features'; + + @override + String get tutorialSettingsTitle => 'Customize Your Experience'; + + @override + String get tutorialSettingsDesc => + 'Personalize the app in Settings to match your preferences.'; + + @override + String get tutorialSettingsTip1 => + 'Change download location and folder organization'; + + @override + String get tutorialSettingsTip2 => + 'Set default audio quality and format preferences'; + + @override + String get tutorialSettingsTip3 => 'Customize app theme and appearance'; + + @override + String get tutorialReadyMessage => + 'You\'re all set! Start downloading your favorite music now.'; + + @override + String get tutorialExample => 'EXAMPLE'; } /// The translations for Portuguese, as used in Portugal (`pt_PT`). diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index afa8b256..9dc3421e 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2502,4 +2502,190 @@ class AppLocalizationsRu extends AppLocalizations { ); return '$_temp0'; } + + @override + String get storageSwitchTitle => 'Switch Storage Mode'; + + @override + String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; + + @override + String get storageSwitchToAppTitle => 'Switch to App Storage?'; + + @override + String get storageSwitchToSafMessage => + 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; + + @override + String get storageSwitchToAppMessage => + 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; + + @override + String get storageSwitchExistingDownloads => 'Existing Downloads'; + + @override + String storageSwitchExistingDownloadsInfo(int count, String mode) { + return '$count tracks in $mode storage'; + } + + @override + String get storageSwitchNewDownloads => 'New Downloads'; + + @override + String storageSwitchNewDownloadsLocation(String location) { + return 'Will be saved to: $location'; + } + + @override + String get storageSwitchContinue => 'Continue'; + + @override + String get storageSwitchSelectFolder => 'Select SAF Folder'; + + @override + String get storageAppStorage => 'App Storage'; + + @override + String get storageSafStorage => 'SAF Storage'; + + @override + String storageModeBadge(String mode) { + return 'Storage: $mode'; + } + + @override + String get storageStatsTitle => 'Storage Statistics'; + + @override + String storageStatsAppCount(int count) { + return '$count tracks in App Storage'; + } + + @override + String storageStatsSafCount(int count) { + return '$count tracks in SAF Storage'; + } + + @override + String get storageModeInfo => 'Your files are stored in multiple locations'; + + @override + String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; + + @override + String get tutorialWelcomeDesc => + 'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.'; + + @override + String get tutorialWelcomeTip1 => + 'Download music from Spotify, Deezer, or paste any supported URL'; + + @override + String get tutorialWelcomeTip2 => + 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; + + @override + String get tutorialWelcomeTip3 => + 'Automatic metadata, cover art, and lyrics embedding'; + + @override + String get tutorialSearchTitle => 'Finding Music'; + + @override + String get tutorialSearchDesc => + 'There are two easy ways to find music you want to download.'; + + @override + String get tutorialSearchTip1 => + 'Paste a Spotify or Deezer URL directly in the search box'; + + @override + String get tutorialSearchTip2 => + 'Or type the song name, artist, or album to search'; + + @override + String get tutorialSearchTip3 => + 'Supports tracks, albums, playlists, and artist pages'; + + @override + String get tutorialDownloadTitle => 'Downloading Music'; + + @override + String get tutorialDownloadDesc => + 'Downloading music is simple and fast. Here\'s how it works.'; + + @override + String get tutorialDownloadTip1 => + 'Tap the download button next to any track to start downloading'; + + @override + String get tutorialDownloadTip2 => + 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; + + @override + String get tutorialDownloadTip3 => + 'Download entire albums or playlists with one tap'; + + @override + String get tutorialLibraryTitle => 'Your Library'; + + @override + String get tutorialLibraryDesc => + 'All your downloaded music is organized in the Library tab.'; + + @override + String get tutorialLibraryTip1 => + 'View download progress and queue in the Library tab'; + + @override + String get tutorialLibraryTip2 => + 'Tap any track to play it with your music player'; + + @override + String get tutorialLibraryTip3 => + 'Switch between list and grid view for better browsing'; + + @override + String get tutorialExtensionsTitle => 'Extensions'; + + @override + String get tutorialExtensionsDesc => + 'Extend the app\'s capabilities with community extensions.'; + + @override + String get tutorialExtensionsTip1 => + 'Browse the Store tab to discover useful extensions'; + + @override + String get tutorialExtensionsTip2 => + 'Add new download providers or search sources'; + + @override + String get tutorialExtensionsTip3 => + 'Get lyrics, enhanced metadata, and more features'; + + @override + String get tutorialSettingsTitle => 'Customize Your Experience'; + + @override + String get tutorialSettingsDesc => + 'Personalize the app in Settings to match your preferences.'; + + @override + String get tutorialSettingsTip1 => + 'Change download location and folder organization'; + + @override + String get tutorialSettingsTip2 => + 'Set default audio quality and format preferences'; + + @override + String get tutorialSettingsTip3 => 'Customize app theme and appearance'; + + @override + String get tutorialReadyMessage => + 'You\'re all set! Start downloading your favorite music now.'; + + @override + String get tutorialExample => 'EXAMPLE'; } diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 81946bb3..e688b7ce 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -2471,4 +2471,190 @@ class AppLocalizationsTr extends AppLocalizations { ); return '$_temp0'; } + + @override + String get storageSwitchTitle => 'Switch Storage Mode'; + + @override + String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; + + @override + String get storageSwitchToAppTitle => 'Switch to App Storage?'; + + @override + String get storageSwitchToSafMessage => + 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; + + @override + String get storageSwitchToAppMessage => + 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; + + @override + String get storageSwitchExistingDownloads => 'Existing Downloads'; + + @override + String storageSwitchExistingDownloadsInfo(int count, String mode) { + return '$count tracks in $mode storage'; + } + + @override + String get storageSwitchNewDownloads => 'New Downloads'; + + @override + String storageSwitchNewDownloadsLocation(String location) { + return 'Will be saved to: $location'; + } + + @override + String get storageSwitchContinue => 'Continue'; + + @override + String get storageSwitchSelectFolder => 'Select SAF Folder'; + + @override + String get storageAppStorage => 'App Storage'; + + @override + String get storageSafStorage => 'SAF Storage'; + + @override + String storageModeBadge(String mode) { + return 'Storage: $mode'; + } + + @override + String get storageStatsTitle => 'Storage Statistics'; + + @override + String storageStatsAppCount(int count) { + return '$count tracks in App Storage'; + } + + @override + String storageStatsSafCount(int count) { + return '$count tracks in SAF Storage'; + } + + @override + String get storageModeInfo => 'Your files are stored in multiple locations'; + + @override + String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; + + @override + String get tutorialWelcomeDesc => + 'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.'; + + @override + String get tutorialWelcomeTip1 => + 'Download music from Spotify, Deezer, or paste any supported URL'; + + @override + String get tutorialWelcomeTip2 => + 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; + + @override + String get tutorialWelcomeTip3 => + 'Automatic metadata, cover art, and lyrics embedding'; + + @override + String get tutorialSearchTitle => 'Finding Music'; + + @override + String get tutorialSearchDesc => + 'There are two easy ways to find music you want to download.'; + + @override + String get tutorialSearchTip1 => + 'Paste a Spotify or Deezer URL directly in the search box'; + + @override + String get tutorialSearchTip2 => + 'Or type the song name, artist, or album to search'; + + @override + String get tutorialSearchTip3 => + 'Supports tracks, albums, playlists, and artist pages'; + + @override + String get tutorialDownloadTitle => 'Downloading Music'; + + @override + String get tutorialDownloadDesc => + 'Downloading music is simple and fast. Here\'s how it works.'; + + @override + String get tutorialDownloadTip1 => + 'Tap the download button next to any track to start downloading'; + + @override + String get tutorialDownloadTip2 => + 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; + + @override + String get tutorialDownloadTip3 => + 'Download entire albums or playlists with one tap'; + + @override + String get tutorialLibraryTitle => 'Your Library'; + + @override + String get tutorialLibraryDesc => + 'All your downloaded music is organized in the Library tab.'; + + @override + String get tutorialLibraryTip1 => + 'View download progress and queue in the Library tab'; + + @override + String get tutorialLibraryTip2 => + 'Tap any track to play it with your music player'; + + @override + String get tutorialLibraryTip3 => + 'Switch between list and grid view for better browsing'; + + @override + String get tutorialExtensionsTitle => 'Extensions'; + + @override + String get tutorialExtensionsDesc => + 'Extend the app\'s capabilities with community extensions.'; + + @override + String get tutorialExtensionsTip1 => + 'Browse the Store tab to discover useful extensions'; + + @override + String get tutorialExtensionsTip2 => + 'Add new download providers or search sources'; + + @override + String get tutorialExtensionsTip3 => + 'Get lyrics, enhanced metadata, and more features'; + + @override + String get tutorialSettingsTitle => 'Customize Your Experience'; + + @override + String get tutorialSettingsDesc => + 'Personalize the app in Settings to match your preferences.'; + + @override + String get tutorialSettingsTip1 => + 'Change download location and folder organization'; + + @override + String get tutorialSettingsTip2 => + 'Set default audio quality and format preferences'; + + @override + String get tutorialSettingsTip3 => 'Customize app theme and appearance'; + + @override + String get tutorialReadyMessage => + 'You\'re all set! Start downloading your favorite music now.'; + + @override + String get tutorialExample => 'EXAMPLE'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 64e6da81..321a4083 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2456,6 +2456,192 @@ class AppLocalizationsZh extends AppLocalizations { ); return '$_temp0'; } + + @override + String get storageSwitchTitle => 'Switch Storage Mode'; + + @override + String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; + + @override + String get storageSwitchToAppTitle => 'Switch to App Storage?'; + + @override + String get storageSwitchToSafMessage => + 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; + + @override + String get storageSwitchToAppMessage => + 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; + + @override + String get storageSwitchExistingDownloads => 'Existing Downloads'; + + @override + String storageSwitchExistingDownloadsInfo(int count, String mode) { + return '$count tracks in $mode storage'; + } + + @override + String get storageSwitchNewDownloads => 'New Downloads'; + + @override + String storageSwitchNewDownloadsLocation(String location) { + return 'Will be saved to: $location'; + } + + @override + String get storageSwitchContinue => 'Continue'; + + @override + String get storageSwitchSelectFolder => 'Select SAF Folder'; + + @override + String get storageAppStorage => 'App Storage'; + + @override + String get storageSafStorage => 'SAF Storage'; + + @override + String storageModeBadge(String mode) { + return 'Storage: $mode'; + } + + @override + String get storageStatsTitle => 'Storage Statistics'; + + @override + String storageStatsAppCount(int count) { + return '$count tracks in App Storage'; + } + + @override + String storageStatsSafCount(int count) { + return '$count tracks in SAF Storage'; + } + + @override + String get storageModeInfo => 'Your files are stored in multiple locations'; + + @override + String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; + + @override + String get tutorialWelcomeDesc => + 'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.'; + + @override + String get tutorialWelcomeTip1 => + 'Download music from Spotify, Deezer, or paste any supported URL'; + + @override + String get tutorialWelcomeTip2 => + 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; + + @override + String get tutorialWelcomeTip3 => + 'Automatic metadata, cover art, and lyrics embedding'; + + @override + String get tutorialSearchTitle => 'Finding Music'; + + @override + String get tutorialSearchDesc => + 'There are two easy ways to find music you want to download.'; + + @override + String get tutorialSearchTip1 => + 'Paste a Spotify or Deezer URL directly in the search box'; + + @override + String get tutorialSearchTip2 => + 'Or type the song name, artist, or album to search'; + + @override + String get tutorialSearchTip3 => + 'Supports tracks, albums, playlists, and artist pages'; + + @override + String get tutorialDownloadTitle => 'Downloading Music'; + + @override + String get tutorialDownloadDesc => + 'Downloading music is simple and fast. Here\'s how it works.'; + + @override + String get tutorialDownloadTip1 => + 'Tap the download button next to any track to start downloading'; + + @override + String get tutorialDownloadTip2 => + 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; + + @override + String get tutorialDownloadTip3 => + 'Download entire albums or playlists with one tap'; + + @override + String get tutorialLibraryTitle => 'Your Library'; + + @override + String get tutorialLibraryDesc => + 'All your downloaded music is organized in the Library tab.'; + + @override + String get tutorialLibraryTip1 => + 'View download progress and queue in the Library tab'; + + @override + String get tutorialLibraryTip2 => + 'Tap any track to play it with your music player'; + + @override + String get tutorialLibraryTip3 => + 'Switch between list and grid view for better browsing'; + + @override + String get tutorialExtensionsTitle => 'Extensions'; + + @override + String get tutorialExtensionsDesc => + 'Extend the app\'s capabilities with community extensions.'; + + @override + String get tutorialExtensionsTip1 => + 'Browse the Store tab to discover useful extensions'; + + @override + String get tutorialExtensionsTip2 => + 'Add new download providers or search sources'; + + @override + String get tutorialExtensionsTip3 => + 'Get lyrics, enhanced metadata, and more features'; + + @override + String get tutorialSettingsTitle => 'Customize Your Experience'; + + @override + String get tutorialSettingsDesc => + 'Personalize the app in Settings to match your preferences.'; + + @override + String get tutorialSettingsTip1 => + 'Change download location and folder organization'; + + @override + String get tutorialSettingsTip2 => + 'Set default audio quality and format preferences'; + + @override + String get tutorialSettingsTip3 => 'Customize app theme and appearance'; + + @override + String get tutorialReadyMessage => + 'You\'re all set! Start downloading your favorite music now.'; + + @override + String get tutorialExample => 'EXAMPLE'; } /// The translations for Chinese, as used in China (`zh_CN`). diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 8b318881..59af3004 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1847,5 +1847,139 @@ "placeholders": { "count": {"type": "int"} } - } + }, + + "storageSwitchTitle": "Switch Storage Mode", + "@storageSwitchTitle": {"description": "Dialog title when switching storage mode"}, + "storageSwitchToSafTitle": "Switch to SAF Storage?", + "@storageSwitchToSafTitle": {"description": "Dialog title when switching to SAF"}, + "storageSwitchToAppTitle": "Switch to App Storage?", + "@storageSwitchToAppTitle": {"description": "Dialog title when switching to app storage"}, + "storageSwitchToSafMessage": "Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.", + "@storageSwitchToSafMessage": {"description": "Explanation when switching to SAF"}, + "storageSwitchToAppMessage": "Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.", + "@storageSwitchToAppMessage": {"description": "Explanation when switching to app storage"}, + "storageSwitchExistingDownloads": "Existing Downloads", + "@storageSwitchExistingDownloads": {"description": "Section header for existing downloads info"}, + "storageSwitchExistingDownloadsInfo": "{count} tracks in {mode} storage", + "@storageSwitchExistingDownloadsInfo": { + "description": "Info about existing downloads count", + "placeholders": { + "count": {"type": "int"}, + "mode": {"type": "String"} + } + }, + "storageSwitchNewDownloads": "New Downloads", + "@storageSwitchNewDownloads": {"description": "Section header for new downloads info"}, + "storageSwitchNewDownloadsLocation": "Will be saved to: {location}", + "@storageSwitchNewDownloadsLocation": { + "description": "Shows where new downloads will go", + "placeholders": { + "location": {"type": "String"} + } + }, + "storageSwitchContinue": "Continue", + "@storageSwitchContinue": {"description": "Button to proceed with storage switch"}, + "storageSwitchSelectFolder": "Select SAF Folder", + "@storageSwitchSelectFolder": {"description": "Button to select SAF folder"}, + "storageAppStorage": "App Storage", + "@storageAppStorage": {"description": "Label for app storage mode"}, + "storageSafStorage": "SAF Storage", + "@storageSafStorage": {"description": "Label for SAF storage mode"}, + "storageModeBadge": "Storage: {mode}", + "@storageModeBadge": { + "description": "Badge showing storage mode for a track", + "placeholders": { + "mode": {"type": "String"} + } + }, + "storageStatsTitle": "Storage Statistics", + "@storageStatsTitle": {"description": "Section title for storage stats"}, + "storageStatsAppCount": "{count} tracks in App Storage", + "@storageStatsAppCount": { + "description": "Count of tracks in app storage", + "placeholders": { + "count": {"type": "int"} + } + }, + "storageStatsSafCount": "{count} tracks in SAF Storage", + "@storageStatsSafCount": { + "description": "Count of tracks in SAF storage", + "placeholders": { + "count": {"type": "int"} + } + }, + "storageModeInfo": "Your files are stored in multiple locations", + "@storageModeInfo": {"description": "Info when user has files in both storage modes"}, + + "tutorialWelcomeTitle": "Welcome to SpotiFLAC!", + "@tutorialWelcomeTitle": {"description": "Tutorial welcome page title"}, + "tutorialWelcomeDesc": "Let's learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.", + "@tutorialWelcomeDesc": {"description": "Tutorial welcome page description"}, + "tutorialWelcomeTip1": "Download music from Spotify, Deezer, or paste any supported URL", + "@tutorialWelcomeTip1": {"description": "Tutorial welcome tip 1"}, + "tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music", + "@tutorialWelcomeTip2": {"description": "Tutorial welcome tip 2"}, + "tutorialWelcomeTip3": "Automatic metadata, cover art, and lyrics embedding", + "@tutorialWelcomeTip3": {"description": "Tutorial welcome tip 3"}, + + "tutorialSearchTitle": "Finding Music", + "@tutorialSearchTitle": {"description": "Tutorial search page title"}, + "tutorialSearchDesc": "There are two easy ways to find music you want to download.", + "@tutorialSearchDesc": {"description": "Tutorial search page description"}, + "tutorialSearchTip1": "Paste a Spotify or Deezer URL directly in the search box", + "@tutorialSearchTip1": {"description": "Tutorial search tip 1"}, + "tutorialSearchTip2": "Or type the song name, artist, or album to search", + "@tutorialSearchTip2": {"description": "Tutorial search tip 2"}, + "tutorialSearchTip3": "Supports tracks, albums, playlists, and artist pages", + "@tutorialSearchTip3": {"description": "Tutorial search tip 3"}, + + "tutorialDownloadTitle": "Downloading Music", + "@tutorialDownloadTitle": {"description": "Tutorial download page title"}, + "tutorialDownloadDesc": "Downloading music is simple and fast. Here's how it works.", + "@tutorialDownloadDesc": {"description": "Tutorial download page description"}, + "tutorialDownloadTip1": "Tap the download button next to any track to start downloading", + "@tutorialDownloadTip1": {"description": "Tutorial download tip 1"}, + "tutorialDownloadTip2": "Choose your preferred quality (FLAC, Hi-Res, or MP3)", + "@tutorialDownloadTip2": {"description": "Tutorial download tip 2"}, + "tutorialDownloadTip3": "Download entire albums or playlists with one tap", + "@tutorialDownloadTip3": {"description": "Tutorial download tip 3"}, + + "tutorialLibraryTitle": "Your Library", + "@tutorialLibraryTitle": {"description": "Tutorial library page title"}, + "tutorialLibraryDesc": "All your downloaded music is organized in the Library tab.", + "@tutorialLibraryDesc": {"description": "Tutorial library page description"}, + "tutorialLibraryTip1": "View download progress and queue in the Library tab", + "@tutorialLibraryTip1": {"description": "Tutorial library tip 1"}, + "tutorialLibraryTip2": "Tap any track to play it with your music player", + "@tutorialLibraryTip2": {"description": "Tutorial library tip 2"}, + "tutorialLibraryTip3": "Switch between list and grid view for better browsing", + "@tutorialLibraryTip3": {"description": "Tutorial library tip 3"}, + + "tutorialExtensionsTitle": "Extensions", + "@tutorialExtensionsTitle": {"description": "Tutorial extensions page title"}, + "tutorialExtensionsDesc": "Extend the app's capabilities with community extensions.", + "@tutorialExtensionsDesc": {"description": "Tutorial extensions page description"}, + "tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions", + "@tutorialExtensionsTip1": {"description": "Tutorial extensions tip 1"}, + "tutorialExtensionsTip2": "Add new download providers or search sources", + "@tutorialExtensionsTip2": {"description": "Tutorial extensions tip 2"}, + "tutorialExtensionsTip3": "Get lyrics, enhanced metadata, and more features", + "@tutorialExtensionsTip3": {"description": "Tutorial extensions tip 3"}, + + "tutorialSettingsTitle": "Customize Your Experience", + "@tutorialSettingsTitle": {"description": "Tutorial settings page title"}, + "tutorialSettingsDesc": "Personalize the app in Settings to match your preferences.", + "@tutorialSettingsDesc": {"description": "Tutorial settings page description"}, + "tutorialSettingsTip1": "Change download location and folder organization", + "@tutorialSettingsTip1": {"description": "Tutorial settings tip 1"}, + "tutorialSettingsTip2": "Set default audio quality and format preferences", + "@tutorialSettingsTip2": {"description": "Tutorial settings tip 2"}, + "tutorialSettingsTip3": "Customize app theme and appearance", + "@tutorialSettingsTip3": {"description": "Tutorial settings tip 3"}, + + "tutorialReadyMessage": "You're all set! Start downloading your favorite music now.", + "@tutorialReadyMessage": {"description": "Tutorial completion message"}, + "tutorialExample": "EXAMPLE", + "@tutorialExample": {"description": "Example label in tutorial"} } diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 7ad048b7..09f70f3d 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -2864,5 +2864,140 @@ "discographyFailedToFetch": "Failed to fetch some albums", "@discographyFailedToFetch": { "description": "Error - some albums failed to load" + }, + + "tutorialWelcomeTitle": "Selamat Datang di SpotiFLAC!", + "@tutorialWelcomeTitle": { + "description": "Tutorial welcome page title" + }, + "tutorialWelcomeDesc": "Mari pelajari cara mengunduh musik favorit Anda dalam kualitas lossless. Tutorial singkat ini akan menunjukkan dasar-dasarnya.", + "@tutorialWelcomeDesc": { + "description": "Tutorial welcome page description" + }, + "tutorialWelcomeTip1": "Unduh musik dari Spotify, Deezer, atau tempel URL yang didukung", + "@tutorialWelcomeTip1": { + "description": "Tutorial welcome tip 1" + }, + "tutorialWelcomeTip2": "Dapatkan audio kualitas FLAC dari Tidal, Qobuz, atau Amazon Music", + "@tutorialWelcomeTip2": { + "description": "Tutorial welcome tip 2" + }, + "tutorialWelcomeTip3": "Metadata, cover art, dan lirik otomatis tertanam", + "@tutorialWelcomeTip3": { + "description": "Tutorial welcome tip 3" + }, + + "tutorialSearchTitle": "Mencari Musik", + "@tutorialSearchTitle": { + "description": "Tutorial search page title" + }, + "tutorialSearchDesc": "Ada dua cara mudah untuk menemukan musik yang ingin Anda unduh.", + "@tutorialSearchDesc": { + "description": "Tutorial search page description" + }, + "tutorialSearchTip1": "Tempel URL Spotify atau Deezer langsung di kotak pencarian", + "@tutorialSearchTip1": { + "description": "Tutorial search tip 1" + }, + "tutorialSearchTip2": "Atau ketik nama lagu, artis, atau album untuk mencari", + "@tutorialSearchTip2": { + "description": "Tutorial search tip 2" + }, + "tutorialSearchTip3": "Mendukung lagu, album, playlist, dan halaman artis", + "@tutorialSearchTip3": { + "description": "Tutorial search tip 3" + }, + + "tutorialDownloadTitle": "Mengunduh Musik", + "@tutorialDownloadTitle": { + "description": "Tutorial download page title" + }, + "tutorialDownloadDesc": "Mengunduh musik itu mudah dan cepat. Begini caranya.", + "@tutorialDownloadDesc": { + "description": "Tutorial download page description" + }, + "tutorialDownloadTip1": "Ketuk tombol unduh di samping lagu mana pun untuk mulai mengunduh", + "@tutorialDownloadTip1": { + "description": "Tutorial download tip 1" + }, + "tutorialDownloadTip2": "Pilih kualitas yang Anda inginkan (FLAC, Hi-Res, atau MP3)", + "@tutorialDownloadTip2": { + "description": "Tutorial download tip 2" + }, + "tutorialDownloadTip3": "Unduh seluruh album atau playlist dengan satu ketukan", + "@tutorialDownloadTip3": { + "description": "Tutorial download tip 3" + }, + + "tutorialLibraryTitle": "Perpustakaan Anda", + "@tutorialLibraryTitle": { + "description": "Tutorial library page title" + }, + "tutorialLibraryDesc": "Semua musik yang Anda unduh terorganisir di tab Perpustakaan.", + "@tutorialLibraryDesc": { + "description": "Tutorial library page description" + }, + "tutorialLibraryTip1": "Lihat progres unduhan dan antrian di tab Perpustakaan", + "@tutorialLibraryTip1": { + "description": "Tutorial library tip 1" + }, + "tutorialLibraryTip2": "Ketuk lagu mana pun untuk memutarnya dengan pemutar musik", + "@tutorialLibraryTip2": { + "description": "Tutorial library tip 2" + }, + "tutorialLibraryTip3": "Beralih antara tampilan daftar dan grid untuk penjelajahan lebih baik", + "@tutorialLibraryTip3": { + "description": "Tutorial library tip 3" + }, + + "tutorialExtensionsTitle": "Ekstensi", + "@tutorialExtensionsTitle": { + "description": "Tutorial extensions page title" + }, + "tutorialExtensionsDesc": "Tingkatkan kemampuan aplikasi dengan ekstensi komunitas.", + "@tutorialExtensionsDesc": { + "description": "Tutorial extensions page description" + }, + "tutorialExtensionsTip1": "Jelajahi tab Toko untuk menemukan ekstensi berguna", + "@tutorialExtensionsTip1": { + "description": "Tutorial extensions tip 1" + }, + "tutorialExtensionsTip2": "Tambahkan provider unduhan atau sumber pencarian baru", + "@tutorialExtensionsTip2": { + "description": "Tutorial extensions tip 2" + }, + "tutorialExtensionsTip3": "Dapatkan lirik, metadata lebih baik, dan fitur lainnya", + "@tutorialExtensionsTip3": { + "description": "Tutorial extensions tip 3" + }, + + "tutorialSettingsTitle": "Sesuaikan Pengalaman Anda", + "@tutorialSettingsTitle": { + "description": "Tutorial settings page title" + }, + "tutorialSettingsDesc": "Personalisasi aplikasi di Pengaturan sesuai preferensi Anda.", + "@tutorialSettingsDesc": { + "description": "Tutorial settings page description" + }, + "tutorialSettingsTip1": "Ubah lokasi unduhan dan organisasi folder", + "@tutorialSettingsTip1": { + "description": "Tutorial settings tip 1" + }, + "tutorialSettingsTip2": "Atur kualitas audio dan preferensi format default", + "@tutorialSettingsTip2": { + "description": "Tutorial settings tip 2" + }, + "tutorialSettingsTip3": "Sesuaikan tema dan tampilan aplikasi", + "@tutorialSettingsTip3": { + "description": "Tutorial settings tip 3" + }, + + "tutorialReadyMessage": "Anda siap! Mulai unduh musik favorit Anda sekarang.", + "@tutorialReadyMessage": { + "description": "Tutorial completion message" + }, + "tutorialExample": "CONTOH", + "@tutorialExample": { + "description": "Example label in tutorial" } } \ No newline at end of file diff --git a/lib/models/settings.dart b/lib/models/settings.dart index e8f8fcff..45aa96dc 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -43,6 +43,9 @@ class AppSettings { final bool localLibraryEnabled; // Enable local library scanning final String localLibraryPath; // Path to scan for audio files final bool localLibraryShowDuplicates; // Show indicator when searching for existing tracks + + // Tutorial/Onboarding + final bool hasCompletedTutorial; // Track if user has completed the app tutorial const AppSettings({ this.defaultService = 'tidal', @@ -83,6 +86,8 @@ class AppSettings { this.localLibraryEnabled = false, this.localLibraryPath = '', this.localLibraryShowDuplicates = true, + // Tutorial default + this.hasCompletedTutorial = false, }); AppSettings copyWith({ @@ -125,6 +130,8 @@ class AppSettings { bool? localLibraryEnabled, String? localLibraryPath, bool? localLibraryShowDuplicates, + // Tutorial + bool? hasCompletedTutorial, }) { return AppSettings( defaultService: defaultService ?? this.defaultService, @@ -165,6 +172,8 @@ class AppSettings { localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled, localLibraryPath: localLibraryPath ?? this.localLibraryPath, localLibraryShowDuplicates: localLibraryShowDuplicates ?? this.localLibraryShowDuplicates, + // Tutorial + hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial, ); } diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 95f0b162..648104cf 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -48,6 +48,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( localLibraryPath: json['localLibraryPath'] as String? ?? '', localLibraryShowDuplicates: json['localLibraryShowDuplicates'] as bool? ?? true, + hasCompletedTutorial: json['hasCompletedTutorial'] as bool? ?? false, ); Map _$AppSettingsToJson(AppSettings instance) => @@ -89,4 +90,5 @@ Map _$AppSettingsToJson(AppSettings instance) => 'localLibraryEnabled': instance.localLibraryEnabled, 'localLibraryPath': instance.localLibraryPath, 'localLibraryShowDuplicates': instance.localLibraryShowDuplicates, + 'hasCompletedTutorial': instance.hasCompletedTutorial, }; diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index 680d65d0..e8fdad00 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -16,6 +16,7 @@ class LocalLibraryState { final double scanProgress; final String? scanCurrentFile; final int scanTotalFiles; + final int scannedFiles; final int scanErrorCount; final bool scanWasCancelled; final DateTime? lastScannedAt; @@ -29,6 +30,7 @@ class LocalLibraryState { this.scanProgress = 0, this.scanCurrentFile, this.scanTotalFiles = 0, + this.scannedFiles = 0, this.scanErrorCount = 0, this.scanWasCancelled = false, this.lastScannedAt, @@ -73,6 +75,7 @@ class LocalLibraryState { double? scanProgress, String? scanCurrentFile, int? scanTotalFiles, + int? scannedFiles, int? scanErrorCount, bool? scanWasCancelled, DateTime? lastScannedAt, @@ -83,6 +86,7 @@ class LocalLibraryState { scanProgress: scanProgress ?? this.scanProgress, scanCurrentFile: scanCurrentFile ?? this.scanCurrentFile, scanTotalFiles: scanTotalFiles ?? this.scanTotalFiles, + scannedFiles: scannedFiles ?? this.scannedFiles, scanErrorCount: scanErrorCount ?? this.scanErrorCount, scanWasCancelled: scanWasCancelled ?? this.scanWasCancelled, lastScannedAt: lastScannedAt ?? this.lastScannedAt, @@ -154,6 +158,7 @@ class LocalLibraryNotifier extends Notifier { scanProgress: 0, scanCurrentFile: null, scanTotalFiles: 0, + scannedFiles: 0, scanErrorCount: 0, scanWasCancelled: false, ); @@ -223,6 +228,7 @@ class LocalLibraryNotifier extends Notifier { scanProgress: (progress['progress_pct'] as num?)?.toDouble() ?? 0, scanCurrentFile: progress['current_file'] as String?, scanTotalFiles: progress['total_files'] as int? ?? 0, + scannedFiles: progress['scanned_files'] as int? ?? 0, scanErrorCount: progress['error_count'] as int? ?? 0, ); diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index dc9a257f..b00fb6df 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -325,6 +325,11 @@ void setUseAllFilesAccess(bool enabled) { state = state.copyWith(localLibraryShowDuplicates: show); _saveSettings(); } + + void setTutorialComplete() { + state = state.copyWith(hasCompletedTutorial: true); + _saveSettings(); + } } final settingsProvider = NotifierProvider( diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 3bf15914..b62fc783 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -50,6 +50,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient String? _lastSearchQuery; late final ProviderSubscription _trackStateSub; late final ProviderSubscription _extensionInitSub; + late final ProviderSubscription _homeFeedExtSub; Timer? _liveSearchDebounce; bool _isLiveSearchInProgress = false; @@ -88,6 +89,20 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } }, ); + + // Watch for new homeFeed extension being installed/enabled after init + _homeFeedExtSub = ref.listenManual( + extensionProvider.select((s) => s.extensions.any((e) => e.enabled && e.hasHomeFeed)), + (previous, next) { + if (next == true && previous != true) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + ref.read(exploreProvider.notifier).fetchHomeFeed(forceRefresh: true); + } + }); + } + }, + ); } void _fetchExploreIfNeeded() { @@ -106,6 +121,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient _liveSearchDebounce?.cancel(); _trackStateSub.close(); _extensionInitSub.close(); + _homeFeedExtSub.close(); _urlController.removeListener(_onSearchChanged); _searchFocusNode.removeListener(_onSearchFocusChanged); _urlController.dispose(); diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 06e28773..e0112cc6 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -1,7 +1,10 @@ import 'dart:async'; +import 'dart:io'; +import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; @@ -12,6 +15,7 @@ import 'package:spotiflac_android/screens/home_tab.dart'; import 'package:spotiflac_android/screens/store_tab.dart'; import 'package:spotiflac_android/screens/queue_tab.dart'; import 'package:spotiflac_android/screens/settings/settings_tab.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/share_intent_service.dart'; import 'package:spotiflac_android/services/update_checker.dart'; import 'package:spotiflac_android/widgets/update_dialog.dart'; @@ -40,6 +44,7 @@ class _MainShellState extends ConsumerState { WidgetsBinding.instance.addPostFrameCallback((_) { _checkForUpdates(); _setupShareListener(); + _checkSafMigration(); }); } @@ -115,6 +120,88 @@ class _MainShellState extends ConsumerState { } } + static const _safMigrationShownKey = 'saf_migration_prompt_shown'; + + Future _checkSafMigration() async { + if (!Platform.isAndroid) return; + + final settings = ref.read(settingsProvider); + // Only show if user is still on legacy storage mode with a download dir set + if (settings.storageMode == 'saf') return; + if (settings.downloadDirectory.isEmpty) return; + + // Check Android version + final deviceInfo = DeviceInfoPlugin(); + final androidInfo = await deviceInfo.androidInfo; + if (androidInfo.version.sdkInt < 29) return; + + // Only show once + final prefs = await SharedPreferences.getInstance(); + if (prefs.getBool(_safMigrationShownKey) == true) return; + await prefs.setBool(_safMigrationShownKey, true); + + if (!mounted) return; + + final colorScheme = Theme.of(context).colorScheme; + + showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => AlertDialog( + icon: Icon( + Icons.folder_special_outlined, + size: 32, + color: colorScheme.primary, + ), + title: const Text('Storage Update Required'), + content: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. ' + 'This fixes "permission denied" errors on Android 10+.', + ), + SizedBox(height: 12), + Text( + 'Please select your download folder again to switch to the new storage system.', + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Later'), + ), + FilledButton( + onPressed: () async { + Navigator.pop(ctx); + final result = await PlatformBridge.pickSafTree(); + if (result != null) { + final treeUri = result['tree_uri'] as String? ?? ''; + final displayName = result['display_name'] as String? ?? ''; + if (treeUri.isNotEmpty) { + ref.read(settingsProvider.notifier).setDownloadTreeUri( + treeUri, + displayName: displayName.isNotEmpty ? displayName : treeUri, + ); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Download folder updated to SAF mode'), + ), + ); + } + } + } + }, + child: const Text('Select Folder'), + ), + ], + ), + ); + } + @override void dispose() { _shareSubscription?.cancel(); diff --git a/lib/screens/settings/about_page.dart b/lib/screens/settings/about_page.dart index 8ca2b2db..751b8537 100644 --- a/lib/screens/settings/about_page.dart +++ b/lib/screens/settings/about_page.dart @@ -191,23 +191,6 @@ _AboutSettingsItem( ), ), - SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.aboutSupport), - ), - SliverToBoxAdapter( - child: SettingsGroup( - children: [ - _AboutSettingsItem( - icon: Icons.coffee_outlined, - title: context.l10n.aboutBuyMeCoffee, - subtitle: context.l10n.aboutBuyMeCoffeeSubtitle, - onTap: () => _launchUrl(AppInfo.kofiUrl), - showDivider: false, - ), - ], - ), - ), - SliverToBoxAdapter( child: SettingsSectionHeader(title: context.l10n.aboutApp), ), diff --git a/lib/screens/settings/donate_page.dart b/lib/screens/settings/donate_page.dart new file mode 100644 index 00000000..9778be84 --- /dev/null +++ b/lib/screens/settings/donate_page.dart @@ -0,0 +1,382 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:spotiflac_android/constants/app_info.dart'; + +class DonatePage extends StatelessWidget { + const DonatePage({super.key}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final topPadding = MediaQuery.of(context).padding.top; + + return Scaffold( + body: CustomScrollView( + slivers: [ + SliverAppBar( + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), + flexibleSpace: LayoutBuilder( + builder: (context, constraints) { + final maxHeight = 120 + topPadding; + final minHeight = kToolbarHeight + topPadding; + final expandRatio = + ((constraints.maxHeight - minHeight) / + (maxHeight - minHeight)) + .clamp(0.0, 1.0); + final leftPadding = 56 - (32 * expandRatio); + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16), + title: Text( + 'Donate', + style: TextStyle( + fontSize: 20 + (8 * expandRatio), + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + // Header message + Card( + elevation: 0, + color: colorScheme.surfaceContainerLow, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(28), + ), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + Icon( + Icons.favorite_rounded, + size: 48, + color: colorScheme.primary, + ), + const SizedBox(height: 12), + Text( + 'Support SpotiFLAC-Mobile', + style: Theme.of(context).textTheme.titleLarge + ?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + const SizedBox(height: 8), + Text( + 'SpotiFLAC-Mobile is free and open source. ' + 'If you enjoy using it, consider supporting ' + 'the development.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ), + ), + + const SizedBox(height: 16), + + // Donate links card + _DonateLinksCard(colorScheme: colorScheme), + + const SizedBox(height: 24), + + // Recent donors section + _RecentDonorsCard(colorScheme: colorScheme), + + const SizedBox(height: 12), + + // Notice + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.info_outline, + size: 14, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + 'Your name will remain in this version permanently as a token of appreciation. ' + 'The supporter list is updated manually each month and embedded directly in the app ' + '-- no remote server is used. Even if your support period ends, your name stays in ' + 'every version it was included in.', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _RecentDonorsCard extends StatelessWidget { + final ColorScheme colorScheme; + + const _RecentDonorsCard({required this.colorScheme}); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + // Match SettingsGroup color logic + final cardColor = isDark + ? Color.alphaBlend( + Colors.white.withValues(alpha: 0.08), + colorScheme.surface, + ) + : Color.alphaBlend( + Colors.black.withValues(alpha: 0.04), + colorScheme.surface, + ); + + return Card( + elevation: 0, + color: cardColor, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.star_rounded, size: 20, color: colorScheme.primary), + const SizedBox(width: 8), + Text( + 'Recent Supporters', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Thank you for your generosity!', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + _DonorTile(name: 'Daniel', colorScheme: colorScheme), + _DonorTile( + name: '283Fabio', + colorScheme: colorScheme, + showDivider: false, + ), + ], + ), + ), + ); + } +} + +class _DonateLinksCard extends StatelessWidget { + final ColorScheme colorScheme; + + const _DonateLinksCard({required this.colorScheme}); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final cardColor = isDark + ? Color.alphaBlend( + Colors.white.withValues(alpha: 0.08), + colorScheme.surface, + ) + : Color.alphaBlend( + Colors.black.withValues(alpha: 0.04), + colorScheme.surface, + ); + + return Card( + elevation: 0, + color: cardColor, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + _DonateCardItem( + title: 'Ko-fi', + subtitle: 'ko-fi.com/zarzet', + icon: Icons.coffee_rounded, + color: const Color(0xFFFF5E5B), + url: AppInfo.kofiUrl, + colorScheme: colorScheme, + ), + Divider( + height: 1, + thickness: 1, + indent: 74, + endIndent: 16, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + _DonateCardItem( + title: 'Buy Me a Coffee', + subtitle: 'buymeacoffee.com/zarzet', + icon: Icons.local_cafe_rounded, + color: const Color(0xFFFFDD00), + iconColor: Colors.black87, + url: AppInfo.bmacUrl, + colorScheme: colorScheme, + ), + ], + ), + ); + } +} + +class _DonateCardItem extends StatelessWidget { + final String title; + final String subtitle; + final IconData icon; + final Color color; + final Color? iconColor; + final String url; + final ColorScheme colorScheme; + + const _DonateCardItem({ + required this.title, + required this.subtitle, + required this.icon, + required this.color, + required this.url, + required this.colorScheme, + this.iconColor, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () => + launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, color: iconColor ?? Colors.white, size: 22), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Icon( + Icons.open_in_new, + size: 18, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ); + } +} + +class _DonorTile extends StatelessWidget { + final String name; + final ColorScheme colorScheme; + final bool showDivider; + + const _DonorTile({ + required this.name, + required this.colorScheme, + this.showDivider = true, + }); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row( + children: [ + CircleAvatar( + radius: 18, + backgroundColor: colorScheme.primaryContainer, + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: colorScheme.onPrimaryContainer, + ), + ), + ), + const SizedBox(width: 12), + Text( + name, + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface), + ), + ], + ), + ), + if (showDivider) + Divider( + height: 1, + thickness: 1, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + ], + ); + } +} diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index a749cd64..943db500 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -7,6 +7,7 @@ import 'package:device_info_plus/device_info_plus.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; class LibrarySettingsPage extends ConsumerStatefulWidget { @@ -21,6 +22,26 @@ class _LibrarySettingsPageState extends ConsumerState { int _androidSdkVersion = 0; bool _hasStoragePermission = false; + /// Convert SAF content URI to a readable display path + String _getDisplayPath(String path) { + if (!path.startsWith('content://')) return path; + // Extract the path portion from SAF tree URI + // e.g. content://com.android.externalstorage.documents/tree/primary%3AMusic + // -> /storage/emulated/0/Music + try { + final uri = Uri.parse(path); + final treePath = uri.pathSegments.last; // e.g. "primary:Music" or "primary%3AMusic" + final decoded = Uri.decodeComponent(treePath); + if (decoded.startsWith('primary:')) { + return '/storage/emulated/0/${decoded.substring('primary:'.length)}'; + } + // For SD card or other volumes, just show the decoded path + return decoded; + } catch (_) { + return path; + } + } + @override void initState() { super.initState(); @@ -33,19 +54,19 @@ class _LibrarySettingsPageState extends ConsumerState { final androidInfo = await deviceInfo.androidInfo; final sdkVersion = androidInfo.version.sdkInt; - // Check appropriate storage permission based on Android version - bool hasPermission; - if (sdkVersion >= 30) { - hasPermission = await Permission.manageExternalStorage.isGranted; - } else { - hasPermission = await Permission.storage.isGranted; - } - if (mounted) { setState(() { _androidSdkVersion = sdkVersion; - _hasStoragePermission = hasPermission; + // SAF doesn't need storage permission on Android 10+ + _hasStoragePermission = sdkVersion >= 29 ? true : false; }); + // For older Android, check legacy storage permission + if (sdkVersion < 29) { + final hasPermission = await Permission.storage.isGranted; + if (mounted) { + setState(() => _hasStoragePermission = hasPermission); + } + } } } else if (Platform.isIOS) { // iOS doesn't need explicit storage permission for app documents @@ -55,13 +76,10 @@ class _LibrarySettingsPageState extends ConsumerState { Future _requestStoragePermission() async { if (Platform.isIOS) return true; + // SAF on Android 10+ doesn't need MANAGE_EXTERNAL_STORAGE + if (_androidSdkVersion >= 29) return true; - PermissionStatus status; - if (_androidSdkVersion >= 30) { - status = await Permission.manageExternalStorage.request(); - } else { - status = await Permission.storage.request(); - } + final status = await Permission.storage.request(); if (status.isGranted) { setState(() => _hasStoragePermission = true); @@ -94,15 +112,26 @@ class _LibrarySettingsPageState extends ConsumerState { } Future _pickLibraryFolder() async { - // Request permission first - if (!_hasStoragePermission) { - final granted = await _requestStoragePermission(); - if (!granted) return; - } - - final result = await FilePicker.platform.getDirectoryPath(); - if (result != null) { - ref.read(settingsProvider.notifier).setLocalLibraryPath(result); + if (Platform.isAndroid && _androidSdkVersion >= 29) { + // Use SAF tree picker - no MANAGE_EXTERNAL_STORAGE needed + final result = await PlatformBridge.pickSafTree(); + if (result != null) { + final treeUri = result['tree_uri'] as String? ?? ''; + if (treeUri.isNotEmpty) { + ref.read(settingsProvider.notifier).setLocalLibraryPath(treeUri); + } + } + } else { + // Legacy: request permission and use file picker for older Android / iOS + if (!_hasStoragePermission) { + final granted = await _requestStoragePermission(); + if (!granted) return; + } + // Fallback for older devices + final result = await FilePicker.platform.getDirectoryPath(); + if (result != null) { + ref.read(settingsProvider.notifier).setLocalLibraryPath(result); + } } } @@ -232,6 +261,7 @@ class _LibrarySettingsPageState extends ConsumerState { scanProgress: libraryState.scanProgress, scanCurrentFile: libraryState.scanCurrentFile, scanTotalFiles: libraryState.scanTotalFiles, + scannedFiles: libraryState.scannedFiles, lastScannedAt: libraryState.lastScannedAt, ), ), @@ -263,7 +293,7 @@ class _LibrarySettingsPageState extends ConsumerState { title: context.l10n.libraryFolder, subtitle: settings.localLibraryPath.isEmpty ? context.l10n.libraryFolderHint - : settings.localLibraryPath, + : _getDisplayPath(settings.localLibraryPath), onTap: settings.localLibraryEnabled ? _pickLibraryFolder : null, @@ -452,6 +482,7 @@ class _LibraryHeroCard extends StatelessWidget { final double scanProgress; final String? scanCurrentFile; final int scanTotalFiles; + final int scannedFiles; final DateTime? lastScannedAt; const _LibraryHeroCard({ @@ -460,6 +491,7 @@ class _LibraryHeroCard extends StatelessWidget { required this.scanProgress, this.scanCurrentFile, required this.scanTotalFiles, + required this.scannedFiles, this.lastScannedAt, }); @@ -584,7 +616,7 @@ class _LibraryHeroCard extends StatelessWidget { fit: BoxFit.scaleDown, alignment: Alignment.centerLeft, child: Text( - itemCount.toString(), + isScanning ? scannedFiles.toString() : itemCount.toString(), style: TextStyle( fontSize: 48, fontWeight: FontWeight.bold, @@ -596,10 +628,12 @@ class _LibraryHeroCard extends StatelessWidget { ), const SizedBox(height: 4), Text( - context.l10n - .libraryTracksCount(itemCount) - .replaceAll(itemCount.toString(), '') - .trim(), // Getting just the label part if possible, or just use the full string if not + isScanning + ? context.l10n.libraryTracksCount(scannedFiles).replaceAll(scannedFiles.toString(), '').trim() + : context.l10n + .libraryTracksCount(itemCount) + .replaceAll(itemCount.toString(), '') + .trim(), style: TextStyle( fontSize: 16, color: colorScheme.onSurfaceVariant, diff --git a/lib/screens/settings/settings_tab.dart b/lib/screens/settings/settings_tab.dart index 27e39e60..b7d21c55 100644 --- a/lib/screens/settings/settings_tab.dart +++ b/lib/screens/settings/settings_tab.dart @@ -8,6 +8,7 @@ import 'package:spotiflac_android/screens/settings/extensions_page.dart'; import 'package:spotiflac_android/screens/settings/library_settings_page.dart'; import 'package:spotiflac_android/screens/settings/options_settings_page.dart'; import 'package:spotiflac_android/screens/settings/about_page.dart'; +import 'package:spotiflac_android/screens/settings/donate_page.dart'; import 'package:spotiflac_android/screens/settings/log_screen.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; @@ -91,6 +92,12 @@ class SettingsTab extends ConsumerWidget { title: l10n.settingsExtensions, subtitle: l10n.settingsExtensionsSubtitle, onTap: () => _navigateTo(context, const ExtensionsPage()), + ), + SettingsItem( + icon: Icons.favorite_outline, + title: 'Donate', + subtitle: 'Support SpotiFLAC-Mobile development', + onTap: () => _navigateTo(context, const DonatePage()), showDivider: false, ), ], diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index 25958832..88f8f141 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -1,5 +1,4 @@ import 'dart:io'; -import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -19,20 +18,25 @@ class SetupScreen extends ConsumerStatefulWidget { } class _SetupScreenState extends ConsumerState { + final PageController _pageController = PageController(); int _currentStep = 0; + + // State variables bool _storagePermissionGranted = false; bool _notificationPermissionGranted = false; String? _selectedDirectory; String? _selectedTreeUri; bool _isLoading = false; int _androidSdkVersion = 0; - + + // Spotify form final _clientIdController = TextEditingController(); final _clientSecretController = TextEditingController(); bool _useSpotifyApi = false; bool _showClientSecret = false; - int get _totalSteps => _androidSdkVersion >= 33 ? 4 : 3; + // We add 1 for the Welcome step + int get _totalSteps => (_androidSdkVersion >= 33 ? 4 : 3) + 1; @override void initState() { @@ -42,6 +46,7 @@ class _SetupScreenState extends ConsumerState { @override void dispose() { + _pageController.dispose(); _clientIdController.dispose(); _clientSecretController.dispose(); super.dispose(); @@ -51,9 +56,12 @@ class _SetupScreenState extends ConsumerState { if (Platform.isAndroid) { final deviceInfo = DeviceInfoPlugin(); final androidInfo = await deviceInfo.androidInfo; - _androidSdkVersion = androidInfo.version.sdkInt; - debugPrint('Android SDK Version: $_androidSdkVersion'); + if (!mounted) return; + setState(() { + _androidSdkVersion = androidInfo.version.sdkInt; + }); } + if (!mounted) return; await _checkInitialPermissions(); } @@ -67,31 +75,23 @@ class _SetupScreenState extends ConsumerState { } } else if (Platform.isAndroid) { bool storageGranted = false; - + if (_androidSdkVersion >= 33) { - // Android 13+: Only require READ_MEDIA_AUDIO by default - // MANAGE_EXTERNAL_STORAGE is optional and can be enabled in settings final audioStatus = await Permission.audio.status; - debugPrint('[Permission] Android 13+ check: READ_MEDIA_AUDIO=$audioStatus'); storageGranted = audioStatus.isGranted; } else if (_androidSdkVersion >= 30) { final manageStatus = await Permission.manageExternalStorage.status; - debugPrint('[Permission] Android 11-12 check: MANAGE_EXTERNAL_STORAGE=$manageStatus'); storageGranted = manageStatus.isGranted; } else { final storageStatus = await Permission.storage.status; - debugPrint('[Permission] Android 10- check: STORAGE=$storageStatus'); storageGranted = storageStatus.isGranted; } - - debugPrint('[Permission] Final storageGranted=$storageGranted'); - + PermissionStatus notificationStatus = PermissionStatus.granted; if (_androidSdkVersion >= 33) { notificationStatus = await Permission.notification.status; - debugPrint('[Permission] Notification=$notificationStatus'); } - + if (mounted) { setState(() { _storagePermissionGranted = storageGranted; @@ -103,121 +103,100 @@ class _SetupScreenState extends ConsumerState { Future _requestStoragePermission() async { setState(() => _isLoading = true); - try { if (Platform.isIOS) { setState(() => _storagePermissionGranted = true); } else if (Platform.isAndroid) { bool allGranted = false; - + if (_androidSdkVersion >= 33) { - // Android 13+: Only request READ_MEDIA_AUDIO by default - // MANAGE_EXTERNAL_STORAGE is optional (can be enabled in Settings) var audioStatus = await Permission.audio.status; if (!audioStatus.isGranted) { audioStatus = await Permission.audio.request(); } - allGranted = audioStatus.isGranted; - + if (audioStatus.isPermanentlyDenied) { - _showPermissionDeniedDialog('Audio'); - setState(() => _isLoading = false); + await _showPermissionDeniedDialog('Audio'); return; } - } else if (_androidSdkVersion >= 30) { var manageStatus = await Permission.manageExternalStorage.status; if (!manageStatus.isGranted) { - if (mounted) { - final shouldOpen = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(context.l10n.setupStorageAccessRequired), - content: Text( - '${context.l10n.setupStorageAccessMessageAndroid11}\n\n' - '${context.l10n.setupAllowAccessToManageFiles}', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: Text(context.l10n.dialogCancel), - ), - FilledButton( - onPressed: () => Navigator.pop(context, true), - child: Text(context.l10n.setupOpenSettings), - ), - ], - ), - ); - - if (shouldOpen == true) { - await Permission.manageExternalStorage.request(); - await Future.delayed(const Duration(milliseconds: 500)); - manageStatus = await Permission.manageExternalStorage.status; - } + final shouldOpen = await _showAndroid11StorageDialog(); + if (shouldOpen == true) { + await Permission.manageExternalStorage.request(); + await Future.delayed(const Duration(milliseconds: 500)); + manageStatus = await Permission.manageExternalStorage.status; } } allGranted = manageStatus.isGranted; - } else { final status = await Permission.storage.request(); allGranted = status.isGranted; - if (status.isPermanentlyDenied) { - _showPermissionDeniedDialog('Storage'); - setState(() => _isLoading = false); + await _showPermissionDeniedDialog('Storage'); return; } } - - if (allGranted) { - setState(() => _storagePermissionGranted = true); - } else { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.setupPermissionDeniedMessage)), - ); - } + + setState(() => _storagePermissionGranted = allGranted); + if (!allGranted && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.setupPermissionDeniedMessage)), + ); } } } catch (e) { debugPrint('Permission error: $e'); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e'))); - } } finally { setState(() => _isLoading = false); } } + Future _showAndroid11StorageDialog() { + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(context.l10n.setupStorageAccessRequired), + content: Text( + '${context.l10n.setupStorageAccessMessageAndroid11}\n\n' + '${context.l10n.setupAllowAccessToManageFiles}', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(context.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(context.l10n.setupOpenSettings), + ), + ], + ), + ); + } + Future _requestNotificationPermission() async { setState(() => _isLoading = true); - try { if (_androidSdkVersion >= 33) { final status = await Permission.notification.request(); if (status.isGranted) { setState(() => _notificationPermissionGranted = true); } else if (status.isPermanentlyDenied) { - _showPermissionDeniedDialog('Notification'); + await _showPermissionDeniedDialog('Notification'); } } else { setState(() => _notificationPermissionGranted = true); } - } catch (e) { - debugPrint('Notification permission error: $e'); } finally { setState(() => _isLoading = false); } } - void _skipNotificationPermission() { - setState(() => _notificationPermissionGranted = true); - } - - void _showPermissionDeniedDialog(String permissionType) { - showDialog( + Future _showPermissionDeniedDialog(String permissionType) async { + await showDialog( context: context, builder: (context) => AlertDialog( title: Text(context.l10n.setupPermissionRequired(permissionType)), @@ -243,7 +222,6 @@ class _SetupScreenState extends ConsumerState { Future _selectDirectory() async { setState(() => _isLoading = true); - try { if (Platform.isIOS) { await _showIOSDirectoryOptions(); @@ -255,11 +233,14 @@ class _SetupScreenState extends ConsumerState { if (treeUri.isNotEmpty) { setState(() { _selectedTreeUri = treeUri; - _selectedDirectory = displayName.isNotEmpty ? displayName : treeUri; + _selectedDirectory = displayName.isNotEmpty + ? displayName + : treeUri; }); } } + // Android fallback if user cancelled SAF picker if (_selectedTreeUri == null || _selectedTreeUri!.isEmpty) { final defaultDir = await _getDefaultDirectory(); if (mounted) { @@ -269,12 +250,17 @@ class _SetupScreenState extends ConsumerState { title: Text(context.l10n.setupUseDefaultFolder), content: Text('${context.l10n.setupNoFolderSelected}\n\n$defaultDir'), actions: [ - TextButton(onPressed: () => Navigator.pop(context, false), child: Text(context.l10n.dialogCancel)), - TextButton(onPressed: () => Navigator.pop(context, true), child: Text(context.l10n.setupUseDefault)), + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(context.l10n.dialogCancel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(context.l10n.setupUseDefault), + ), ], ), ); - if (useDefault == true) { setState(() { _selectedTreeUri = ''; @@ -291,11 +277,12 @@ class _SetupScreenState extends ConsumerState { Future _showIOSDirectoryOptions() async { final colorScheme = Theme.of(context).colorScheme; - await showModalBottomSheet( context: context, backgroundColor: colorScheme.surfaceContainerHigh, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), builder: (ctx) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, @@ -303,20 +290,23 @@ class _SetupScreenState extends ConsumerState { children: [ Padding( padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), - child: Text(context.l10n.setupDownloadLocationTitle, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), + child: Text( + context.l10n.setupDownloadLocationTitle, + style: Theme.of(context).textTheme.titleLarge, + ), ), Padding( padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), child: Text( context.l10n.setupDownloadLocationIosMessage, - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), ), ListTile( leading: Icon(Icons.folder_special, color: colorScheme.primary), title: Text(context.l10n.setupAppDocumentsFolder), - subtitle: Text(context.l10n.setupAppDocumentsFolderSubtitle), - trailing: Icon(Icons.check_circle, color: colorScheme.primary), onTap: () async { final dir = await _getDefaultDirectory(); setState(() => _selectedDirectory = dir); @@ -326,7 +316,6 @@ class _SetupScreenState extends ConsumerState { ListTile( leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant), title: Text(context.l10n.setupChooseFromFiles), - subtitle: Text(context.l10n.setupChooseFromFilesSubtitle), onTap: () async { Navigator.pop(ctx); final result = await FilePicker.platform.getDirectoryPath(); @@ -335,29 +324,7 @@ class _SetupScreenState extends ConsumerState { } }, ), - Padding( - padding: const EdgeInsets.fromLTRB(24, 8, 24, 16), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: colorScheme.tertiaryContainer.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary), - const SizedBox(width: 12), - Expanded( - child: Text( - context.l10n.setupIosEmptyFolderWarning, - style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onTertiaryContainer), - ), - ), - ], - ), - ), - ), - const SizedBox(height: 8), + const SizedBox(height: 16), ], ), ), @@ -365,19 +332,7 @@ class _SetupScreenState extends ConsumerState { } Future _getDefaultDirectory() async { - if (Platform.isIOS) { - final appDir = await getApplicationDocumentsDirectory(); - final musicDir = Directory('${appDir.path}/SpotiFLAC'); - try { - if (!await musicDir.exists()) { - await musicDir.create(recursive: true); - } - return musicDir.path; - } catch (e) { - debugPrint('Cannot create SpotiFLAC folder: $e'); - } - return '${appDir.path}/SpotiFLAC'; - } else if (Platform.isAndroid) { + if (Platform.isAndroid) { final musicDir = Directory('/storage/emulated/0/Music/SpotiFLAC'); try { if (!await musicDir.exists()) { @@ -394,606 +349,557 @@ class _SetupScreenState extends ConsumerState { Future _completeSetup() async { if (_selectedDirectory == null) return; - setState(() => _isLoading = true); try { - if (!Platform.isAndroid || _selectedTreeUri == null || _selectedTreeUri!.isEmpty) { + if (!Platform.isAndroid || + _selectedTreeUri == null || + _selectedTreeUri!.isEmpty) { final dir = Directory(_selectedDirectory!); if (!await dir.exists()) { await dir.create(recursive: true); } ref.read(settingsProvider.notifier).setStorageMode('app'); - ref.read(settingsProvider.notifier).setDownloadDirectory(_selectedDirectory!); + ref + .read(settingsProvider.notifier) + .setDownloadDirectory(_selectedDirectory!); ref.read(settingsProvider.notifier).setDownloadTreeUri(''); } else { ref.read(settingsProvider.notifier).setStorageMode('saf'); - ref.read(settingsProvider.notifier).setDownloadTreeUri( - _selectedTreeUri!, - displayName: _selectedDirectory, - ); + ref + .read(settingsProvider.notifier) + .setDownloadTreeUri( + _selectedTreeUri!, + displayName: _selectedDirectory, + ); } - - if (_useSpotifyApi && - _clientIdController.text.trim().isNotEmpty && + + if (_useSpotifyApi && + _clientIdController.text.trim().isNotEmpty && _clientSecretController.text.trim().isNotEmpty) { - ref.read(settingsProvider.notifier).setSpotifyCredentials( - _clientIdController.text.trim(), - _clientSecretController.text.trim(), - ); + ref + .read(settingsProvider.notifier) + .setSpotifyCredentials( + _clientIdController.text.trim(), + _clientSecretController.text.trim(), + ); ref.read(settingsProvider.notifier).setMetadataSource('spotify'); } else { ref.read(settingsProvider.notifier).setMetadataSource('deezer'); } - + ref.read(settingsProvider.notifier).setFirstLaunchComplete(); - if (mounted) { - context.go('/'); - } + if (mounted) context.go('/tutorial'); } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e'))); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error: $e'))); } } finally { setState(() => _isLoading = false); } } - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return Scaffold( - body: SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24.0), - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: math.max(0, MediaQuery.of(context).size.height - - MediaQuery.of(context).padding.top - - MediaQuery.of(context).padding.bottom - 48), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - children: [ - const SizedBox(height: 24), - ClipRRect( - borderRadius: BorderRadius.circular(24), - child: Image.asset('assets/images/logo.png', width: 96, height: 96), - ), - const SizedBox(height: 12), - Text(context.l10n.appName, - style: Theme.of(context).textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.bold, color: colorScheme.primary)), - const SizedBox(height: 4), - Text(context.l10n.setupDownloadInFlac, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant)), - ], - ), + void _nextPage() { + bool canProceed = false; + // Step 0 is Welcome, always can proceed + if (_currentStep == 0) { + canProceed = true; + } else { + // Logic for other steps (offset by 1 because of welcome step) + // Step 1: Storage + // Step 2: Notification (if android 13+) OR Directory + // etc. + canProceed = _isStepCompleted(_currentStep); + } - Column( - children: [ - const SizedBox(height: 24), - _buildStepIndicator(colorScheme), - const SizedBox(height: 24), - _buildCurrentStepContent(colorScheme), - ], - ), - - Column( - children: [ - const SizedBox(height: 24), - _buildNavigationButtons(colorScheme), - const SizedBox(height: 16), - ], - ), - ], - ), - ), - ), - ), - ); + if (canProceed) { + _pageController.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + setState(() => _currentStep++); + } } - Widget _buildStepIndicator(ColorScheme colorScheme) { - final steps = _androidSdkVersion >= 33 - ? [context.l10n.setupStepStorage, context.l10n.setupStepNotification, context.l10n.setupStepFolder, context.l10n.setupStepSpotify] - : [context.l10n.setupStepPermission, context.l10n.setupStepFolder, context.l10n.setupStepSpotify]; - - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - for (int i = 0; i < steps.length; i++) ...[ - if (i > 0) - Padding( - padding: const EdgeInsets.only(bottom: 20), - child: Container( - width: 32, - height: 2, - color: _currentStep >= i ? colorScheme.primary : colorScheme.surfaceContainerHighest, - ), - ), - _buildStepDot(i, steps[i], colorScheme), - ], - ], - ); - } - - Widget _buildStepDot(int step, String label, ColorScheme colorScheme) { - final isActive = _currentStep >= step; - final isCompleted = _isStepCompleted(step); - final isCurrent = _currentStep == step; - - return Column( - children: [ - AnimatedContainer( - duration: const Duration(milliseconds: 200), - width: 36, - height: 36, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: isCompleted - ? colorScheme.primary - : isCurrent - ? colorScheme.primaryContainer - : colorScheme.surfaceContainerHighest, - border: isCurrent && !isCompleted - ? Border.all(color: colorScheme.primary, width: 2) - : null, - ), - child: Center( - child: isCompleted - ? Icon(Icons.check_rounded, size: 20, color: colorScheme.onPrimary) - : Text('${step + 1}', - style: TextStyle( - color: isCurrent ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - fontSize: 14, - )), - ), - ), - const SizedBox(height: 6), - Text(label, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: isActive ? colorScheme.onSurface : colorScheme.onSurfaceVariant, - fontWeight: isCurrent ? FontWeight.w600 : FontWeight.normal, - )), - ], + void _prevPage() { + _pageController.previousPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, ); + setState(() => _currentStep--); } bool _isStepCompleted(int step) { + if (step == 0) return true; // Welcome + + // Adjust step index for logic because we added Welcome at 0 + final logicStep = step - 1; + if (_androidSdkVersion >= 33) { - switch (step) { - case 0: return _storagePermissionGranted; - case 1: return _notificationPermissionGranted; - case 2: return _selectedDirectory != null; - case 3: return false; + switch (logicStep) { + case 0: + return _storagePermissionGranted; + case 1: + return _notificationPermissionGranted; + case 2: + return _selectedDirectory != null; + case 3: + return false; // Spotify is last/submit } } else { - switch (step) { - case 0: return _storagePermissionGranted; - case 1: return _selectedDirectory != null; - case 2: return false; + switch (logicStep) { + case 0: + return _storagePermissionGranted; + case 1: + return _selectedDirectory != null; + case 2: + return false; // Spotify } } return false; } - Widget _buildCurrentStepContent(ColorScheme colorScheme) { - if (_androidSdkVersion >= 33) { - switch (_currentStep) { - case 0: return _buildStoragePermissionStep(colorScheme); - case 1: return _buildNotificationPermissionStep(colorScheme); - case 2: return _buildDirectoryStep(colorScheme); - case 3: return _buildSpotifyApiStep(colorScheme); - } - } else { - switch (_currentStep) { - case 0: return _buildStoragePermissionStep(colorScheme); - case 1: return _buildDirectoryStep(colorScheme); - case 2: return _buildSpotifyApiStep(colorScheme); - } - } - return const SizedBox(); - } + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; - Widget _buildStoragePermissionStep(ColorScheme colorScheme) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 80, - height: 80, - decoration: BoxDecoration( - color: _storagePermissionGranted ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(24), - ), - child: Icon( - _storagePermissionGranted ? Icons.check_rounded : Icons.folder_open_rounded, - size: 40, - color: _storagePermissionGranted ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 20), - Text( - _storagePermissionGranted ? context.l10n.setupStorageGranted : context.l10n.setupStorageRequired, - style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - _storagePermissionGranted - ? context.l10n.setupProceedToNextStep - : context.l10n.setupStorageDescription, - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), - textAlign: TextAlign.center, - ), - ), - const SizedBox(height: 24), - if (!_storagePermissionGranted) - FilledButton.icon( - onPressed: _isLoading ? null : _requestStoragePermission, - icon: _isLoading - ? SizedBox(width: 20, height: 20, - child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary)) - : const Icon(Icons.security_rounded), - label: Text(context.l10n.setupGrantPermission), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - ), - ), - ], - ); - } + // Calculate progress + final progress = (_currentStep + 1) / _totalSteps; - Widget _buildNotificationPermissionStep(ColorScheme colorScheme) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 80, - height: 80, - decoration: BoxDecoration( - color: _notificationPermissionGranted ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(24), - ), - child: Icon( - _notificationPermissionGranted ? Icons.check_rounded : Icons.notifications_outlined, - size: 40, - color: _notificationPermissionGranted ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 20), - Text( - _notificationPermissionGranted ? context.l10n.setupNotificationGranted : context.l10n.setupNotificationEnable, - style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - _notificationPermissionGranted - ? context.l10n.setupNotificationProgressDescription - : context.l10n.setupNotificationBackgroundDescription, - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), - textAlign: TextAlign.center, - ), - ), - const SizedBox(height: 24), - if (!_notificationPermissionGranted) ...[ - FilledButton.icon( - onPressed: _isLoading ? null : _requestNotificationPermission, - icon: _isLoading - ? SizedBox(width: 20, height: 20, - child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary)) - : const Icon(Icons.notifications_active_rounded), - label: Text(context.l10n.setupEnableNotifications), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - ), - ), - const SizedBox(height: 12), - TextButton( - onPressed: _skipNotificationPermission, - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - ), - child: Text(context.l10n.setupSkipForNow), - ), - ], - ], - ); - } - - Widget _buildDirectoryStep(ColorScheme colorScheme) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 80, - height: 80, - decoration: BoxDecoration( - color: _selectedDirectory != null ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(24), - ), - child: Icon( - _selectedDirectory != null ? Icons.folder_rounded : Icons.create_new_folder_rounded, - size: 40, - color: _selectedDirectory != null ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 20), - Text( - _selectedDirectory != null ? context.l10n.setupFolderSelected : context.l10n.setupFolderChoose, - style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - if (_selectedDirectory != null) - Card( - elevation: 0, - color: colorScheme.surfaceContainerHigh, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + return Scaffold( + backgroundColor: colorScheme.surface, + body: SafeArea( + child: Column( + children: [ + // Top Bar + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), child: Row( - mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.folder_rounded, color: colorScheme.primary, size: 20), - const SizedBox(width: 12), - Flexible( - child: Text( - _selectedDirectory!, - style: Theme.of(context).textTheme.bodySmall, - overflow: TextOverflow.ellipsis, + if (_currentStep > 0) + IconButton.filledTonal( + onPressed: _prevPage, + icon: const Icon(Icons.arrow_back), + style: IconButton.styleFrom( + backgroundColor: colorScheme.surfaceContainerHighest, + foregroundColor: colorScheme.onSurfaceVariant, + ), + ) + else + const SizedBox(width: 48), // Spacer + const Spacer(), + // Progress Indicator + SizedBox( + width: 48, + height: 48, + child: Stack( + fit: StackFit.expand, + children: [ + CircularProgressIndicator( + value: progress, + strokeWidth: 4, + backgroundColor: colorScheme.surfaceContainerHighest, + color: colorScheme.primary, + strokeCap: StrokeCap.round, + ), + Center( + child: Text( + '${_currentStep + 1}/$_totalSteps', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], ), ), ], ), ), - ) - else - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - context.l10n.setupFolderDescription, - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), - textAlign: TextAlign.center, + + // Content + Expanded( + child: PageView( + controller: _pageController, + physics: const NeverScrollableScrollPhysics(), + children: [ + _buildWelcomeStep(colorScheme), + _buildStorageStep(colorScheme), + if (_androidSdkVersion >= 33) + _buildNotificationStep(colorScheme), + _buildDirectoryStep(colorScheme), + _buildSpotifyStep(colorScheme), + ], + ), ), - ), - const SizedBox(height: 24), - FilledButton.icon( - onPressed: _isLoading ? null : _selectDirectory, - icon: _isLoading - ? SizedBox(width: 20, height: 20, - child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary)) - : Icon(_selectedDirectory != null ? Icons.edit_rounded : Icons.folder_open_rounded), - label: Text(_selectedDirectory != null ? context.l10n.setupChangeFolder : context.l10n.setupSelectFolder), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - ), + ], ), - ], + ), + floatingActionButton: _currentStep < _totalSteps - 1 + ? FloatingActionButton.extended( + onPressed: _isStepCompleted(_currentStep) ? _nextPage : null, + label: Row( + children: [ + Text(context.l10n.setupNext), + const SizedBox(width: 8), + const Icon(Icons.arrow_forward), + ], + ), + icon: const SizedBox.shrink(), // Custom layout + ) + : FloatingActionButton.extended( + onPressed: + (!_useSpotifyApi || + (_clientIdController.text.isNotEmpty && + _clientSecretController.text.isNotEmpty)) + ? _completeSetup + : null, + label: _isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + color: colorScheme.onPrimary, + ), + ) + : Text(context.l10n.setupGetStarted), + icon: const Icon(Icons.check), + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + ), ); } - Widget _buildSpotifyApiStep(ColorScheme colorScheme) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 80, - height: 80, - decoration: BoxDecoration( - color: _useSpotifyApi ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(24), + Widget _buildWelcomeStep(ColorScheme colorScheme) { + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + 'assets/images/logo-transparant.png', + width: 104, + height: 104, + color: colorScheme.primary, + fit: BoxFit.contain, ), - child: Icon( - Icons.api_rounded, - size: 40, - color: _useSpotifyApi ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, + const SizedBox(height: 32), + Text( + context.l10n.appName, + style: Theme.of(context).textTheme.displaySmall?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), ), - ), - const SizedBox(height: 20), - Text( - context.l10n.setupSpotifyApiOptional, - style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - context.l10n.setupSpotifyApiDescription, - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), + const SizedBox(height: 16), + Text( + context.l10n.setupDownloadInFlac, textAlign: TextAlign.center, - ), - ), - const SizedBox(height: 24), - - Card( - elevation: 0, - color: colorScheme.surfaceContainerHigh, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - clipBehavior: Clip.antiAlias, - child: SwitchListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - title: Text(context.l10n.setupUseSpotifyApi, style: Theme.of(context).textTheme.titleSmall), - subtitle: Text( - _useSpotifyApi ? context.l10n.setupEnterCredentialsBelow : context.l10n.setupUsingDeezer, - style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + height: 1.5, ), - secondary: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: _useSpotifyApi ? colorScheme.primary : colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - _useSpotifyApi ? Icons.music_note_rounded : Icons.album_rounded, - size: 20, - color: _useSpotifyApi ? colorScheme.onPrimary : colorScheme.onSurfaceVariant, - ), - ), - value: _useSpotifyApi, - onChanged: (value) => setState(() => _useSpotifyApi = value), ), - ), - - AnimatedSize( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - child: _useSpotifyApi ? Padding( - padding: const EdgeInsets.only(top: 16), - child: Card( - elevation: 0, - color: colorScheme.surfaceContainerHigh, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(context.l10n.credentialsClientId, style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)), - const SizedBox(height: 8), - TextField( - controller: _clientIdController, - decoration: InputDecoration( - hintText: context.l10n.setupEnterClientId, - prefixIcon: const Icon(Icons.key_rounded), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - filled: true, - fillColor: colorScheme.surfaceContainerHighest, - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - ), - ), - const SizedBox(height: 16), - - Text(context.l10n.credentialsClientSecret, style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)), - const SizedBox(height: 8), - TextField( - controller: _clientSecretController, - obscureText: !_showClientSecret, - decoration: InputDecoration( - hintText: context.l10n.setupEnterClientSecret, - prefixIcon: const Icon(Icons.lock_rounded), - suffixIcon: IconButton( - icon: Icon(_showClientSecret ? Icons.visibility_off_rounded : Icons.visibility_rounded), - onPressed: () => setState(() => _showClientSecret = !_showClientSecret), - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - filled: true, - fillColor: colorScheme.surfaceContainerHighest, - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - ), - ), - const SizedBox(height: 16), - - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: colorScheme.tertiaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Icon(Icons.info_outline_rounded, size: 20, color: colorScheme.onTertiaryContainer), - const SizedBox(width: 12), - Expanded( - child: Text( - context.l10n.setupGetCredentialsFromSpotify, - style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onTertiaryContainer), - ), - ), - ], - ), - ), - ], + ], + ), + ); + } + + Widget _buildStorageStep(ColorScheme colorScheme) { + return _StepLayout( + title: context.l10n.setupStorageRequired, + description: context.l10n.setupStorageDescription, + icon: Icons.folder, + child: _storagePermissionGranted + ? _SuccessCard( + text: context.l10n.setupStorageGranted, + colorScheme: colorScheme, + ) + : FilledButton.tonalIcon( + onPressed: _requestStoragePermission, + icon: const Icon(Icons.folder_open), + label: Text(context.l10n.setupGrantPermission), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, ), ), ), - ) : const SizedBox.shrink(), - ), - ], ); } - Widget _buildNavigationButtons(ColorScheme colorScheme) { - final isLastStep = _currentStep == _totalSteps - 1; - final canProceed = _isStepCompleted(_currentStep); - - final isSpotifyStepValid = !_useSpotifyApi || - (_clientIdController.text.trim().isNotEmpty && _clientSecretController.text.trim().isNotEmpty); - - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - if (_currentStep > 0) - TextButton.icon( - onPressed: () => setState(() => _currentStep--), - icon: const Icon(Icons.arrow_back_rounded), - label: Text(context.l10n.setupBack), - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - ), - ) - else - const SizedBox(width: 100), - - if (!isLastStep) - FilledButton( - onPressed: canProceed ? () => setState(() => _currentStep++) : null, - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [Text(context.l10n.setupNext), const SizedBox(width: 8), const Icon(Icons.arrow_forward_rounded, size: 18)], - ), - ) - else - FilledButton( - onPressed: isSpotifyStepValid && !_isLoading ? _completeSetup : null, - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - ), - child: _isLoading - ? SizedBox(width: 20, height: 20, - child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary)) - : Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(_useSpotifyApi ? context.l10n.setupGetStarted : context.l10n.setupSkipAndStart), - const SizedBox(width: 8), - const Icon(Icons.check_rounded, size: 18), - ], + Widget _buildNotificationStep(ColorScheme colorScheme) { + return _StepLayout( + title: context.l10n.setupNotificationEnable, + description: context.l10n.setupNotificationBackgroundDescription, + icon: Icons.notifications, + child: _notificationPermissionGranted + ? _SuccessCard( + text: context.l10n.setupNotificationGranted, + colorScheme: colorScheme, + ) + : Column( + children: [ + FilledButton.tonalIcon( + onPressed: _requestNotificationPermission, + icon: const Icon(Icons.notifications_active), + label: Text(context.l10n.setupEnableNotifications), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), ), + ), + TextButton( + onPressed: () => + setState(() => _notificationPermissionGranted = true), + child: Text(context.l10n.setupSkipForNow), + ), + ], + ), + ); + } + + Widget _buildDirectoryStep(ColorScheme colorScheme) { + return _StepLayout( + title: context.l10n.setupFolderChoose, + description: context.l10n.setupFolderDescription, + icon: Icons.create_new_folder, + child: Column( + children: [ + if (_selectedDirectory != null) + Card( + color: colorScheme.secondaryContainer, + child: ListTile( + leading: Icon( + Icons.folder, + color: colorScheme.onSecondaryContainer, + ), + title: Text( + _selectedDirectory!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: IconButton( + icon: const Icon(Icons.edit), + onPressed: _selectDirectory, + ), + ), + ) + else + FilledButton.tonalIcon( + onPressed: _selectDirectory, + icon: const Icon(Icons.create_new_folder), + label: Text(context.l10n.setupSelectFolder), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + ), + ), + ], + ), + ); + } + + Widget _buildSpotifyStep(ColorScheme colorScheme) { + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + Icon(Icons.api, size: 48, color: colorScheme.primary), + const SizedBox(height: 24), + Text( + context.l10n.setupSpotifyApiOptional, + style: Theme.of( + context, + ).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold), + textAlign: TextAlign.center, ), - ], + const SizedBox(height: 8), + Text( + context.l10n.setupSpotifyApiDescription, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 32), + + Card( + elevation: 0, + color: colorScheme.surfaceContainerHighest, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + SwitchListTile( + value: _useSpotifyApi, + onChanged: (v) => setState(() => _useSpotifyApi = v), + title: Text(context.l10n.setupUseSpotifyApi), + subtitle: Text( + _useSpotifyApi + ? context.l10n.setupEnterCredentialsBelow + : "Using bundled metadata", + ), + ), + if (_useSpotifyApi) ...[ + const Divider(), + Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + TextField( + controller: _clientIdController, + decoration: InputDecoration( + labelText: context.l10n.credentialsClientId, + prefixIcon: const Icon(Icons.key), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: colorScheme.outline, + width: 0.5, + ), + ), + ), + ), + const SizedBox(height: 16), + TextField( + controller: _clientSecretController, + obscureText: !_showClientSecret, + decoration: InputDecoration( + labelText: context.l10n.credentialsClientSecret, + prefixIcon: const Icon(Icons.lock), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: colorScheme.outline, + width: 0.5, + ), + ), + suffixIcon: IconButton( + icon: Icon( + _showClientSecret + ? Icons.visibility_off + : Icons.visibility, + ), + onPressed: () => setState( + () => _showClientSecret = !_showClientSecret, + ), + ), + ), + ), + ], + ), + ), + ], + ], + ), + ), + ], + ), + ); + } +} + +class _StepLayout extends StatelessWidget { + final String title; + final String description; + final IconData icon; + final Widget child; + + const _StepLayout({ + required this.title, + required this.description, + required this.icon, + required this.child, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: Icon(icon, size: 48, color: colorScheme.primary), + ), + const SizedBox(height: 32), + Text( + title, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + description, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + height: 1.5, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 48), + child, + ], + ), + ); + } +} + +class _SuccessCard extends StatelessWidget { + final String text; + final ColorScheme colorScheme; + + const _SuccessCard({required this.text, required this.colorScheme}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_circle, color: colorScheme.onPrimaryContainer), + const SizedBox(width: 12), + Text( + text, + style: TextStyle( + fontWeight: FontWeight.bold, + color: colorScheme.onPrimaryContainer, + ), + ), + ], + ), ); } } diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 92429da2..492d33ee 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -1273,23 +1273,26 @@ class _TrackMetadataScreenState extends ConsumerState { return; } + final shareTitle = '$trackName - $artistName'; + + // For SAF content URIs, use native share intent directly (zero-copy) if (isContentUri(sharePath)) { - final tempPath = await PlatformBridge.copyContentUriToTemp(sharePath); - if (tempPath == null || tempPath.isEmpty) { + try { + await PlatformBridge.shareContentUri(sharePath, title: shareTitle); + } catch (_) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('Failed to prepare file for sharing'))), + SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('Failed to share file'))), ); } - return; } - sharePath = tempPath; + return; } await SharePlus.instance.share( ShareParams( files: [XFile(sharePath)], - text: '$trackName - $artistName', + text: shareTitle, ), ); } diff --git a/lib/screens/tutorial_screen.dart b/lib/screens/tutorial_screen.dart new file mode 100644 index 00000000..8447ad8c --- /dev/null +++ b/lib/screens/tutorial_screen.dart @@ -0,0 +1,712 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; + +class TutorialScreen extends ConsumerStatefulWidget { + const TutorialScreen({super.key}); + + @override + ConsumerState createState() => _TutorialScreenState(); +} + +class _TutorialScreenState extends ConsumerState { + final PageController _pageController = PageController(); + int _currentPage = 0; + static const int _totalPages = 6; + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + void _nextPage() { + if (_currentPage < _totalPages - 1) { + _pageController.nextPage( + duration: const Duration(milliseconds: 600), + curve: Curves.easeOutQuart, + ); + } else { + _completeTutorial(); + } + } + + void _prevPage() { + _pageController.previousPage( + duration: const Duration(milliseconds: 600), + curve: Curves.easeOutQuart, + ); + } + + void _completeTutorial() { + ref.read(settingsProvider.notifier).setTutorialComplete(); + context.go('/'); + } + + void _skipTutorial() { + ref.read(settingsProvider.notifier).setTutorialComplete(); + context.go('/'); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final l10n = context.l10n; + final isLastPage = _currentPage == _totalPages - 1; + + return Scaffold( + backgroundColor: colorScheme.surface, + body: SafeArea( + child: Column( + children: [ + // Top Navigation Bar + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + AnimatedOpacity( + duration: const Duration(milliseconds: 300), + opacity: _currentPage > 0 ? 1.0 : 0.0, + child: IconButton.filledTonal( + onPressed: _currentPage > 0 ? _prevPage : null, + icon: const Icon(Icons.arrow_back), + style: IconButton.styleFrom( + backgroundColor: colorScheme.surfaceContainerHighest, + foregroundColor: colorScheme.onSurfaceVariant, + ), + ), + ), + + // Skip button + TextButton( + onPressed: _skipTutorial, + style: TextButton.styleFrom( + foregroundColor: colorScheme.onSurfaceVariant, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + ), + child: Text( + l10n.setupSkip, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ), + + // Main Content Area + Expanded( + child: PageView( + controller: _pageController, + onPageChanged: (page) => setState(() => _currentPage = page), + children: [ + _TutorialPage( + index: 0, + currentIndex: _currentPage, + icon: Icons.waving_hand_rounded, + iconColor: Colors.amber, + title: l10n.tutorialWelcomeTitle, + description: l10n.tutorialWelcomeDesc, + content: _buildFeatureList(context, [ + (Icons.music_note_rounded, l10n.tutorialWelcomeTip1), + (Icons.high_quality_rounded, l10n.tutorialWelcomeTip2), + (Icons.download_rounded, l10n.tutorialWelcomeTip3), + ]), + ), + _TutorialPage( + index: 1, + currentIndex: _currentPage, + icon: Icons.search_rounded, + title: l10n.tutorialSearchTitle, + description: l10n.tutorialSearchDesc, + content: const _InteractiveSearchExample(), + ), + _TutorialPage( + index: 2, + currentIndex: _currentPage, + icon: Icons.download_rounded, + title: l10n.tutorialDownloadTitle, + description: l10n.tutorialDownloadDesc, + content: const _InteractiveDownloadExample(), + ), + _TutorialPage( + index: 3, + currentIndex: _currentPage, + icon: Icons.library_music_rounded, + title: l10n.tutorialLibraryTitle, + description: l10n.tutorialLibraryDesc, + content: _buildFeatureList(context, [ + (Icons.offline_pin_rounded, l10n.tutorialLibraryTip1), + (Icons.play_circle_fill, l10n.tutorialLibraryTip2), + (Icons.grid_view_rounded, l10n.tutorialLibraryTip3), + ]), + ), + _TutorialPage( + index: 4, + currentIndex: _currentPage, + icon: Icons.extension_rounded, + title: l10n.tutorialExtensionsTitle, + description: l10n.tutorialExtensionsDesc, + content: _buildFeatureList(context, [ + (Icons.storefront_rounded, l10n.tutorialExtensionsTip1), + ( + Icons.add_circle_outline_rounded, + l10n.tutorialExtensionsTip2, + ), + (Icons.lyrics_rounded, l10n.tutorialExtensionsTip3), + ]), + ), + _TutorialPage( + index: 5, + currentIndex: _currentPage, + icon: Icons.settings_rounded, + title: l10n.tutorialSettingsTitle, + description: l10n.tutorialSettingsDesc, + content: Column( + children: [ + _buildFeatureList(context, [ + ( + Icons.folder_open_rounded, + l10n.tutorialSettingsTip1, + ), + (Icons.tune_rounded, l10n.tutorialSettingsTip2), + (Icons.palette_rounded, l10n.tutorialSettingsTip3), + ]), + const SizedBox(height: 24), + _AnimatedReadyCard(text: l10n.tutorialReadyMessage), + ], + ), + ), + ], + ), + ), + + // Bottom Control Area + Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + // Expressive Page Indicators + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(_totalPages, (index) { + final isActive = _currentPage == index; + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutBack, + margin: const EdgeInsets.symmetric(horizontal: 4), + height: 8, + width: isActive ? 32 : 8, + decoration: BoxDecoration( + color: isActive + ? colorScheme.primary + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + ); + }), + ), + const SizedBox(height: 32), + // Action Button + SizedBox( + width: double.infinity, + height: 56, + child: FilledButton( + onPressed: _nextPage, + style: FilledButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(28), + ), + ), + child: Text( + isLastPage ? l10n.setupGetStarted : l10n.setupNext, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildFeatureList( + BuildContext context, + List<(IconData, String)> features, + ) { + final colorScheme = Theme.of(context).colorScheme; + return Column( + children: features.asMap().entries.map((entry) { + final index = entry.key; + final feature = entry.value; + return TweenAnimationBuilder( + tween: Tween(begin: 0, end: 1), + duration: Duration(milliseconds: 600 + (index * 200)), + curve: Curves.easeOutQuart, + builder: (context, value, child) { + return Transform.translate( + offset: Offset(0, 20 * (1 - value)), + child: Opacity( + opacity: value, + child: Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + ), + child: Icon( + feature.$1, + size: 24, + color: colorScheme.primary, + ), + ), + const SizedBox(width: 20), + Expanded( + child: Text( + feature.$2, + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + ); + }, + ); + }).toList(), + ); + } +} + +class _AnimatedReadyCard extends StatelessWidget { + final String text; + const _AnimatedReadyCard({required this.text}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(28), + ), + child: Row( + children: [ + Icon( + Icons.lightbulb_rounded, + color: colorScheme.onPrimaryContainer, + size: 28, + ), + const SizedBox(width: 16), + Expanded( + child: Text( + text, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: colorScheme.onPrimaryContainer, + ), + ), + ), + ], + ), + ); + } +} + +class _InteractiveSearchExample extends StatefulWidget { + const _InteractiveSearchExample(); + + @override + State<_InteractiveSearchExample> createState() => + _InteractiveSearchExampleState(); +} + +class _InteractiveSearchExampleState extends State<_InteractiveSearchExample> { + final TextEditingController _controller = TextEditingController(); + bool _showResult = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(28), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Search Input + TextField( + controller: _controller, + onChanged: (value) { + setState(() { + _showResult = value.isNotEmpty; + }); + }, + style: TextStyle(color: colorScheme.onSurface, fontSize: 16), + decoration: InputDecoration( + hintText: 'Paste or search...', + hintStyle: TextStyle(color: colorScheme.onSurfaceVariant), + prefixIcon: Icon(Icons.search, color: colorScheme.primary), + filled: true, + fillColor: colorScheme.surface, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 16, + ), + ), + ), + + // Result Placeholder + AnimatedSize( + duration: const Duration(milliseconds: 400), + curve: Curves.easeOutBack, + child: _showResult + ? Padding( + padding: const EdgeInsets.only(top: 16), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.music_note_rounded, + color: colorScheme.onPrimaryContainer, + size: 28, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: double.infinity, + height: 14, + decoration: BoxDecoration( + color: colorScheme.onSurface.withValues( + alpha: 0.1, + ), + borderRadius: BorderRadius.circular(7), + ), + ), + const SizedBox(height: 8), + Container( + width: 100, + height: 12, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant + .withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Icon( + Icons.download_rounded, + color: colorScheme.primary, + ), + ], + ), + ), + ) + : const SizedBox.shrink(), + ), + ], + ), + ); + } +} + +class _InteractiveDownloadExample extends StatefulWidget { + const _InteractiveDownloadExample(); + + @override + State<_InteractiveDownloadExample> createState() => + _InteractiveDownloadExampleState(); +} + +class _InteractiveDownloadExampleState + extends State<_InteractiveDownloadExample> { + bool _isDownloading = false; + double _progress = 0.0; + bool _isCompleted = false; + + void _startDownload() async { + if (_isDownloading || _isCompleted) return; + + setState(() { + _isDownloading = true; + _progress = 0.0; + }); + + for (int i = 0; i <= 100; i += 5) { + if (!mounted) return; + await Future.delayed(const Duration(milliseconds: 50)); + setState(() => _progress = i / 100); + } + + setState(() { + _isDownloading = false; + _isCompleted = true; + }); + + // Reset after a delay + await Future.delayed(const Duration(seconds: 2)); + if (mounted) { + setState(() { + _isCompleted = false; + _progress = 0.0; + }); + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(28), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + ), + child: Row( + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(20), + ), + child: Icon( + Icons.album_rounded, + size: 36, + color: colorScheme.onPrimaryContainer, + ), + ), + const SizedBox(width: 20), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 140, + height: 14, + decoration: BoxDecoration( + color: colorScheme.onSurface, + borderRadius: BorderRadius.circular(7), + ), + ), + const SizedBox(height: 10), + if (_isDownloading) + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: LinearProgressIndicator( + value: _progress, + minHeight: 12, + backgroundColor: colorScheme.surfaceContainerHighest, + color: colorScheme.primary, + ), + ) + else + Container( + width: 90, + height: 12, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant, + borderRadius: BorderRadius.circular(6), + ), + ), + ], + ), + ), + const SizedBox(width: 16), + GestureDetector( + onTap: _startDownload, + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: _isCompleted ? Colors.green : colorScheme.primary, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: (_isCompleted ? Colors.green : colorScheme.primary) + .withValues(alpha: 0.3), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + child: _isDownloading + ? SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator( + strokeWidth: 3, + color: colorScheme.onPrimary, + ), + ) + : Icon( + _isCompleted + ? Icons.check_rounded + : Icons.download_rounded, + color: colorScheme.onPrimary, + size: 28, + ), + ), + ), + ], + ), + ); + } +} + +class _TutorialPage extends StatelessWidget { + final int index; + final int currentIndex; + final IconData icon; + final String title; + final String description; + final Widget content; + final Color? iconColor; + + const _TutorialPage({ + required this.index, + required this.currentIndex, + required this.icon, + required this.title, + required this.description, + required this.content, + this.iconColor, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + // Parallax effect logic (simplified for StatelessWidget) + // In a real advanced implementation we'd pass the Controller's listenable + // But for now, let's use entrance animations based on currentIndex == index + + final isActive = currentIndex == index; + + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + physics: const BouncingScrollPhysics(), + child: Column( + children: [ + const SizedBox(height: 24), + AnimatedContainer( + duration: const Duration(milliseconds: 500), + curve: Curves.easeOutBack, + transform: Matrix4.translationValues(0, isActive ? 0 : -20, 0), + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: (iconColor ?? colorScheme.primary).withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + child: Icon( + icon, + size: 56, + color: iconColor ?? colorScheme.primary, + ), + ), + const SizedBox(height: 48), + AnimatedOpacity( + duration: const Duration(milliseconds: 500), + opacity: isActive ? 1.0 : 0.0, + curve: Curves.easeOut, + child: Text( + title, + style: Theme.of(context).textTheme.headlineLarge?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + letterSpacing: -0.5, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 20), + AnimatedOpacity( + duration: const Duration(milliseconds: 500), + opacity: isActive ? 1.0 : 0.0, + curve: Curves.easeOut, + child: Text( + description, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + height: 1.5, + fontSize: 16, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 56), + content, // The content itself now handles its own internal animations + const SizedBox(height: 32), + ], + ), + ); + } +} diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index d51569a1..d24a5a09 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -319,6 +319,14 @@ class PlatformBridge { }); } + static Future shareContentUri(String uri, {String title = ''}) async { + final result = await _channel.invokeMethod('shareContentUri', { + 'uri': uri, + 'title': title, + }); + return result as bool? ?? false; + } + static Future> fetchLyrics( String spotifyId, String trackName, diff --git a/pubspec.yaml b/pubspec.yaml index e552801a..5b495ca8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 3.5.0-beta.1+73 +version: 3.5.0+74 environment: sdk: ^3.10.0