feat: add multilanguage support (i18n) for English and Indonesian

- Add flutter_localizations and intl dependencies
- Create l10n.yaml configuration and ARB files (app_en.arb, app_id.arb)
- Add L10n extension for easy context.l10n access
- Localize all active screens:
  - setup_screen, track_metadata_screen, log_screen
  - download_settings_page, options_settings_page, appearance_settings_page
  - extensions_page, extension_detail_page, extension_details_screen
  - about_page, provider_priority_page, metadata_provider_priority_page
  - home_tab, queue_tab, store_tab, main_shell
  - album_screen, artist_screen, playlist_screen
  - downloaded_album_screen, queue_screen
- Localize widgets: update_dialog, download_service_picker
- Technical terms (FLAC, API, Spotify, Tidal, Qobuz, etc.) are NOT translated
- ~900+ localized strings in English, ~660+ in Indonesian
This commit is contained in:
zarzet
2026-01-16 05:50:11 +07:00
parent 7c6705c75c
commit f26af38c1e
34 changed files with 9758 additions and 601 deletions
+6
View File
@@ -0,0 +1,6 @@
arb-dir: lib/l10n/arb
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
output-class: AppLocalizations
output-dir: lib/l10n
nullable-getter: false
+10
View File
@@ -1,10 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
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/providers/settings_provider.dart';
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
final _routerProvider = Provider<GoRouter>((ref) {
// Only watch isFirstLaunch to prevent router rebuild on other settings changes
@@ -43,6 +45,14 @@ class SpotiFLACApp extends ConsumerWidget {
themeAnimationDuration: const Duration(milliseconds: 300),
themeAnimationCurve: Curves.easeInOut,
routerConfig: router,
// Localization
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
);
},
);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+910
View File
@@ -0,0 +1,910 @@
{
"@@locale": "en",
"@@last_modified": "2026-01-16",
"appName": "SpotiFLAC",
"appDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
"navHome": "Home",
"navHistory": "History",
"navSettings": "Settings",
"navStore": "Store",
"homeTitle": "Home",
"homeSearchHint": "Paste Spotify URL or search...",
"homeSearchHintExtension": "Search with {extensionName}...",
"@homeSearchHintExtension": {
"placeholders": {
"extensionName": {"type": "String"}
}
},
"homeSubtitle": "Paste a Spotify link or search by name",
"homeSupports": "Supports: Track, Album, Playlist, Artist URLs",
"homeRecent": "Recent",
"historyTitle": "History",
"historyDownloading": "Downloading ({count})",
"@historyDownloading": {
"placeholders": {
"count": {"type": "int"}
}
},
"historyDownloaded": "Downloaded",
"historyFilterAll": "All",
"historyFilterAlbums": "Albums",
"historyFilterSingles": "Singles",
"historyTracksCount": "{count, plural, =1{1 track} other{{count} tracks}}",
"@historyTracksCount": {
"placeholders": {
"count": {"type": "int"}
}
},
"historyAlbumsCount": "{count, plural, =1{1 album} other{{count} albums}}",
"@historyAlbumsCount": {
"placeholders": {
"count": {"type": "int"}
}
},
"historyNoDownloads": "No download history",
"historyNoDownloadsSubtitle": "Downloaded tracks will appear here",
"historyNoAlbums": "No album downloads",
"historyNoAlbumsSubtitle": "Download multiple tracks from an album to see them here",
"historyNoSingles": "No single downloads",
"historyNoSinglesSubtitle": "Single track downloads will appear here",
"settingsTitle": "Settings",
"settingsDownload": "Download",
"settingsAppearance": "Appearance",
"settingsOptions": "Options",
"settingsExtensions": "Extensions",
"settingsAbout": "About",
"downloadTitle": "Download",
"downloadLocation": "Download Location",
"downloadLocationSubtitle": "Choose where to save files",
"downloadLocationDefault": "Default location",
"downloadDefaultService": "Default Service",
"downloadDefaultServiceSubtitle": "Service used for downloads",
"downloadDefaultQuality": "Default Quality",
"downloadAskQuality": "Ask Quality Before Download",
"downloadAskQualitySubtitle": "Show quality picker for each download",
"downloadFilenameFormat": "Filename Format",
"downloadFolderOrganization": "Folder Organization",
"downloadSeparateSingles": "Separate Singles",
"downloadSeparateSinglesSubtitle": "Put single tracks in a separate folder",
"qualityBest": "Best Available",
"qualityFlac": "FLAC",
"quality320": "320 kbps",
"quality128": "128 kbps",
"appearanceTitle": "Appearance",
"appearanceTheme": "Theme",
"appearanceThemeSystem": "System",
"appearanceThemeLight": "Light",
"appearanceThemeDark": "Dark",
"appearanceDynamicColor": "Dynamic Color",
"appearanceDynamicColorSubtitle": "Use colors from your wallpaper",
"appearanceAccentColor": "Accent Color",
"appearanceHistoryView": "History View",
"appearanceHistoryViewList": "List",
"appearanceHistoryViewGrid": "Grid",
"optionsTitle": "Options",
"optionsSearchSource": "Search Source",
"optionsPrimaryProvider": "Primary Provider",
"optionsPrimaryProviderSubtitle": "Service used when searching by track name.",
"optionsUsingExtension": "Using extension: {extensionName}",
"@optionsUsingExtension": {
"placeholders": {
"extensionName": {"type": "String"}
}
},
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension",
"optionsAutoFallback": "Auto Fallback",
"optionsAutoFallbackSubtitle": "Try other services if download fails",
"optionsUseExtensionProviders": "Use Extension Providers",
"optionsUseExtensionProvidersOn": "Extensions will be tried first",
"optionsUseExtensionProvidersOff": "Using built-in providers only",
"optionsEmbedLyrics": "Embed Lyrics",
"optionsEmbedLyricsSubtitle": "Embed synced lyrics into FLAC files",
"optionsMaxQualityCover": "Max Quality Cover",
"optionsMaxQualityCoverSubtitle": "Download highest resolution cover art",
"optionsConcurrentDownloads": "Concurrent Downloads",
"optionsConcurrentSequential": "Sequential (1 at a time)",
"optionsConcurrentParallel": "{count} parallel downloads",
"@optionsConcurrentParallel": {
"placeholders": {
"count": {"type": "int"}
}
},
"optionsConcurrentWarning": "Parallel downloads may trigger rate limiting",
"optionsExtensionStore": "Extension Store",
"optionsExtensionStoreSubtitle": "Show Store tab in navigation",
"optionsCheckUpdates": "Check for Updates",
"optionsCheckUpdatesSubtitle": "Notify when new version is available",
"optionsUpdateChannel": "Update Channel",
"optionsUpdateChannelStable": "Stable releases only",
"optionsUpdateChannelPreview": "Get preview releases",
"optionsUpdateChannelWarning": "Preview may contain bugs or incomplete features",
"optionsClearHistory": "Clear Download History",
"optionsClearHistorySubtitle": "Remove all downloaded tracks from history",
"optionsDetailedLogging": "Detailed Logging",
"optionsDetailedLoggingOn": "Detailed logs are being recorded",
"optionsDetailedLoggingOff": "Enable for bug reports",
"optionsSpotifyCredentials": "Spotify Credentials",
"optionsSpotifyCredentialsConfigured": "Client ID: {clientId}...",
"@optionsSpotifyCredentialsConfigured": {
"placeholders": {
"clientId": {"type": "String"}
}
},
"optionsSpotifyCredentialsRequired": "Required - tap to configure",
"optionsSpotifyWarning": "Spotify requires your own API credentials. Get them free from developer.spotify.com",
"extensionsTitle": "Extensions",
"extensionsInstalled": "Installed Extensions",
"extensionsNone": "No extensions installed",
"extensionsNoneSubtitle": "Install extensions from the Store tab",
"extensionsEnabled": "Enabled",
"extensionsDisabled": "Disabled",
"extensionsVersion": "Version {version}",
"@extensionsVersion": {
"placeholders": {
"version": {"type": "String"}
}
},
"extensionsAuthor": "by {author}",
"@extensionsAuthor": {
"placeholders": {
"author": {"type": "String"}
}
},
"extensionsUninstall": "Uninstall",
"extensionsSetAsSearch": "Set as Search Provider",
"storeTitle": "Extension Store",
"storeSearch": "Search extensions...",
"storeInstall": "Install",
"storeInstalled": "Installed",
"storeUpdate": "Update",
"aboutTitle": "About",
"aboutContributors": "Contributors",
"aboutMobileDeveloper": "Mobile version developer",
"aboutOriginalCreator": "Creator of the original SpotiFLAC",
"aboutLogoArtist": "The talented artist who created our beautiful app logo!",
"aboutSpecialThanks": "Special Thanks",
"aboutLinks": "Links",
"aboutMobileSource": "Mobile source code",
"aboutPCSource": "PC source code",
"aboutReportIssue": "Report an issue",
"aboutReportIssueSubtitle": "Report any problems you encounter",
"aboutFeatureRequest": "Feature request",
"aboutFeatureRequestSubtitle": "Suggest new features for the app",
"aboutSupport": "Support",
"aboutBuyMeCoffee": "Buy me a coffee",
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
"aboutApp": "App",
"aboutVersion": "Version",
"albumTitle": "Album",
"albumTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
"@albumTracks": {
"placeholders": {
"count": {"type": "int"}
}
},
"albumDownloadAll": "Download All",
"albumDownloadRemaining": "Download Remaining",
"playlistTitle": "Playlist",
"artistTitle": "Artist",
"artistAlbums": "Albums",
"artistSingles": "Singles & EPs",
"trackMetadataTitle": "Track Info",
"trackMetadataArtist": "Artist",
"trackMetadataAlbum": "Album",
"trackMetadataDuration": "Duration",
"trackMetadataQuality": "Quality",
"trackMetadataPath": "File Path",
"trackMetadataDownloadedAt": "Downloaded",
"trackMetadataService": "Service",
"trackMetadataPlay": "Play",
"trackMetadataShare": "Share",
"trackMetadataDelete": "Delete",
"trackMetadataRedownload": "Re-download",
"trackMetadataOpenFolder": "Open Folder",
"setupTitle": "Welcome to SpotiFLAC",
"setupSubtitle": "Let's get you started",
"setupStoragePermission": "Storage Permission",
"setupStoragePermissionSubtitle": "Required to save downloaded files",
"setupStoragePermissionGranted": "Permission granted",
"setupStoragePermissionDenied": "Permission denied",
"setupGrantPermission": "Grant Permission",
"setupDownloadLocation": "Download Location",
"setupChooseFolder": "Choose Folder",
"setupContinue": "Continue",
"setupSkip": "Skip for now",
"dialogCancel": "Cancel",
"dialogOk": "OK",
"dialogSave": "Save",
"dialogDelete": "Delete",
"dialogRetry": "Retry",
"dialogClose": "Close",
"dialogYes": "Yes",
"dialogNo": "No",
"dialogClear": "Clear",
"dialogConfirm": "Confirm",
"dialogDone": "Done",
"dialogClearHistoryTitle": "Clear History",
"dialogClearHistoryMessage": "Are you sure you want to clear all download history? This cannot be undone.",
"dialogDeleteSelectedTitle": "Delete Selected",
"dialogDeleteSelectedMessage": "Delete {count} {count, plural, =1{track} other{tracks}} from history?\n\nThis will also delete the files from storage.",
"@dialogDeleteSelectedMessage": {
"placeholders": {
"count": {"type": "int"}
}
},
"dialogImportPlaylistTitle": "Import Playlist",
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
"@dialogImportPlaylistMessage": {
"placeholders": {
"count": {"type": "int"}
}
},
"snackbarAddedToQueue": "Added \"{trackName}\" to queue",
"@snackbarAddedToQueue": {
"placeholders": {
"trackName": {"type": "String"}
}
},
"snackbarAddedTracksToQueue": "Added {count} tracks to queue",
"@snackbarAddedTracksToQueue": {
"placeholders": {
"count": {"type": "int"}
}
},
"snackbarAlreadyDownloaded": "\"{trackName}\" already downloaded",
"@snackbarAlreadyDownloaded": {
"placeholders": {
"trackName": {"type": "String"}
}
},
"snackbarHistoryCleared": "History cleared",
"snackbarCredentialsSaved": "Credentials saved",
"snackbarCredentialsCleared": "Credentials cleared",
"snackbarDeletedTracks": "Deleted {count} {count, plural, =1{track} other{tracks}}",
"@snackbarDeletedTracks": {
"placeholders": {
"count": {"type": "int"}
}
},
"snackbarCannotOpenFile": "Cannot open file: {error}",
"@snackbarCannotOpenFile": {
"placeholders": {
"error": {"type": "String"}
}
},
"snackbarFillAllFields": "Please fill all fields",
"snackbarViewQueue": "View Queue",
"errorRateLimited": "Rate Limited",
"errorRateLimitedMessage": "Too many requests. Please wait a moment before searching again.",
"errorFailedToLoad": "Failed to load {item}",
"@errorFailedToLoad": {
"placeholders": {
"item": {"type": "String"}
}
},
"errorNoTracksFound": "No tracks found",
"errorMissingExtensionSource": "Cannot load {item}: missing extension source",
"@errorMissingExtensionSource": {
"placeholders": {
"item": {"type": "String"}
}
},
"statusQueued": "Queued",
"statusDownloading": "Downloading",
"statusFinalizing": "Finalizing",
"statusCompleted": "Completed",
"statusFailed": "Failed",
"statusSkipped": "Skipped",
"statusPaused": "Paused",
"actionPause": "Pause",
"actionResume": "Resume",
"actionCancel": "Cancel",
"actionStop": "Stop",
"actionSelect": "Select",
"actionSelectAll": "Select All",
"actionDeselect": "Deselect",
"actionPaste": "Paste",
"actionImportCsv": "Import CSV",
"actionRemoveCredentials": "Remove Credentials",
"actionSaveCredentials": "Save Credentials",
"selectionSelected": "{count} selected",
"@selectionSelected": {
"placeholders": {
"count": {"type": "int"}
}
},
"selectionAllSelected": "All tracks selected",
"selectionTapToSelect": "Tap tracks to select",
"selectionDeleteTracks": "Delete {count} {count, plural, =1{track} other{tracks}}",
"@selectionDeleteTracks": {
"placeholders": {
"count": {"type": "int"}
}
},
"selectionSelectToDelete": "Select tracks to delete",
"progressFetchingMetadata": "Fetching metadata... {current}/{total}",
"@progressFetchingMetadata": {
"placeholders": {
"current": {"type": "int"},
"total": {"type": "int"}
}
},
"progressReadingCsv": "Reading CSV...",
"searchSongs": "Songs",
"searchArtists": "Artists",
"searchAlbums": "Albums",
"searchPlaylists": "Playlists",
"tooltipPlay": "Play",
"tooltipCancel": "Cancel",
"tooltipStop": "Stop",
"tooltipRetry": "Retry",
"tooltipRemove": "Remove",
"tooltipClear": "Clear",
"tooltipPaste": "Paste",
"filenameFormat": "Filename Format",
"filenameFormatPreview": "Preview: {preview}",
"@filenameFormatPreview": {
"placeholders": {
"preview": {"type": "String"}
}
},
"folderOrganization": "Folder Organization",
"folderOrganizationNone": "No organization",
"folderOrganizationByArtist": "By Artist",
"folderOrganizationByAlbum": "By Album",
"folderOrganizationByArtistAlbum": "Artist/Album",
"updateAvailable": "Update Available",
"updateNewVersion": "Version {version} is available",
"@updateNewVersion": {
"placeholders": {
"version": {"type": "String"}
}
},
"updateDownload": "Download",
"updateLater": "Later",
"updateChangelog": "Changelog",
"providerPriority": "Provider Priority",
"providerPrioritySubtitle": "Drag to reorder download providers",
"metadataProviderPriority": "Metadata Provider Priority",
"metadataProviderPrioritySubtitle": "Order used when fetching track metadata",
"logTitle": "Logs",
"logCopy": "Copy Logs",
"logClear": "Clear Logs",
"logShare": "Share Logs",
"logEmpty": "No logs yet",
"logCopied": "Logs copied to clipboard",
"credentialsTitle": "Spotify Credentials",
"credentialsDescription": "Enter your Client ID and Secret to use your own Spotify application quota.",
"credentialsClientId": "Client ID",
"credentialsClientIdHint": "Paste Client ID",
"credentialsClientSecret": "Client Secret",
"credentialsClientSecretHint": "Paste Client Secret",
"channelStable": "Stable",
"channelPreview": "Preview",
"sectionSearchSource": "Search Source",
"sectionDownload": "Download",
"sectionPerformance": "Performance",
"sectionApp": "App",
"sectionData": "Data",
"sectionDebug": "Debug",
"sectionService": "Service",
"sectionAudioQuality": "Audio Quality",
"sectionFileSettings": "File Settings",
"sectionColor": "Color",
"sectionTheme": "Theme",
"sectionLayout": "Layout",
"settingsAppearanceSubtitle": "Theme, colors, display",
"settingsDownloadSubtitle": "Service, quality, filename format",
"settingsOptionsSubtitle": "Fallback, lyrics, cover art, updates",
"settingsExtensionsSubtitle": "Manage download providers",
"settingsLogsSubtitle": "View app logs for debugging",
"loadingSharedLink": "Loading shared link...",
"pressBackAgainToExit": "Press back again to exit",
"artistReleases": "{count, plural, =1{1 release} other{{count} releases}}",
"@artistReleases": {
"placeholders": {
"count": {"type": "int"}
}
},
"artistCompilations": "Compilations",
"tracksHeader": "Tracks",
"downloadAllCount": "Download All ({count})",
"@downloadAllCount": {
"placeholders": {
"count": {"type": "int"}
}
},
"tracksCount": "{count, plural, =1{1 track} other{{count} tracks}}",
"@tracksCount": {
"placeholders": {
"count": {"type": "int"}
}
},
"setupStorageAccessRequired": "Storage Access Required",
"setupStorageAccessMessage": "SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.",
"setupStorageAccessMessageAndroid11": "Android 11+ requires \"All files access\" permission to save files to your chosen download folder.",
"setupOpenSettings": "Open Settings",
"setupPermissionDeniedMessage": "Permission denied. Please grant all permissions to continue.",
"setupPermissionRequired": "{permissionType} Permission Required",
"@setupPermissionRequired": {
"placeholders": {
"permissionType": {"type": "String"}
}
},
"setupPermissionRequiredMessage": "{permissionType} permission is required for the best experience. You can change this later in Settings.",
"@setupPermissionRequiredMessage": {
"placeholders": {
"permissionType": {"type": "String"}
}
},
"setupSelectDownloadFolder": "Select Download Folder",
"setupUseDefaultFolder": "Use Default Folder?",
"setupNoFolderSelected": "No folder selected. Would you like to use the default Music folder?",
"setupUseDefault": "Use Default",
"setupDownloadLocationTitle": "Download Location",
"setupDownloadLocationIosMessage": "On iOS, downloads are saved to the app's Documents folder. You can access them via the Files app.",
"setupAppDocumentsFolder": "App Documents Folder",
"setupAppDocumentsFolderSubtitle": "Recommended - accessible via Files app",
"setupChooseFromFiles": "Choose from Files",
"setupChooseFromFilesSubtitle": "Select iCloud or other location",
"setupIosEmptyFolderWarning": "iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.",
"setupDownloadInFlac": "Download Spotify tracks in FLAC",
"setupStepStorage": "Storage",
"setupStepNotification": "Notification",
"setupStepFolder": "Folder",
"setupStepSpotify": "Spotify",
"setupStepPermission": "Permission",
"setupStorageGranted": "Storage Permission Granted!",
"setupStorageRequired": "Storage Permission Required",
"setupStorageDescription": "SpotiFLAC needs storage permission to save your downloaded music files.",
"setupNotificationGranted": "Notification Permission Granted!",
"setupNotificationEnable": "Enable Notifications",
"setupNotificationDescription": "Get notified when downloads complete or require attention.",
"setupFolderSelected": "Download Folder Selected!",
"setupFolderChoose": "Choose Download Folder",
"setupFolderDescription": "Select a folder where your downloaded music will be saved.",
"setupChangeFolder": "Change Folder",
"setupSelectFolder": "Select Folder",
"setupSpotifyApiOptional": "Spotify API (Optional)",
"setupSpotifyApiDescription": "Add your Spotify API credentials for better search results and access to Spotify-exclusive content.",
"setupUseSpotifyApi": "Use Spotify API",
"setupEnterCredentialsBelow": "Enter your credentials below",
"setupUsingDeezer": "Using Deezer (no account needed)",
"setupEnterClientId": "Enter Spotify Client ID",
"setupEnterClientSecret": "Enter Spotify Client Secret",
"setupGetFreeCredentials": "Get your free API credentials from the Spotify Developer Dashboard.",
"setupEnableNotifications": "Enable Notifications",
"dialogImport": "Import",
"dialogDiscard": "Discard",
"dialogRemove": "Remove",
"dialogUninstall": "Uninstall",
"dialogDiscardChanges": "Discard Changes?",
"dialogUnsavedChanges": "You have unsaved changes. Do you want to discard them?",
"dialogDownloadFailed": "Download Failed",
"dialogTrackLabel": "Track:",
"dialogArtistLabel": "Artist:",
"dialogErrorLabel": "Error:",
"dialogClearAll": "Clear All",
"dialogClearAllDownloads": "Are you sure you want to clear all downloads?",
"dialogRemoveFromDevice": "Remove from device?",
"dialogRemoveExtension": "Remove Extension",
"dialogRemoveExtensionMessage": "Are you sure you want to remove this extension? This cannot be undone.",
"dialogUninstallExtension": "Uninstall Extension?",
"dialogUninstallExtensionMessage": "Are you sure you want to remove {extensionName}?",
"@dialogUninstallExtensionMessage": {
"placeholders": {
"extensionName": {"type": "String"}
}
},
"snackbarFailedToLoad": "Failed to load: {error}",
"@snackbarFailedToLoad": {
"placeholders": {
"error": {"type": "String"}
}
},
"snackbarUrlCopied": "{platform} URL copied to clipboard",
"@snackbarUrlCopied": {
"placeholders": {
"platform": {"type": "String"}
}
},
"snackbarFileNotFound": "File not found",
"snackbarSelectExtFile": "Please select a .spotiflac-ext file",
"snackbarProviderPrioritySaved": "Provider priority saved",
"snackbarMetadataProviderSaved": "Metadata provider priority saved",
"snackbarExtensionInstalled": "{extensionName} installed.",
"@snackbarExtensionInstalled": {
"placeholders": {
"extensionName": {"type": "String"}
}
},
"snackbarExtensionUpdated": "{extensionName} updated.",
"@snackbarExtensionUpdated": {
"placeholders": {
"extensionName": {"type": "String"}
}
},
"snackbarFailedToInstall": "Failed to install extension",
"snackbarFailedToUpdate": "Failed to update extension",
"storeFilterAll": "All",
"storeFilterMetadata": "Metadata",
"storeFilterDownload": "Download",
"storeFilterUtility": "Utility",
"storeFilterLyrics": "Lyrics",
"storeFilterIntegration": "Integration",
"storeClearFilters": "Clear filters",
"storeNoResults": "No extensions found",
"extensionProviderPriority": "Provider Priority",
"extensionInstallButton": "Install Extension",
"extensionDefaultProvider": "Default (Deezer/Spotify)",
"extensionDefaultProviderSubtitle": "Use built-in search",
"extensionAuthor": "Author",
"extensionId": "ID",
"extensionError": "Error",
"extensionCapabilities": "Capabilities",
"extensionMetadataProvider": "Metadata Provider",
"extensionDownloadProvider": "Download Provider",
"extensionLyricsProvider": "Lyrics Provider",
"extensionUrlHandler": "URL Handler",
"extensionQualityOptions": "Quality Options",
"extensionPostProcessingHooks": "Post-Processing Hooks",
"extensionPermissions": "Permissions",
"extensionSettings": "Settings",
"extensionRemoveButton": "Remove Extension",
"extensionUpdated": "Updated",
"extensionMinAppVersion": "Min App Version",
"qualityFlacLossless": "FLAC Lossless",
"qualityFlacLosslessSubtitle": "16-bit / 44.1kHz",
"qualityHiResFlac": "Hi-Res FLAC",
"qualityHiResFlacSubtitle": "24-bit / up to 96kHz",
"qualityHiResFlacMax": "Hi-Res FLAC Max",
"qualityHiResFlacMaxSubtitle": "24-bit / up to 192kHz",
"qualityNote": "Actual quality depends on track availability from the service",
"downloadAskBeforeDownload": "Ask Before Download",
"downloadDirectory": "Download Directory",
"downloadSeparateSinglesFolder": "Separate Singles Folder",
"downloadAlbumFolderStructure": "Album Folder Structure",
"downloadSaveFormat": "Save Format",
"downloadSelectService": "Select Service",
"downloadSelectQuality": "Select Quality",
"downloadFrom": "Download From",
"downloadDefaultQualityLabel": "Default Quality",
"downloadBestAvailable": "Best available",
"folderNone": "None",
"folderNoneSubtitle": "Save all files directly to download folder",
"folderArtist": "Artist",
"folderArtistSubtitle": "Artist Name/filename",
"folderAlbum": "Album",
"folderAlbumSubtitle": "Album Name/filename",
"folderArtistAlbum": "Artist/Album",
"folderArtistAlbumSubtitle": "Artist Name/Album Name/filename",
"serviceTidal": "Tidal",
"serviceQobuz": "Qobuz",
"serviceAmazon": "Amazon",
"serviceDeezer": "Deezer",
"serviceSpotify": "Spotify",
"logSearchHint": "Search logs...",
"logFilterLevel": "Level",
"logFilterSection": "Filter",
"logShareLogs": "Share logs",
"logClearLogs": "Clear logs",
"logClearLogsTitle": "Clear Logs",
"logClearLogsMessage": "Are you sure you want to clear all logs?",
"logIspBlocking": "ISP BLOCKING DETECTED",
"logRateLimited": "RATE LIMITED",
"logNetworkError": "NETWORK ERROR",
"logTrackNotFound": "TRACK NOT FOUND",
"appearanceAmoledDark": "AMOLED Dark",
"appearanceAmoledDarkSubtitle": "Pure black background",
"appearanceChooseAccentColor": "Choose Accent Color",
"appearanceChooseTheme": "Theme Mode",
"updateStartingDownload": "Starting download...",
"updateDownloadFailed": "Download failed",
"updateFailedMessage": "Failed to download update",
"updateNewVersionReady": "A new version is ready",
"updateCurrent": "Current",
"updateNew": "New",
"updateDownloading": "Downloading...",
"updateWhatsNew": "What's New",
"updateDownloadInstall": "Download & Install",
"updateDontRemind": "Don't remind",
"trackCopyFilePath": "Copy file path",
"trackRemoveFromDevice": "Remove from device",
"trackLoadLyrics": "Load Lyrics",
"dateToday": "Today",
"dateYesterday": "Yesterday",
"dateDaysAgo": "{count} days ago",
"@dateDaysAgo": {
"placeholders": {
"count": {"type": "int"}
}
},
"dateWeeksAgo": "{count} weeks ago",
"@dateWeeksAgo": {
"placeholders": {
"count": {"type": "int"}
}
},
"dateMonthsAgo": "{count} months ago",
"@dateMonthsAgo": {
"placeholders": {
"count": {"type": "int"}
}
},
"concurrentSequential": "Sequential",
"concurrentParallel2": "2 Parallel",
"concurrentParallel3": "3 Parallel",
"filenameAvailablePlaceholders": "Available placeholders:",
"filenameHint": "{artist} - {title}",
"tapToSeeError": "Tap to see error details",
"setupProceedToNextStep": "You can now proceed to the next step.",
"setupNotificationProgressDescription": "You will receive download progress notifications.",
"setupNotificationBackgroundDescription": "Get notified about download progress and completion. This helps you track downloads when the app is in background.",
"setupSkipForNow": "Skip for now",
"setupBack": "Back",
"setupNext": "Next",
"setupGetStarted": "Get Started",
"setupSkipAndStart": "Skip & Start",
"setupAllowAccessToManageFiles": "Please enable \"Allow access to manage all files\" in the next screen.",
"setupGetCredentialsFromSpotify": "Get credentials from developer.spotify.com",
"trackMetadata": "Metadata",
"trackFileInfo": "File Info",
"trackLyrics": "Lyrics",
"trackFileNotFound": "File not found",
"trackOpenInDeezer": "Open in Deezer",
"trackOpenInSpotify": "Open in Spotify",
"trackTrackName": "Track name",
"trackArtist": "Artist",
"trackAlbumArtist": "Album artist",
"trackAlbum": "Album",
"trackTrackNumber": "Track number",
"trackDiscNumber": "Disc number",
"trackDuration": "Duration",
"trackAudioQuality": "Audio quality",
"trackReleaseDate": "Release date",
"trackDownloaded": "Downloaded",
"trackCopyLyrics": "Copy lyrics",
"trackLyricsNotAvailable": "Lyrics not available for this track",
"trackLyricsTimeout": "Request timed out. Try again later.",
"trackLyricsLoadFailed": "Failed to load lyrics",
"trackCopiedToClipboard": "Copied to clipboard",
"trackDeleteConfirmTitle": "Remove from device?",
"trackDeleteConfirmMessage": "This will permanently delete the downloaded file and remove it from your history.",
"trackCannotOpen": "Cannot open: {message}",
"@trackCannotOpen": {
"placeholders": {
"message": {"type": "String"}
}
},
"logFilterBySeverity": "Filter logs by severity",
"logNoLogsYet": "No logs yet",
"logNoLogsYetSubtitle": "Logs will appear here as you use the app",
"logIssueSummary": "Issue Summary",
"logIspBlockingDescription": "Your ISP may be blocking access to download services",
"logIspBlockingSuggestion": "Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8",
"logRateLimitedDescription": "Too many requests to the service",
"logRateLimitedSuggestion": "Wait a few minutes before trying again",
"logNetworkErrorDescription": "Connection issues detected",
"logNetworkErrorSuggestion": "Check your internet connection",
"logTrackNotFoundDescription": "Some tracks could not be found on download services",
"logTrackNotFoundSuggestion": "The track may not be available in lossless quality",
"logTotalErrors": "Total errors: {count}",
"@logTotalErrors": {
"placeholders": {
"count": {"type": "int"}
}
},
"logAffected": "Affected: {domains}",
"@logAffected": {
"placeholders": {
"domains": {"type": "String"}
}
},
"logEntriesFiltered": "Entries ({count} filtered)",
"@logEntriesFiltered": {
"placeholders": {
"count": {"type": "int"}
}
},
"logEntries": "Entries ({count})",
"@logEntries": {
"placeholders": {
"count": {"type": "int"}
}
},
"extensionsProviderPrioritySection": "Provider Priority",
"extensionsInstalledSection": "Installed Extensions",
"extensionsNoExtensions": "No extensions installed",
"extensionsNoExtensionsSubtitle": "Install .spotiflac-ext files to add new providers",
"extensionsInstallButton": "Install Extension",
"extensionsInfoTip": "Extensions can add new metadata and download providers. Only install extensions from trusted sources.",
"extensionsInstalledSuccess": "Extension installed successfully",
"extensionsDownloadPriority": "Download Priority",
"extensionsDownloadPrioritySubtitle": "Set download service order",
"extensionsNoDownloadProvider": "No extensions with download provider",
"extensionsMetadataPriority": "Metadata Priority",
"extensionsMetadataPrioritySubtitle": "Set search & metadata source order",
"extensionsNoMetadataProvider": "No extensions with metadata provider",
"extensionsSearchProvider": "Search Provider",
"extensionsNoCustomSearch": "No extensions with custom search",
"extensionsSearchProviderDescription": "Choose which service to use for searching tracks",
"extensionsCustomSearch": "Custom search",
"extensionsErrorLoading": "Error loading extension",
"extensionCustomTrackMatching": "Custom Track Matching",
"extensionPostProcessing": "Post-Processing",
"extensionHooksAvailable": "{count} hook(s) available",
"@extensionHooksAvailable": {
"placeholders": {
"count": {"type": "int"}
}
},
"extensionPatternsCount": "{count} pattern(s)",
"@extensionPatternsCount": {
"placeholders": {
"count": {"type": "int"}
}
},
"extensionStrategy": "Strategy: {strategy}",
"@extensionStrategy": {
"placeholders": {
"strategy": {"type": "String"}
}
},
"aboutDoubleDouble": "DoubleDouble",
"aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!",
"aboutDabMusic": "DAB Music",
"aboutDabMusicDesc": "The best Qobuz streaming API. Hi-Res downloads wouldn't be possible without this!",
"queueTitle": "Download Queue",
"queueClearAll": "Clear All",
"queueClearAllMessage": "Are you sure you want to clear all downloads?",
"albumFolderArtistAlbum": "Artist / Album",
"albumFolderArtistAlbumSubtitle": "Albums/Artist Name/Album Name/",
"albumFolderArtistYearAlbum": "Artist / [Year] Album",
"albumFolderArtistYearAlbumSubtitle": "Albums/Artist Name/[2005] Album Name/",
"albumFolderAlbumOnly": "Album Only",
"albumFolderAlbumOnlySubtitle": "Albums/Album Name/",
"albumFolderYearAlbum": "[Year] Album",
"albumFolderYearAlbumSubtitle": "Albums/[2005] Album Name/",
"downloadedAlbumDeleteSelected": "Delete Selected",
"downloadedAlbumDeleteMessage": "Delete {count} {count, plural, =1{track} other{tracks}} from this album?\n\nThis will also delete the files from storage.",
"@downloadedAlbumDeleteMessage": {
"placeholders": {
"count": {"type": "int"}
}
},
"utilityFunctions": "Utility Functions",
"aboutMobileDeveloper": "Mobile version developer",
"aboutOriginalCreator": "Creator of the original SpotiFLAC",
"aboutLogoArtist": "The talented artist who created our beautiful app logo!",
"aboutBinimumDesc": "The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn't exist!",
"aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!",
"aboutMobileSource": "Mobile source code",
"aboutPCSource": "PC source code",
"aboutReportIssue": "Report an issue",
"aboutReportIssueSubtitle": "Report any problems you encounter",
"aboutFeatureRequest": "Feature request",
"aboutFeatureRequestSubtitle": "Suggest new features for the app",
"aboutBuyMeCoffee": "Buy me a coffee",
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
"aboutVersion": "Version",
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
"providerPriorityTitle": "Provider Priority",
"providerPriorityDescription": "Drag to reorder download providers. The app will try providers from top to bottom when downloading tracks.",
"providerPriorityInfo": "If a track is not available on the first provider, the app will automatically try the next one.",
"providerBuiltIn": "Built-in",
"providerExtension": "Extension",
"metadataProviderPriorityTitle": "Metadata Priority",
"metadataProviderPriorityDescription": "Drag to reorder metadata providers. The app will try providers from top to bottom when searching for tracks and fetching metadata.",
"metadataProviderPriorityInfo": "Deezer has no rate limits and is recommended as primary. Spotify may rate limit after many requests.",
"metadataNoRateLimits": "No rate limits",
"metadataMayRateLimit": "May rate limit",
"queueEmpty": "No downloads in queue",
"queueEmptySubtitle": "Add tracks from the home screen",
"queueClearCompleted": "Clear completed",
"queueDownloadFailed": "Download Failed",
"queueTrackLabel": "Track:",
"queueArtistLabel": "Artist:",
"queueErrorLabel": "Error:",
"queueUnknownError": "Unknown error",
"downloadedAlbumTracksHeader": "Tracks",
"downloadedAlbumDownloadedCount": "{count} downloaded",
"@downloadedAlbumDownloadedCount": {
"placeholders": {
"count": {"type": "int"}
}
},
"downloadedAlbumSelectedCount": "{count} selected",
"@downloadedAlbumSelectedCount": {
"placeholders": {
"count": {"type": "int"}
}
},
"downloadedAlbumAllSelected": "All tracks selected",
"downloadedAlbumTapToSelect": "Tap tracks to select",
"downloadedAlbumDeleteCount": "Delete {count} {count, plural, =1{track} other{tracks}}",
"@downloadedAlbumDeleteCount": {
"placeholders": {
"count": {"type": "int"}
}
},
"downloadedAlbumSelectToDelete": "Select tracks to delete",
"folderOrganizationDescription": "Organize downloaded files into folders",
"folderOrganizationNone": "None",
"folderOrganizationNoneSubtitle": "All files in download folder",
"folderOrganizationByArtist": "By Artist",
"folderOrganizationByArtistSubtitle": "Separate folder for each artist",
"folderOrganizationByAlbum": "By Album",
"folderOrganizationByAlbumSubtitle": "Separate folder for each album",
"folderOrganizationByArtistAlbum": "By Artist & Album",
"folderOrganizationByArtistAlbumSubtitle": "Nested folders for artist and album"
}
+664
View File
@@ -0,0 +1,664 @@
{
"@@locale": "id",
"@@last_modified": "2026-01-16",
"appName": "SpotiFLAC",
"appDescription": "Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.",
"navHome": "Beranda",
"navHistory": "Riwayat",
"navSettings": "Pengaturan",
"navStore": "Toko",
"homeTitle": "Beranda",
"homeSearchHint": "Tempel URL Spotify atau cari...",
"homeSearchHintExtension": "Cari dengan {extensionName}...",
"homeSubtitle": "Tempel link Spotify atau cari berdasarkan nama",
"homeSupports": "Mendukung: URL Track, Album, Playlist, Artis",
"homeRecent": "Terbaru",
"historyTitle": "Riwayat",
"historyDownloading": "Mengunduh ({count})",
"historyDownloaded": "Terunduh",
"historyFilterAll": "Semua",
"historyFilterAlbums": "Album",
"historyFilterSingles": "Single",
"historyTracksCount": "{count, plural, =1{1 lagu} other{{count} lagu}}",
"historyAlbumsCount": "{count, plural, =1{1 album} other{{count} album}}",
"historyNoDownloads": "Tidak ada riwayat unduhan",
"historyNoDownloadsSubtitle": "Lagu yang diunduh akan muncul di sini",
"historyNoAlbums": "Tidak ada unduhan album",
"historyNoAlbumsSubtitle": "Unduh beberapa lagu dari album untuk melihatnya di sini",
"historyNoSingles": "Tidak ada unduhan single",
"historyNoSinglesSubtitle": "Unduhan lagu satuan akan muncul di sini",
"settingsTitle": "Pengaturan",
"settingsDownload": "Unduhan",
"settingsAppearance": "Tampilan",
"settingsOptions": "Opsi",
"settingsExtensions": "Ekstensi",
"settingsAbout": "Tentang",
"downloadTitle": "Unduhan",
"downloadLocation": "Lokasi Unduhan",
"downloadLocationSubtitle": "Pilih tempat menyimpan file",
"downloadLocationDefault": "Lokasi default",
"downloadDefaultService": "Layanan Default",
"downloadDefaultServiceSubtitle": "Layanan yang digunakan untuk unduhan",
"downloadDefaultQuality": "Kualitas Default",
"downloadAskQuality": "Tanya Kualitas Sebelum Unduh",
"downloadAskQualitySubtitle": "Tampilkan pemilih kualitas untuk setiap unduhan",
"downloadFilenameFormat": "Format Nama File",
"downloadFolderOrganization": "Organisasi Folder",
"downloadSeparateSingles": "Pisahkan Single",
"downloadSeparateSinglesSubtitle": "Letakkan lagu satuan di folder terpisah",
"qualityBest": "Terbaik",
"qualityFlac": "FLAC",
"quality320": "320 kbps",
"quality128": "128 kbps",
"appearanceTitle": "Tampilan",
"appearanceTheme": "Tema",
"appearanceThemeSystem": "Sistem",
"appearanceThemeLight": "Terang",
"appearanceThemeDark": "Gelap",
"appearanceDynamicColor": "Warna Dinamis",
"appearanceDynamicColorSubtitle": "Gunakan warna dari wallpaper Anda",
"appearanceAccentColor": "Warna Aksen",
"appearanceHistoryView": "Tampilan Riwayat",
"appearanceHistoryViewList": "Daftar",
"appearanceHistoryViewGrid": "Grid",
"optionsTitle": "Opsi",
"optionsSearchSource": "Sumber Pencarian",
"optionsPrimaryProvider": "Provider Utama",
"optionsPrimaryProviderSubtitle": "Layanan yang digunakan saat mencari berdasarkan nama lagu.",
"optionsUsingExtension": "Menggunakan ekstensi: {extensionName}",
"optionsSwitchBack": "Ketuk Deezer atau Spotify untuk beralih dari ekstensi",
"optionsAutoFallback": "Auto Fallback",
"optionsAutoFallbackSubtitle": "Coba layanan lain jika unduhan gagal",
"optionsUseExtensionProviders": "Gunakan Provider Ekstensi",
"optionsUseExtensionProvidersOn": "Ekstensi akan dicoba terlebih dahulu",
"optionsUseExtensionProvidersOff": "Hanya menggunakan provider bawaan",
"optionsEmbedLyrics": "Sematkan Lirik",
"optionsEmbedLyricsSubtitle": "Sematkan lirik sinkron ke file FLAC",
"optionsMaxQualityCover": "Cover Kualitas Maksimal",
"optionsMaxQualityCoverSubtitle": "Unduh cover art resolusi tertinggi",
"optionsConcurrentDownloads": "Unduhan Bersamaan",
"optionsConcurrentSequential": "Berurutan (1 per waktu)",
"optionsConcurrentParallel": "{count} unduhan paralel",
"optionsConcurrentWarning": "Unduhan paralel dapat memicu pembatasan rate",
"optionsExtensionStore": "Toko Ekstensi",
"optionsExtensionStoreSubtitle": "Tampilkan tab Toko di navigasi",
"optionsCheckUpdates": "Periksa Pembaruan",
"optionsCheckUpdatesSubtitle": "Beritahu saat versi baru tersedia",
"optionsUpdateChannel": "Saluran Pembaruan",
"optionsUpdateChannelStable": "Hanya rilis stabil",
"optionsUpdateChannelPreview": "Dapatkan rilis preview",
"optionsUpdateChannelWarning": "Preview mungkin mengandung bug atau fitur belum lengkap",
"optionsClearHistory": "Hapus Riwayat Unduhan",
"optionsClearHistorySubtitle": "Hapus semua lagu dari riwayat",
"optionsDetailedLogging": "Log Detail",
"optionsDetailedLoggingOn": "Log detail sedang direkam",
"optionsDetailedLoggingOff": "Aktifkan untuk laporan bug",
"optionsSpotifyCredentials": "Kredensial Spotify",
"optionsSpotifyCredentialsConfigured": "Client ID: {clientId}...",
"optionsSpotifyCredentialsRequired": "Diperlukan - ketuk untuk mengatur",
"optionsSpotifyWarning": "Spotify memerlukan kredensial API Anda sendiri. Dapatkan gratis dari developer.spotify.com",
"extensionsTitle": "Ekstensi",
"extensionsInstalled": "Ekstensi Terpasang",
"extensionsNone": "Tidak ada ekstensi terpasang",
"extensionsNoneSubtitle": "Pasang ekstensi dari tab Toko",
"extensionsEnabled": "Aktif",
"extensionsDisabled": "Nonaktif",
"extensionsVersion": "Versi {version}",
"extensionsAuthor": "oleh {author}",
"extensionsUninstall": "Copot",
"extensionsSetAsSearch": "Jadikan Provider Pencarian",
"storeTitle": "Toko Ekstensi",
"storeSearch": "Cari ekstensi...",
"storeInstall": "Pasang",
"storeInstalled": "Terpasang",
"storeUpdate": "Perbarui",
"aboutTitle": "Tentang",
"aboutContributors": "Kontributor",
"aboutMobileDeveloper": "Pengembang versi mobile",
"aboutOriginalCreator": "Pencipta SpotiFLAC asli",
"aboutLogoArtist": "Seniman berbakat yang membuat logo aplikasi kami yang indah!",
"aboutSpecialThanks": "Terima Kasih Khusus",
"aboutLinks": "Tautan",
"aboutMobileSource": "Kode sumber mobile",
"aboutPCSource": "Kode sumber PC",
"aboutReportIssue": "Laporkan masalah",
"aboutReportIssueSubtitle": "Laporkan masalah yang Anda temui",
"aboutFeatureRequest": "Permintaan fitur",
"aboutFeatureRequestSubtitle": "Sarankan fitur baru untuk aplikasi",
"aboutSupport": "Dukungan",
"aboutBuyMeCoffee": "Traktir saya kopi",
"aboutBuyMeCoffeeSubtitle": "Dukung pengembangan di Ko-fi",
"aboutApp": "Aplikasi",
"aboutVersion": "Versi",
"albumTitle": "Album",
"albumTracks": "{count, plural, =1{1 lagu} other{{count} lagu}}",
"albumDownloadAll": "Unduh Semua",
"albumDownloadRemaining": "Unduh Sisanya",
"playlistTitle": "Playlist",
"artistTitle": "Artis",
"artistAlbums": "Album",
"artistSingles": "Single & EP",
"trackMetadataTitle": "Info Lagu",
"trackMetadataArtist": "Artis",
"trackMetadataAlbum": "Album",
"trackMetadataDuration": "Durasi",
"trackMetadataQuality": "Kualitas",
"trackMetadataPath": "Lokasi File",
"trackMetadataDownloadedAt": "Diunduh",
"trackMetadataService": "Layanan",
"trackMetadataPlay": "Putar",
"trackMetadataShare": "Bagikan",
"trackMetadataDelete": "Hapus",
"trackMetadataRedownload": "Unduh ulang",
"trackMetadataOpenFolder": "Buka Folder",
"setupTitle": "Selamat Datang di SpotiFLAC",
"setupSubtitle": "Mari mulai pengaturan",
"setupStoragePermission": "Izin Penyimpanan",
"setupStoragePermissionSubtitle": "Diperlukan untuk menyimpan file unduhan",
"setupStoragePermissionGranted": "Izin diberikan",
"setupStoragePermissionDenied": "Izin ditolak",
"setupGrantPermission": "Berikan Izin",
"setupDownloadLocation": "Lokasi Unduhan",
"setupChooseFolder": "Pilih Folder",
"setupContinue": "Lanjutkan",
"setupSkip": "Lewati untuk sekarang",
"dialogCancel": "Batal",
"dialogOk": "OK",
"dialogSave": "Simpan",
"dialogDelete": "Hapus",
"dialogRetry": "Coba Lagi",
"dialogClose": "Tutup",
"dialogYes": "Ya",
"dialogNo": "Tidak",
"dialogClear": "Hapus",
"dialogConfirm": "Konfirmasi",
"dialogDone": "Selesai",
"dialogClearHistoryTitle": "Hapus Riwayat",
"dialogClearHistoryMessage": "Apakah Anda yakin ingin menghapus semua riwayat unduhan? Ini tidak dapat dibatalkan.",
"dialogDeleteSelectedTitle": "Hapus yang Dipilih",
"dialogDeleteSelectedMessage": "Hapus {count} {count, plural, =1{lagu} other{lagu}} dari riwayat?\n\nIni juga akan menghapus file dari penyimpanan.",
"dialogImportPlaylistTitle": "Impor Playlist",
"dialogImportPlaylistMessage": "Ditemukan {count} lagu di CSV. Tambahkan ke antrian unduhan?",
"snackbarAddedToQueue": "Menambahkan \"{trackName}\" ke antrian",
"snackbarAddedTracksToQueue": "Menambahkan {count} lagu ke antrian",
"snackbarAlreadyDownloaded": "\"{trackName}\" sudah diunduh",
"snackbarHistoryCleared": "Riwayat dihapus",
"snackbarCredentialsSaved": "Kredensial disimpan",
"snackbarCredentialsCleared": "Kredensial dihapus",
"snackbarDeletedTracks": "Menghapus {count} {count, plural, =1{lagu} other{lagu}}",
"snackbarCannotOpenFile": "Tidak dapat membuka file: {error}",
"snackbarFillAllFields": "Harap isi semua field",
"snackbarViewQueue": "Lihat Antrian",
"errorRateLimited": "Dibatasi",
"errorRateLimitedMessage": "Terlalu banyak permintaan. Harap tunggu sebentar sebelum mencari lagi.",
"errorFailedToLoad": "Gagal memuat {item}",
"errorNoTracksFound": "Tidak ada lagu ditemukan",
"errorMissingExtensionSource": "Tidak dapat memuat {item}: sumber ekstensi tidak ada",
"statusQueued": "Mengantri",
"statusDownloading": "Mengunduh",
"statusFinalizing": "Menyelesaikan",
"statusCompleted": "Selesai",
"statusFailed": "Gagal",
"statusSkipped": "Dilewati",
"statusPaused": "Dijeda",
"actionPause": "Jeda",
"actionResume": "Lanjutkan",
"actionCancel": "Batal",
"actionStop": "Hentikan",
"actionSelect": "Pilih",
"actionSelectAll": "Pilih Semua",
"actionDeselect": "Batal Pilih",
"actionPaste": "Tempel",
"actionImportCsv": "Impor CSV",
"actionRemoveCredentials": "Hapus Kredensial",
"actionSaveCredentials": "Simpan Kredensial",
"selectionSelected": "{count} dipilih",
"selectionAllSelected": "Semua lagu dipilih",
"selectionTapToSelect": "Ketuk lagu untuk memilih",
"selectionDeleteTracks": "Hapus {count} {count, plural, =1{lagu} other{lagu}}",
"selectionSelectToDelete": "Pilih lagu untuk dihapus",
"progressFetchingMetadata": "Mengambil metadata... {current}/{total}",
"progressReadingCsv": "Membaca CSV...",
"searchSongs": "Lagu",
"searchArtists": "Artis",
"searchAlbums": "Album",
"searchPlaylists": "Playlist",
"tooltipPlay": "Putar",
"tooltipCancel": "Batal",
"tooltipStop": "Hentikan",
"tooltipRetry": "Coba Lagi",
"tooltipRemove": "Hapus",
"tooltipClear": "Hapus",
"tooltipPaste": "Tempel",
"filenameFormat": "Format Nama File",
"filenameFormatPreview": "Pratinjau: {preview}",
"folderOrganization": "Organisasi Folder",
"folderOrganizationNone": "Tanpa organisasi",
"folderOrganizationByArtist": "Berdasarkan Artis",
"folderOrganizationByAlbum": "Berdasarkan Album",
"folderOrganizationByArtistAlbum": "Artis/Album",
"updateAvailable": "Pembaruan Tersedia",
"updateNewVersion": "Versi {version} tersedia",
"updateDownload": "Unduh",
"updateLater": "Nanti",
"updateChangelog": "Log Perubahan",
"providerPriority": "Prioritas Provider",
"providerPrioritySubtitle": "Seret untuk mengatur ulang provider unduhan",
"metadataProviderPriority": "Prioritas Provider Metadata",
"metadataProviderPrioritySubtitle": "Urutan yang digunakan saat mengambil metadata lagu",
"logTitle": "Log",
"logCopy": "Salin Log",
"logClear": "Hapus Log",
"logShare": "Bagikan Log",
"logEmpty": "Belum ada log",
"logCopied": "Log disalin ke clipboard",
"credentialsTitle": "Kredensial Spotify",
"credentialsDescription": "Masukkan Client ID dan Secret Anda untuk menggunakan kuota aplikasi Spotify Anda sendiri.",
"credentialsClientId": "Client ID",
"credentialsClientIdHint": "Tempel Client ID",
"credentialsClientSecret": "Client Secret",
"credentialsClientSecretHint": "Tempel Client Secret",
"channelStable": "Stabil",
"channelPreview": "Preview",
"sectionSearchSource": "Sumber Pencarian",
"sectionDownload": "Unduhan",
"sectionPerformance": "Performa",
"sectionApp": "Aplikasi",
"sectionData": "Data",
"sectionDebug": "Debug",
"sectionService": "Layanan",
"sectionAudioQuality": "Kualitas Audio",
"sectionFileSettings": "Pengaturan File",
"sectionColor": "Warna",
"sectionTheme": "Tema",
"sectionLayout": "Tata Letak",
"settingsAppearanceSubtitle": "Tema, warna, tampilan",
"settingsDownloadSubtitle": "Layanan, kualitas, format nama file",
"settingsOptionsSubtitle": "Fallback, lirik, cover art, pembaruan",
"settingsExtensionsSubtitle": "Kelola provider unduhan",
"settingsLogsSubtitle": "Lihat log aplikasi untuk debugging",
"loadingSharedLink": "Memuat link yang dibagikan...",
"pressBackAgainToExit": "Tekan kembali sekali lagi untuk keluar",
"artistReleases": "{count, plural, =1{1 rilis} other{{count} rilis}}",
"artistCompilations": "Kompilasi",
"tracksHeader": "Lagu",
"downloadAllCount": "Unduh Semua ({count})",
"tracksCount": "{count, plural, =1{1 lagu} other{{count} lagu}}",
"setupStorageAccessRequired": "Akses Penyimpanan Diperlukan",
"setupStorageAccessMessage": "SpotiFLAC membutuhkan izin \"Akses semua file\" untuk menyimpan file musik ke folder pilihan Anda.",
"setupStorageAccessMessageAndroid11": "Android 11+ memerlukan izin \"Akses semua file\" untuk menyimpan file ke folder unduhan pilihan Anda.",
"setupOpenSettings": "Buka Pengaturan",
"setupPermissionDeniedMessage": "Izin ditolak. Harap berikan semua izin untuk melanjutkan.",
"setupPermissionRequired": "Izin {permissionType} Diperlukan",
"setupPermissionRequiredMessage": "Izin {permissionType} diperlukan untuk pengalaman terbaik. Anda dapat mengubahnya nanti di Pengaturan.",
"setupSelectDownloadFolder": "Pilih Folder Unduhan",
"setupUseDefaultFolder": "Gunakan Folder Default?",
"setupNoFolderSelected": "Tidak ada folder dipilih. Apakah Anda ingin menggunakan folder Musik default?",
"setupUseDefault": "Gunakan Default",
"setupDownloadLocationTitle": "Lokasi Unduhan",
"setupDownloadLocationIosMessage": "Di iOS, unduhan disimpan ke folder Documents aplikasi. Anda dapat mengaksesnya melalui aplikasi Files.",
"setupAppDocumentsFolder": "Folder Documents Aplikasi",
"setupAppDocumentsFolderSubtitle": "Direkomendasikan - dapat diakses via aplikasi Files",
"setupChooseFromFiles": "Pilih dari Files",
"setupChooseFromFilesSubtitle": "Pilih lokasi iCloud atau lainnya",
"setupIosEmptyFolderWarning": "Batasan iOS: Folder kosong tidak dapat dipilih. Pilih folder dengan minimal satu file.",
"setupDownloadInFlac": "Unduh lagu Spotify dalam format FLAC",
"setupStepStorage": "Penyimpanan",
"setupStepNotification": "Notifikasi",
"setupStepFolder": "Folder",
"setupStepSpotify": "Spotify",
"setupStepPermission": "Izin",
"setupStorageGranted": "Izin Penyimpanan Diberikan!",
"setupStorageRequired": "Izin Penyimpanan Diperlukan",
"setupStorageDescription": "SpotiFLAC membutuhkan izin penyimpanan untuk menyimpan file musik yang diunduh.",
"setupNotificationGranted": "Izin Notifikasi Diberikan!",
"setupNotificationEnable": "Aktifkan Notifikasi",
"setupNotificationDescription": "Dapatkan pemberitahuan saat unduhan selesai atau membutuhkan perhatian.",
"setupFolderSelected": "Folder Unduhan Dipilih!",
"setupFolderChoose": "Pilih Folder Unduhan",
"setupFolderDescription": "Pilih folder tempat musik yang diunduh akan disimpan.",
"setupChangeFolder": "Ubah Folder",
"setupSelectFolder": "Pilih Folder",
"setupSpotifyApiOptional": "Spotify API (Opsional)",
"setupSpotifyApiDescription": "Tambahkan kredensial Spotify API untuk hasil pencarian lebih baik dan akses ke konten eksklusif Spotify.",
"setupUseSpotifyApi": "Gunakan Spotify API",
"setupEnterCredentialsBelow": "Masukkan kredensial Anda di bawah",
"setupUsingDeezer": "Menggunakan Deezer (tidak perlu akun)",
"setupEnterClientId": "Masukkan Spotify Client ID",
"setupEnterClientSecret": "Masukkan Spotify Client Secret",
"setupGetFreeCredentials": "Dapatkan kredensial API gratis dari Spotify Developer Dashboard.",
"setupEnableNotifications": "Aktifkan Notifikasi",
"dialogImport": "Impor",
"dialogDiscard": "Buang",
"dialogRemove": "Hapus",
"dialogUninstall": "Copot",
"dialogDiscardChanges": "Buang Perubahan?",
"dialogUnsavedChanges": "Anda memiliki perubahan yang belum disimpan. Apakah Anda ingin membuangnya?",
"dialogDownloadFailed": "Unduhan Gagal",
"dialogTrackLabel": "Lagu:",
"dialogArtistLabel": "Artis:",
"dialogErrorLabel": "Error:",
"dialogClearAll": "Hapus Semua",
"dialogClearAllDownloads": "Apakah Anda yakin ingin menghapus semua unduhan?",
"dialogRemoveFromDevice": "Hapus dari perangkat?",
"dialogRemoveExtension": "Hapus Ekstensi",
"dialogRemoveExtensionMessage": "Apakah Anda yakin ingin menghapus ekstensi ini? Tindakan ini tidak dapat dibatalkan.",
"dialogUninstallExtension": "Copot Ekstensi?",
"dialogUninstallExtensionMessage": "Apakah Anda yakin ingin menghapus {extensionName}?",
"snackbarFailedToLoad": "Gagal memuat: {error}",
"snackbarUrlCopied": "URL {platform} disalin ke clipboard",
"snackbarFileNotFound": "File tidak ditemukan",
"snackbarSelectExtFile": "Harap pilih file .spotiflac-ext",
"snackbarProviderPrioritySaved": "Prioritas provider disimpan",
"snackbarMetadataProviderSaved": "Prioritas provider metadata disimpan",
"snackbarExtensionInstalled": "{extensionName} terpasang.",
"snackbarExtensionUpdated": "{extensionName} diperbarui.",
"snackbarFailedToInstall": "Gagal memasang ekstensi",
"snackbarFailedToUpdate": "Gagal memperbarui ekstensi",
"storeFilterAll": "Semua",
"storeFilterMetadata": "Metadata",
"storeFilterDownload": "Unduhan",
"storeFilterUtility": "Utilitas",
"storeFilterLyrics": "Lirik",
"storeFilterIntegration": "Integrasi",
"storeClearFilters": "Hapus filter",
"storeNoResults": "Tidak ada ekstensi ditemukan",
"extensionProviderPriority": "Prioritas Provider",
"extensionInstallButton": "Pasang Ekstensi",
"extensionDefaultProvider": "Default (Deezer/Spotify)",
"extensionDefaultProviderSubtitle": "Gunakan pencarian bawaan",
"extensionAuthor": "Pembuat",
"extensionId": "ID",
"extensionError": "Error",
"extensionCapabilities": "Kemampuan",
"extensionMetadataProvider": "Provider Metadata",
"extensionDownloadProvider": "Provider Unduhan",
"extensionLyricsProvider": "Provider Lirik",
"extensionUrlHandler": "Penanganan URL",
"extensionQualityOptions": "Opsi Kualitas",
"extensionPostProcessingHooks": "Hook Pasca-Pemrosesan",
"extensionPermissions": "Izin",
"extensionSettings": "Pengaturan",
"extensionRemoveButton": "Hapus Ekstensi",
"extensionUpdated": "Diperbarui",
"extensionMinAppVersion": "Versi App Minimum",
"qualityFlacLossless": "FLAC Lossless",
"qualityFlacLosslessSubtitle": "16-bit / 44.1kHz",
"qualityHiResFlac": "Hi-Res FLAC",
"qualityHiResFlacSubtitle": "24-bit / hingga 96kHz",
"qualityHiResFlacMax": "Hi-Res FLAC Max",
"qualityHiResFlacMaxSubtitle": "24-bit / hingga 192kHz",
"qualityNote": "Kualitas sebenarnya tergantung ketersediaan lagu dari layanan",
"downloadAskBeforeDownload": "Tanya Sebelum Unduh",
"downloadDirectory": "Direktori Unduhan",
"downloadSeparateSinglesFolder": "Folder Singles Terpisah",
"downloadAlbumFolderStructure": "Struktur Folder Album",
"downloadSaveFormat": "Simpan Format",
"downloadSelectService": "Pilih Layanan",
"downloadSelectQuality": "Pilih Kualitas",
"downloadFrom": "Unduh Dari",
"downloadDefaultQualityLabel": "Kualitas Default",
"downloadBestAvailable": "Terbaik tersedia",
"folderNone": "Tidak ada",
"folderNoneSubtitle": "Simpan semua file langsung ke folder unduhan",
"folderArtist": "Artis",
"folderArtistSubtitle": "Nama Artis/namafile",
"folderAlbum": "Album",
"folderAlbumSubtitle": "Nama Album/namafile",
"folderArtistAlbum": "Artis/Album",
"folderArtistAlbumSubtitle": "Nama Artis/Nama Album/namafile",
"serviceTidal": "Tidal",
"serviceQobuz": "Qobuz",
"serviceAmazon": "Amazon",
"serviceDeezer": "Deezer",
"serviceSpotify": "Spotify",
"logSearchHint": "Cari log...",
"logFilterLevel": "Level",
"logFilterSection": "Filter",
"logShareLogs": "Bagikan log",
"logClearLogs": "Hapus log",
"logClearLogsTitle": "Hapus Log",
"logClearLogsMessage": "Apakah Anda yakin ingin menghapus semua log?",
"logIspBlocking": "PEMBLOKIRAN ISP TERDETEKSI",
"logRateLimited": "DIBATASI",
"logNetworkError": "ERROR JARINGAN",
"logTrackNotFound": "LAGU TIDAK DITEMUKAN",
"appearanceAmoledDark": "AMOLED Gelap",
"appearanceAmoledDarkSubtitle": "Latar belakang hitam murni",
"appearanceChooseAccentColor": "Pilih Warna Aksen",
"appearanceChooseTheme": "Mode Tema",
"updateStartingDownload": "Memulai unduhan...",
"updateDownloadFailed": "Unduhan gagal",
"updateFailedMessage": "Gagal mengunduh pembaruan",
"updateNewVersionReady": "Versi baru sudah siap",
"updateCurrent": "Saat ini",
"updateNew": "Baru",
"updateDownloading": "Mengunduh...",
"updateWhatsNew": "Yang Baru",
"updateDownloadInstall": "Unduh & Pasang",
"updateDontRemind": "Jangan ingatkan",
"trackCopyFilePath": "Salin lokasi file",
"trackRemoveFromDevice": "Hapus dari perangkat",
"trackLoadLyrics": "Muat Lirik",
"dateToday": "Hari ini",
"dateYesterday": "Kemarin",
"dateDaysAgo": "{count} hari lalu",
"dateWeeksAgo": "{count} minggu lalu",
"dateMonthsAgo": "{count} bulan lalu",
"concurrentSequential": "Berurutan",
"concurrentParallel2": "2 Paralel",
"concurrentParallel3": "3 Paralel",
"filenameAvailablePlaceholders": "Placeholder yang tersedia:",
"filenameHint": "{artist} - {title}",
"tapToSeeError": "Ketuk untuk melihat detail error",
"setupProceedToNextStep": "Anda dapat melanjutkan ke langkah berikutnya.",
"setupNotificationProgressDescription": "Anda akan menerima notifikasi progres unduhan.",
"setupNotificationBackgroundDescription": "Dapatkan notifikasi tentang progres dan penyelesaian unduhan. Ini membantu Anda melacak unduhan saat aplikasi di latar belakang.",
"setupSkipForNow": "Lewati untuk sekarang",
"setupBack": "Kembali",
"setupNext": "Lanjut",
"setupGetStarted": "Mulai",
"setupSkipAndStart": "Lewati & Mulai",
"setupAllowAccessToManageFiles": "Harap aktifkan \"Izinkan akses untuk mengelola semua file\" di layar berikutnya.",
"setupGetCredentialsFromSpotify": "Dapatkan kredensial dari developer.spotify.com",
"trackMetadata": "Metadata",
"trackFileInfo": "Info File",
"trackLyrics": "Lirik",
"trackFileNotFound": "File tidak ditemukan",
"trackOpenInDeezer": "Buka di Deezer",
"trackOpenInSpotify": "Buka di Spotify",
"trackTrackName": "Nama lagu",
"trackArtist": "Artis",
"trackAlbumArtist": "Artis album",
"trackAlbum": "Album",
"trackTrackNumber": "Nomor lagu",
"trackDiscNumber": "Nomor disc",
"trackDuration": "Durasi",
"trackAudioQuality": "Kualitas audio",
"trackReleaseDate": "Tanggal rilis",
"trackDownloaded": "Diunduh",
"trackCopyLyrics": "Salin lirik",
"trackLyricsNotAvailable": "Lirik tidak tersedia untuk lagu ini",
"trackLyricsTimeout": "Permintaan timeout. Coba lagi nanti.",
"trackLyricsLoadFailed": "Gagal memuat lirik",
"trackCopiedToClipboard": "Disalin ke clipboard",
"trackDeleteConfirmTitle": "Hapus dari perangkat?",
"trackDeleteConfirmMessage": "Ini akan menghapus file unduhan secara permanen dan menghapusnya dari riwayat Anda.",
"trackCannotOpen": "Tidak dapat membuka: {message}",
"logFilterBySeverity": "Filter log berdasarkan tingkat keparahan",
"logNoLogsYet": "Belum ada log",
"logNoLogsYetSubtitle": "Log akan muncul di sini saat Anda menggunakan aplikasi",
"logIssueSummary": "Ringkasan Masalah",
"logIspBlockingDescription": "ISP Anda mungkin memblokir akses ke layanan unduhan",
"logIspBlockingSuggestion": "Coba gunakan VPN atau ubah DNS ke 1.1.1.1 atau 8.8.8.8",
"logRateLimitedDescription": "Terlalu banyak permintaan ke layanan",
"logRateLimitedSuggestion": "Tunggu beberapa menit sebelum mencoba lagi",
"logNetworkErrorDescription": "Masalah koneksi terdeteksi",
"logNetworkErrorSuggestion": "Periksa koneksi internet Anda",
"logTrackNotFoundDescription": "Beberapa lagu tidak dapat ditemukan di layanan unduhan",
"logTrackNotFoundSuggestion": "Lagu mungkin tidak tersedia dalam kualitas lossless",
"logTotalErrors": "Total error: {count}",
"logAffected": "Terpengaruh: {domains}",
"logEntriesFiltered": "Entri ({count} difilter)",
"logEntries": "Entri ({count})",
"extensionsProviderPrioritySection": "Prioritas Provider",
"extensionsInstalledSection": "Ekstensi Terpasang",
"extensionsNoExtensions": "Tidak ada ekstensi terpasang",
"extensionsNoExtensionsSubtitle": "Pasang file .spotiflac-ext untuk menambahkan provider baru",
"extensionsInstallButton": "Pasang Ekstensi",
"extensionsInfoTip": "Ekstensi dapat menambahkan provider metadata dan unduhan baru. Hanya pasang ekstensi dari sumber terpercaya.",
"extensionsInstalledSuccess": "Ekstensi berhasil dipasang",
"extensionsDownloadPriority": "Prioritas Unduhan",
"extensionsDownloadPrioritySubtitle": "Atur urutan layanan unduhan",
"extensionsNoDownloadProvider": "Tidak ada ekstensi dengan provider unduhan",
"extensionsMetadataPriority": "Prioritas Metadata",
"extensionsMetadataPrioritySubtitle": "Atur urutan sumber pencarian & metadata",
"extensionsNoMetadataProvider": "Tidak ada ekstensi dengan provider metadata",
"extensionsSearchProvider": "Provider Pencarian",
"extensionsNoCustomSearch": "Tidak ada ekstensi dengan pencarian kustom",
"extensionsSearchProviderDescription": "Pilih layanan yang digunakan untuk mencari lagu",
"extensionsCustomSearch": "Pencarian kustom",
"extensionsErrorLoading": "Error memuat ekstensi",
"extensionCustomTrackMatching": "Pencocokan Lagu Kustom",
"extensionPostProcessing": "Pasca-Pemrosesan",
"extensionHooksAvailable": "{count} hook tersedia",
"extensionPatternsCount": "{count} pola",
"extensionStrategy": "Strategi: {strategy}",
"aboutDoubleDouble": "DoubleDouble",
"aboutDoubleDoubleDesc": "API luar biasa untuk unduhan Amazon Music. Terima kasih sudah membuatnya gratis!",
"aboutDabMusic": "DAB Music",
"aboutDabMusicDesc": "API streaming Qobuz terbaik. Unduhan Hi-Res tidak akan mungkin tanpa ini!",
"queueTitle": "Antrian Unduhan",
"queueClearAll": "Hapus Semua",
"queueClearAllMessage": "Apakah Anda yakin ingin menghapus semua unduhan?",
"albumFolderArtistAlbum": "Artis / Album",
"albumFolderArtistAlbumSubtitle": "Albums/Nama Artis/Nama Album/",
"albumFolderArtistYearAlbum": "Artis / [Tahun] Album",
"albumFolderArtistYearAlbumSubtitle": "Albums/Nama Artis/[2005] Nama Album/",
"albumFolderAlbumOnly": "Album Saja",
"albumFolderAlbumOnlySubtitle": "Albums/Nama Album/",
"albumFolderYearAlbum": "[Tahun] Album",
"albumFolderYearAlbumSubtitle": "Albums/[2005] Nama Album/",
"downloadedAlbumDeleteSelected": "Hapus yang Dipilih",
"downloadedAlbumDeleteMessage": "Hapus {count} {count, plural, =1{lagu} other{lagu}} dari album ini?\n\nIni juga akan menghapus file dari penyimpanan.",
"utilityFunctions": "Fungsi Utilitas",
"aboutMobileDeveloper": "Pengembang versi mobile",
"aboutOriginalCreator": "Pembuat SpotiFLAC asli",
"aboutLogoArtist": "Seniman berbakat yang membuat logo aplikasi kita yang indah!",
"aboutBinimumDesc": "Pembuat QQDL & HiFi API. Tanpa API ini, unduhan Tidal tidak akan ada!",
"aboutSachinsenalDesc": "Pembuat proyek HiFi asli. Fondasi dari integrasi Tidal!",
"aboutMobileSource": "Kode sumber mobile",
"aboutPCSource": "Kode sumber PC",
"aboutReportIssue": "Laporkan masalah",
"aboutReportIssueSubtitle": "Laporkan masalah yang Anda temui",
"aboutFeatureRequest": "Permintaan fitur",
"aboutFeatureRequestSubtitle": "Sarankan fitur baru untuk aplikasi",
"aboutBuyMeCoffee": "Belikan saya kopi",
"aboutBuyMeCoffeeSubtitle": "Dukung pengembangan di Ko-fi",
"aboutVersion": "Versi",
"aboutAppDescription": "Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.",
"providerPriorityTitle": "Prioritas Provider",
"providerPriorityDescription": "Seret untuk mengatur ulang urutan provider unduhan. Aplikasi akan mencoba provider dari atas ke bawah saat mengunduh lagu.",
"providerPriorityInfo": "Jika lagu tidak tersedia di provider pertama, aplikasi akan otomatis mencoba yang berikutnya.",
"providerBuiltIn": "Bawaan",
"providerExtension": "Ekstensi",
"metadataProviderPriorityTitle": "Prioritas Metadata",
"metadataProviderPriorityDescription": "Seret untuk mengatur ulang urutan provider metadata. Aplikasi akan mencoba provider dari atas ke bawah saat mencari lagu dan mengambil metadata.",
"metadataProviderPriorityInfo": "Deezer tidak memiliki batas rate dan direkomendasikan sebagai utama. Spotify mungkin membatasi rate setelah banyak permintaan.",
"metadataNoRateLimits": "Tidak ada batas rate",
"metadataMayRateLimit": "Mungkin dibatasi rate",
"queueEmpty": "Tidak ada unduhan dalam antrian",
"queueEmptySubtitle": "Tambahkan lagu dari layar beranda",
"queueClearCompleted": "Hapus yang selesai",
"queueDownloadFailed": "Unduhan Gagal",
"queueTrackLabel": "Lagu:",
"queueArtistLabel": "Artis:",
"queueErrorLabel": "Error:",
"queueUnknownError": "Error tidak diketahui",
"downloadedAlbumTracksHeader": "Lagu",
"downloadedAlbumDownloadedCount": "{count} diunduh",
"downloadedAlbumSelectedCount": "{count} dipilih",
"downloadedAlbumAllSelected": "Semua lagu dipilih",
"downloadedAlbumTapToSelect": "Ketuk lagu untuk memilih",
"downloadedAlbumDeleteCount": "Hapus {count} {count, plural, =1{lagu} other{lagu}}",
"downloadedAlbumSelectToDelete": "Pilih lagu untuk dihapus",
"folderOrganizationDescription": "Atur file yang diunduh ke dalam folder",
"folderOrganizationNone": "Tidak ada",
"folderOrganizationNoneSubtitle": "Semua file di folder unduhan",
"folderOrganizationByArtist": "Berdasarkan Artis",
"folderOrganizationByArtistSubtitle": "Folder terpisah untuk setiap artis",
"folderOrganizationByAlbum": "Berdasarkan Album",
"folderOrganizationByAlbumSubtitle": "Folder terpisah untuk setiap album",
"folderOrganizationByArtistAlbum": "Berdasarkan Artis & Album",
"folderOrganizationByArtistAlbumSubtitle": "Folder bersarang untuk artis dan album"
}
+11
View File
@@ -0,0 +1,11 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
export 'package:spotiflac_android/l10n/app_localizations.dart';
/// Extension to easily access AppLocalizations from BuildContext
extension AppLocalizationsX on BuildContext {
/// Get the AppLocalizations instance
/// Usage: context.l10n.navHome
AppLocalizations get l10n => AppLocalizations.of(this);
}
+11 -10
View File
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
@@ -260,7 +261,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
children: [
Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer),
const SizedBox(width: 4),
Text('${tracks.length} tracks', style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
Text(context.l10n.tracksCount(tracks.length), style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
@@ -269,7 +270,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
FilledButton.icon(
onPressed: () => _downloadAll(context),
icon: const Icon(Icons.download),
label: Text('Download All (${tracks.length})'),
label: Text(context.l10n.downloadAllCount(tracks.length)),
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
),
],
@@ -289,7 +290,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text('Tracks', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
Text(context.l10n.tracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
],
),
),
@@ -324,12 +325,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
coverUrl: track.coverUrl,
onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
},
);
} else {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
}
}
@@ -344,12 +345,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
artistName: widget.albumName,
onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
},
);
} else {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
}
}
@@ -375,7 +376,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Rate Limited',
context.l10n.errorRateLimited,
style: TextStyle(
color: colorScheme.onErrorContainer,
fontWeight: FontWeight.bold,
@@ -383,7 +384,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
),
const SizedBox(height: 4),
Text(
'Too many requests. Please wait a moment and try again.',
context.l10n.errorRateLimitedMessage,
style: TextStyle(
color: colorScheme.onErrorContainer,
fontSize: 12,
@@ -476,7 +477,7 @@ class _AlbumTrackItem extends ConsumerWidget {
final fileExists = await File(historyItem.filePath).exists();
if (fileExists) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('"${track.name}" already downloaded')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))));
}
return;
} else {
+8 -7
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
@@ -147,9 +148,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
child: _buildErrorWidget(_error!, colorScheme),
)),
if (!_isLoadingDiscography && _error == null) ...[
if (albumsOnly.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Albums', albumsOnly, colorScheme)),
if (singles.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Singles & EPs', singles, colorScheme)),
if (compilations.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Compilations', compilations, colorScheme)),
if (albumsOnly.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistAlbums, albumsOnly, colorScheme)),
if (singles.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistSingles, singles, colorScheme)),
if (compilations.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistCompilations, compilations, colorScheme)),
],
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
@@ -255,7 +256,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
children: [
Icon(Icons.album, size: 14, color: colorScheme.onPrimaryContainer),
const SizedBox(width: 4),
Text('${_albums!.length} releases', style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
Text(context.l10n.artistReleases(_albums!.length), style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
@@ -327,7 +328,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
const Spacer(),
Text(
album.totalTracks > 0
? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate}${album.totalTracks} tracks'
? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate}${context.l10n.tracksCount(album.totalTracks)}'
: album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant, fontSize: 11),
maxLines: 1,
@@ -394,7 +395,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Rate Limited',
context.l10n.errorRateLimited,
style: TextStyle(
color: colorScheme.onErrorContainer,
fontWeight: FontWeight.bold,
@@ -402,7 +403,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
),
const SizedBox(height: 4),
Text(
'Too many requests. Please wait a moment and try again.',
context.l10n.errorRateLimitedMessage,
style: TextStyle(
color: colorScheme.onErrorContainer,
fontSize: 12,
+15 -14
View File
@@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:open_filex/open_filex.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/mime_utils.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
@@ -84,19 +85,19 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete Selected'),
content: Text('Delete $count ${count == 1 ? 'track' : 'tracks'} from this album?\n\nThis will also delete the files from storage.'),
title: Text(context.l10n.downloadedAlbumDeleteSelected),
content: Text(context.l10n.downloadedAlbumDeleteMessage(count)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: const Text('Delete'),
child: Text(context.l10n.dialogDelete),
),
],
),
@@ -125,7 +126,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Deleted $deletedCount ${deletedCount == 1 ? 'track' : 'tracks'}')),
SnackBar(content: Text(context.l10n.snackbarDeletedTracks(deletedCount))),
);
}
}
@@ -138,7 +139,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Cannot open file: $e')),
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(e.toString()))),
);
}
}
@@ -323,7 +324,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
children: [
Icon(Icons.download_done, size: 14, color: colorScheme.onPrimaryContainer),
const SizedBox(width: 4),
Text('${tracks.length} downloaded', style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
Text(context.l10n.downloadedAlbumDownloadedCount(tracks.length), style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
@@ -376,13 +377,13 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text('Tracks', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
Text(context.l10n.downloadedAlbumTracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
const Spacer(),
if (!_isSelectionMode)
TextButton.icon(
onPressed: tracks.isNotEmpty ? () => _enterSelectionMode(tracks.first.id) : null,
icon: const Icon(Icons.checklist, size: 18),
label: const Text('Select'),
label: Text(context.l10n.actionSelect),
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
),
],
@@ -523,11 +524,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$selectedCount selected',
context.l10n.downloadedAlbumSelectedCount(selectedCount),
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
Text(
allSelected ? 'All tracks selected' : 'Tap tracks to select',
allSelected ? context.l10n.downloadedAlbumAllSelected : context.l10n.downloadedAlbumTapToSelect,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
@@ -542,7 +543,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
}
},
icon: Icon(allSelected ? Icons.deselect : Icons.select_all, size: 20),
label: Text(allSelected ? 'Deselect' : 'Select All'),
label: Text(allSelected ? context.l10n.actionDeselect : context.l10n.actionSelectAll),
style: TextButton.styleFrom(foregroundColor: colorScheme.primary),
),
],
@@ -555,8 +556,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
icon: const Icon(Icons.delete_outline),
label: Text(
selectedCount > 0
? 'Delete $selectedCount ${selectedCount == 1 ? 'track' : 'tracks'}'
: 'Select tracks to delete',
? context.l10n.downloadedAlbumDeleteCount(selectedCount)
: context.l10n.downloadedAlbumSelectToDelete,
),
style: FilledButton.styleFrom(
backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest,
+27 -26
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
@@ -202,12 +203,12 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
coverUrl: track.coverUrl,
onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
},
);
} else {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
}
}
}
@@ -238,8 +239,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
const SizedBox(height: 16),
Text(
totalTracks > 0
? 'Fetching metadata... $currentProgress/$totalTracks'
: 'Reading CSV...',
? context.l10n.progressFetchingMetadata(currentProgress, totalTracks)
: context.l10n.progressReadingCsv,
),
],
),
@@ -274,16 +275,16 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
final confirmed = await showDialog<bool>(
context: this.context,
builder: (dialogCtx) => AlertDialog(
title: const Text('Import Playlist'),
content: Text('Found ${tracks.length} tracks in CSV. Add them to download queue?'),
title: Text(context.l10n.dialogImportPlaylistTitle),
content: Text(context.l10n.dialogImportPlaylistMessage(tracks.length)),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogCtx, false),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(dialogCtx, true),
child: const Text('Import'),
child: Text(context.l10n.dialogImport),
),
],
),
@@ -294,9 +295,9 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
if (mounted) {
ScaffoldMessenger.of(this.context).showSnackBar(
SnackBar(
content: Text('Added ${tracks.length} tracks to queue'),
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
action: SnackBarAction(
label: 'View Queue',
label: context.l10n.snackbarViewQueue,
onPressed: () {
// Navigate to queue tab (handled by main_shell index)
// We don't have direct access to set index here easily without provider
@@ -364,7 +365,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
expandedTitleScale: 1.0,
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
title: Text(
'Home',
context.l10n.homeTitle,
style: TextStyle(
fontSize: 20 + (14 * expandRatio), // 20 -> 34
fontWeight: FontWeight.bold,
@@ -418,7 +419,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
),
const SizedBox(height: 8),
Text(
'Paste a Spotify link or search by name',
context.l10n.homeSubtitle,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
@@ -450,7 +451,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'Supports: Track, Album, Playlist, Artist URLs',
context.l10n.homeSupports,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
@@ -490,7 +491,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
'Recent',
context.l10n.homeRecent,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -663,7 +664,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
if (artistItems.isNotEmpty)
SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text('Artists', style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
child: Text(context.l10n.searchArtists, style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
)),
if (artistItems.isNotEmpty)
SliverToBoxAdapter(
@@ -698,7 +699,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
if (albumItems.isNotEmpty)
SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text('Albums', style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
child: Text(context.l10n.searchAlbums, style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
)),
if (albumItems.isNotEmpty)
SliverToBoxAdapter(
@@ -733,7 +734,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
if (playlistItems.isNotEmpty)
SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text('Playlists', style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
child: Text(context.l10n.searchPlaylists, style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
)),
if (playlistItems.isNotEmpty)
SliverToBoxAdapter(
@@ -768,7 +769,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
if (realTracks.isNotEmpty)
SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text('Songs', style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
child: Text(context.l10n.searchSongs, style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
)),
// Track list in grouped card
@@ -813,7 +814,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text('Artists', style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
child: Text(context.l10n.searchArtists, style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
),
SizedBox(
height: 160,
@@ -901,7 +902,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
final extensionId = albumItem.source;
if (extensionId == null || extensionId.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Cannot load album: missing extension source')),
SnackBar(content: Text(context.l10n.errorMissingExtensionSource('album'))),
);
return;
}
@@ -923,7 +924,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
final extensionId = playlistItem.source;
if (extensionId == null || extensionId.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Cannot load playlist: missing extension source')),
SnackBar(content: Text(context.l10n.errorMissingExtensionSource('playlist'))),
);
return;
}
@@ -945,7 +946,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
final extensionId = artistItem.source;
if (extensionId == null || extensionId.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Cannot load artist: missing extension source')),
SnackBar(content: Text(context.l10n.errorMissingExtensionSource('artist'))),
);
return;
}
@@ -1206,7 +1207,7 @@ class _TrackItemWithStatus extends ConsumerWidget {
// File exists, show snackbar
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('"${track.name}" already downloaded')),
SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))),
);
}
return;
@@ -1511,7 +1512,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
children: [
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
const SizedBox(height: 16),
ElevatedButton(onPressed: _fetchTracks, child: const Text('Retry')),
ElevatedButton(onPressed: _fetchTracks, child: Text(context.l10n.dialogRetry)),
],
),
),
@@ -1649,7 +1650,7 @@ class _ExtensionPlaylistScreenState extends ConsumerState<ExtensionPlaylistScree
children: [
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
const SizedBox(height: 16),
ElevatedButton(onPressed: _fetchTracks, child: const Text('Retry')),
ElevatedButton(onPressed: _fetchTracks, child: Text(context.l10n.dialogRetry)),
],
),
),
@@ -1772,7 +1773,7 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
children: [
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
const SizedBox(height: 16),
ElevatedButton(onPressed: _fetchArtist, child: const Text('Retry')),
ElevatedButton(onPressed: _fetchArtist, child: Text(context.l10n.dialogRetry)),
],
),
),
+19 -17
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
@@ -77,7 +78,7 @@ class _MainShellState extends ConsumerState<MainShell> {
// Show snackbar
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Loading shared link...')),
SnackBar(content: Text(context.l10n.loadingSharedLink)),
);
}
}
@@ -162,9 +163,9 @@ class _MainShellState extends ConsumerState<MainShell> {
} else {
_lastBackPress = now;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Press back again to exit'),
duration: Duration(seconds: 2),
SnackBar(
content: Text(context.l10n.pressBackAgainToExit),
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
),
);
@@ -201,11 +202,12 @@ class _MainShellState extends ConsumerState<MainShell> {
const SettingsTab(),
];
final l10n = context.l10n;
final destinations = <NavigationDestination>[
const NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: 'Home',
NavigationDestination(
icon: const Icon(Icons.home_outlined),
selectedIcon: const Icon(Icons.home),
label: l10n.navHome,
),
NavigationDestination(
icon: Badge(
@@ -218,18 +220,18 @@ class _MainShellState extends ConsumerState<MainShell> {
label: Text('$queueState'),
child: const Icon(Icons.history),
),
label: 'History',
label: l10n.navHistory,
),
if (showStore)
const NavigationDestination(
icon: Icon(Icons.store_outlined),
selectedIcon: Icon(Icons.store),
label: 'Store',
NavigationDestination(
icon: const Icon(Icons.store_outlined),
selectedIcon: const Icon(Icons.store),
label: l10n.navStore,
),
const NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: 'Settings',
NavigationDestination(
icon: const Icon(Icons.settings_outlined),
selectedIcon: const Icon(Icons.settings),
label: l10n.navSettings,
),
];
+9 -8
View File
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
@@ -114,7 +115,7 @@ class PlaylistScreen extends ConsumerWidget {
children: [
Icon(Icons.playlist_play, size: 14, color: colorScheme.onTertiaryContainer),
const SizedBox(width: 4),
Text('${tracks.length} tracks', style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
Text(context.l10n.tracksCount(tracks.length), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
@@ -122,7 +123,7 @@ class PlaylistScreen extends ConsumerWidget {
FilledButton.icon(
onPressed: () => _downloadAll(context, ref),
icon: const Icon(Icons.download),
label: Text('Download All (${tracks.length})'),
label: Text(context.l10n.downloadAllCount(tracks.length)),
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
),
],
@@ -141,7 +142,7 @@ class PlaylistScreen extends ConsumerWidget {
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text('Tracks', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
Text(context.l10n.tracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
],
),
),
@@ -176,12 +177,12 @@ class PlaylistScreen extends ConsumerWidget {
coverUrl: track.coverUrl,
onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
},
);
} else {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
}
}
@@ -195,12 +196,12 @@ class PlaylistScreen extends ConsumerWidget {
artistName: playlistName,
onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
},
);
} else {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
}
}
}
@@ -264,7 +265,7 @@ class _PlaylistTrackItem extends ConsumerWidget {
final fileExists = await File(historyItem.filePath).exists();
if (fileExists) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('"${track.name}" already downloaded')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))));
}
return;
} else {
+16 -15
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
@@ -14,19 +15,19 @@ class QueueScreen extends ConsumerWidget {
return Scaffold(
appBar: AppBar(
title: const Text('Download Queue'),
title: Text(context.l10n.queueTitle),
actions: [
if (queueState.items.isNotEmpty)
IconButton(
icon: const Icon(Icons.delete_sweep),
onPressed: () => ref.read(downloadQueueProvider.notifier).clearCompleted(),
tooltip: 'Clear completed',
tooltip: context.l10n.queueClearCompleted,
),
if (queueState.items.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear_all),
onPressed: () => _showClearAllDialog(context, ref),
tooltip: 'Clear all',
tooltip: context.l10n.queueClearAll,
),
],
),
@@ -51,14 +52,14 @@ class QueueScreen extends ConsumerWidget {
),
const SizedBox(height: 16),
Text(
'No downloads in queue',
context.l10n.queueEmpty,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
'Add tracks from the home screen',
context.l10n.queueEmptySubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
@@ -177,7 +178,7 @@ class QueueScreen extends ConsumerWidget {
children: [
Icon(Icons.error, color: colorScheme.error),
const SizedBox(width: 8),
const Text('Download Failed'),
Text(context.l10n.queueDownloadFailed),
],
),
content: SingleChildScrollView(
@@ -185,10 +186,10 @@ class QueueScreen extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Track: ${item.track.name}', style: const TextStyle(fontWeight: FontWeight.bold)),
Text('Artist: ${item.track.artistName}'),
Text('${context.l10n.queueTrackLabel} ${item.track.name}', style: const TextStyle(fontWeight: FontWeight.bold)),
Text('${context.l10n.queueArtistLabel} ${item.track.artistName}'),
const SizedBox(height: 16),
const Text('Error:', style: TextStyle(fontWeight: FontWeight.bold)),
Text(context.l10n.queueErrorLabel, style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(8),
@@ -197,7 +198,7 @@ class QueueScreen extends ConsumerWidget {
borderRadius: BorderRadius.circular(8),
),
child: Text(
item.error ?? 'Unknown error',
item.error ?? context.l10n.queueUnknownError,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
@@ -211,7 +212,7 @@ class QueueScreen extends ConsumerWidget {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
child: Text(context.l10n.dialogClose),
),
],
),
@@ -223,19 +224,19 @@ class QueueScreen extends ConsumerWidget {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear All'),
content: const Text('Are you sure you want to clear all downloads?'),
title: Text(context.l10n.queueClearAll),
content: Text(context.l10n.queueClearAllMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
TextButton(
onPressed: () {
ref.read(downloadQueueProvider.notifier).clearAll();
Navigator.pop(context);
},
child: Text('Clear', style: TextStyle(color: colorScheme.error)),
child: Text(context.l10n.dialogClear, style: TextStyle(color: colorScheme.error)),
),
],
),
+12 -15
View File
@@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:open_filex/open_filex.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/mime_utils.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
@@ -139,21 +140,19 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete Selected'),
content: Text(
'Delete $count ${count == 1 ? 'track' : 'tracks'} from history?\n\nThis will also delete the files from storage.',
),
title: Text(context.l10n.dialogDeleteSelectedTitle),
content: Text(context.l10n.dialogDeleteSelectedMessage(count)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: const Text('Delete'),
child: Text(context.l10n.dialogDelete),
),
],
),
@@ -184,9 +183,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Deleted $deletedCount ${deletedCount == 1 ? 'track' : 'tracks'}',
),
content: Text(context.l10n.snackbarDeletedTracks(deletedCount)),
),
);
}
@@ -235,7 +232,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Cannot open file: $e')));
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(e.toString()))));
}
}
}
@@ -493,7 +490,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
expandedTitleScale: 1.0,
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
title: Text(
'History',
context.l10n.historyTitle,
style: TextStyle(
fontSize: 20 + (14 * expandRatio),
fontWeight: FontWeight.bold,
@@ -590,7 +587,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
child: Row(
children: [
_FilterChip(
label: 'All',
label: context.l10n.historyFilterAll,
count: allHistoryItems.length,
isSelected: historyFilterMode == 'all',
onTap: () {
@@ -599,7 +596,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
const SizedBox(width: 8),
_FilterChip(
label: 'Albums',
label: context.l10n.historyFilterAlbums,
count: albumCount,
isSelected: historyFilterMode == 'albums',
onTap: () {
@@ -608,7 +605,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
const SizedBox(width: 8),
_FilterChip(
label: 'Singles',
label: context.l10n.historyFilterSingles,
count: singleCount,
isSelected: historyFilterMode == 'singles',
onTap: () {
@@ -784,7 +781,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
? () => _enterSelectionMode(historyItems.first.id)
: null,
icon: const Icon(Icons.checklist, size: 18),
label: const Text('Select'),
label: Text(context.l10n.actionSelect),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
),
+37 -36
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class AboutPage extends StatelessWidget {
@@ -41,7 +42,7 @@ class AboutPage extends StatelessWidget {
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
'About',
context.l10n.aboutTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
@@ -62,27 +63,27 @@ class AboutPage extends StatelessWidget {
),
// Contributors section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Contributors'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutContributors),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_ContributorItem(
name: AppInfo.mobileAuthor,
description: 'Mobile version developer',
description: context.l10n.aboutMobileDeveloper,
githubUsername: AppInfo.mobileAuthor,
showDivider: true,
),
_ContributorItem(
name: AppInfo.originalAuthor,
description: 'Creator of the original SpotiFLAC',
description: context.l10n.aboutOriginalCreator,
githubUsername: AppInfo.originalAuthor,
showDivider: true,
),
_ContributorItem(
name: 'Amonoman',
description: 'The talented artist who created our beautiful app logo!',
description: context.l10n.aboutLogoArtist,
githubUsername: 'Amonoman',
showDivider: false,
),
@@ -91,35 +92,35 @@ class AboutPage extends StatelessWidget {
),
// Special Thanks section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Special Thanks'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutSpecialThanks),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_ContributorItem(
name: 'binimum',
description: 'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!',
description: context.l10n.aboutBinimumDesc,
githubUsername: 'binimum',
showDivider: true,
),
_ContributorItem(
name: 'sachinsenal0x64',
description: 'The original HiFi project creator. The foundation of Tidal integration!',
description: context.l10n.aboutSachinsenalDesc,
githubUsername: 'sachinsenal0x64',
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.cloud_outlined,
title: 'DoubleDouble',
subtitle: 'Amazing API for Amazon Music downloads. Thank you for making it free!',
title: context.l10n.aboutDoubleDouble,
subtitle: context.l10n.aboutDoubleDoubleDesc,
onTap: () => _launchUrl('https://doubledouble.top'),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.music_note_outlined,
title: 'DAB Music',
subtitle: 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!',
title: context.l10n.aboutDabMusic,
subtitle: context.l10n.aboutDabMusicDesc,
onTap: () => _launchUrl('https://dabmusic.xyz'),
showDivider: false,
),
@@ -128,37 +129,37 @@ class AboutPage extends StatelessWidget {
),
// Links section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Links'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutLinks),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
_AboutSettingsItem(
icon: Icons.phone_android,
title: 'Mobile source code',
title: context.l10n.aboutMobileSource,
subtitle: 'github.com/${AppInfo.githubRepo}',
onTap: () => _launchUrl(AppInfo.githubUrl),
showDivider: true,
),
SettingsItem(
_AboutSettingsItem(
icon: Icons.computer,
title: 'PC source code',
title: context.l10n.aboutPCSource,
subtitle: 'github.com/${AppInfo.originalAuthor}/SpotiFLAC',
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
showDivider: true,
),
SettingsItem(
_AboutSettingsItem(
icon: Icons.bug_report_outlined,
title: 'Report an issue',
subtitle: 'Report any problems you encounter',
title: context.l10n.aboutReportIssue,
subtitle: context.l10n.aboutReportIssueSubtitle,
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
showDivider: true,
),
SettingsItem(
_AboutSettingsItem(
icon: Icons.lightbulb_outline,
title: 'Feature request',
subtitle: 'Suggest new features for the app',
title: context.l10n.aboutFeatureRequest,
subtitle: context.l10n.aboutFeatureRequestSubtitle,
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
showDivider: false,
),
@@ -167,16 +168,16 @@ class AboutPage extends StatelessWidget {
),
// Support section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Support'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutSupport),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
_AboutSettingsItem(
icon: Icons.coffee_outlined,
title: 'Buy me a coffee',
subtitle: 'Support development on Ko-fi',
title: context.l10n.aboutBuyMeCoffee,
subtitle: context.l10n.aboutBuyMeCoffeeSubtitle,
onTap: () => _launchUrl(AppInfo.kofiUrl),
showDivider: false,
),
@@ -185,15 +186,15 @@ class AboutPage extends StatelessWidget {
),
// App info section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'App'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutApp),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
_AboutSettingsItem(
icon: Icons.info_outline,
title: 'Version',
title: context.l10n.aboutVersion,
subtitle: 'v${AppInfo.version} (build ${AppInfo.buildNumber})',
showDivider: false,
),
@@ -300,7 +301,7 @@ class _AppHeaderCard extends StatelessWidget {
const SizedBox(height: 16),
// Description
Text(
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.',
context.l10n.aboutAppDescription,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/theme_provider.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
@@ -32,7 +33,7 @@ class AppearanceSettingsPage extends ConsumerWidget {
onPressed: () => Navigator.pop(context),
),
flexibleSpace: _AppBarTitle(
title: 'Appearance',
title: context.l10n.appearanceTitle,
topPadding: topPadding,
),
),
@@ -49,8 +50,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
),
// Color section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Color'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionColor),
),
SliverToBoxAdapter(
@@ -58,8 +59,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
children: [
SettingsSwitchItem(
icon: Icons.wallpaper,
title: 'Dynamic Color',
subtitle: 'Use colors from your wallpaper',
title: context.l10n.appearanceDynamicColor,
subtitle: context.l10n.appearanceDynamicColorSubtitle,
value: themeSettings.useDynamicColor,
onChanged: (value) => ref
.read(themeProvider.notifier)
@@ -82,8 +83,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
),
// Theme section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Theme'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionTheme),
),
SliverToBoxAdapter(
child: SettingsGroup(
@@ -96,8 +97,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
if (Theme.of(context).brightness == Brightness.dark)
SettingsSwitchItem(
icon: Icons.brightness_2,
title: 'AMOLED Dark',
subtitle: 'Pure black background',
title: context.l10n.appearanceAmoledDark,
subtitle: context.l10n.appearanceAmoledDarkSubtitle,
value: themeSettings.useAmoled,
onChanged: (value) =>
ref.read(themeProvider.notifier).setUseAmoled(value),
@@ -108,8 +109,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
),
// Layout section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Layout'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionLayout),
),
SliverToBoxAdapter(
child: SettingsGroup(
@@ -283,7 +284,7 @@ class _ThemePreviewCard extends StatelessWidget {
borderRadius: BorderRadius.circular(20),
),
child: Text(
isDark ? 'Dark Mode' : 'Light Mode',
isDark ? context.l10n.appearanceThemeDark : context.l10n.appearanceThemeLight,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
@@ -451,21 +452,21 @@ class _ThemeModeSelector extends StatelessWidget {
children: [
_ThemeModeChip(
icon: Icons.brightness_auto,
label: 'System',
label: context.l10n.appearanceThemeSystem,
isSelected: currentMode == ThemeMode.system,
onTap: () => onChanged(ThemeMode.system),
),
const SizedBox(width: 8),
_ThemeModeChip(
icon: Icons.light_mode,
label: 'Light',
label: context.l10n.appearanceThemeLight,
isSelected: currentMode == ThemeMode.light,
onTap: () => onChanged(ThemeMode.light),
),
const SizedBox(width: 8),
_ThemeModeChip(
icon: Icons.dark_mode,
label: 'Dark',
label: context.l10n.appearanceThemeDark,
isSelected: currentMode == ThemeMode.dark,
onTap: () => onChanged(ThemeMode.dark),
),
@@ -575,7 +576,7 @@ class _HistoryViewSelector extends StatelessWidget {
Padding(
padding: const EdgeInsets.only(left: 8, bottom: 8),
child: Text(
'History View',
context.l10n.appearanceHistoryView,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -585,14 +586,14 @@ class _HistoryViewSelector extends StatelessWidget {
children: [
_ViewModeChip(
icon: Icons.view_list,
label: 'List',
label: context.l10n.appearanceHistoryViewList,
isSelected: currentMode == 'list',
onTap: () => onChanged('list'),
),
const SizedBox(width: 8),
_ViewModeChip(
icon: Icons.grid_view,
label: 'Grid',
label: context.l10n.appearanceHistoryViewGrid,
isSelected: currentMode == 'grid',
onTap: () => onChanged('grid'),
),
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
@@ -55,7 +56,7 @@ class DownloadSettingsPage extends ConsumerWidget {
bottom: 16,
),
title: Text(
'Download',
context.l10n.downloadTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
@@ -68,8 +69,8 @@ class DownloadSettingsPage extends ConsumerWidget {
),
// Service section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Service'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionService),
),
SliverToBoxAdapter(
child: SettingsGroup(
@@ -85,17 +86,17 @@ class DownloadSettingsPage extends ConsumerWidget {
),
// Quality section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Audio Quality'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionAudioQuality),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.tune,
title: 'Ask Before Download',
title: context.l10n.downloadAskBeforeDownload,
subtitle: isBuiltInService
? 'Choose quality for each download'
? context.l10n.downloadAskQualitySubtitle
: 'Select a built-in service to enable',
value: settings.askQualityBeforeDownload,
// Not selected visually if extension is active
@@ -106,24 +107,24 @@ class DownloadSettingsPage extends ConsumerWidget {
),
if (!settings.askQualityBeforeDownload && isBuiltInService) ...[
_QualityOption(
title: 'FLAC Lossless',
subtitle: '16-bit / 44.1kHz',
title: context.l10n.qualityFlacLossless,
subtitle: context.l10n.qualityFlacLosslessSubtitle,
isSelected: settings.audioQuality == 'LOSSLESS',
onTap: () => ref
.read(settingsProvider.notifier)
.setAudioQuality('LOSSLESS'),
),
_QualityOption(
title: 'Hi-Res FLAC',
subtitle: '24-bit / up to 96kHz',
title: context.l10n.qualityHiResFlac,
subtitle: context.l10n.qualityHiResFlacSubtitle,
isSelected: settings.audioQuality == 'HI_RES',
onTap: () => ref
.read(settingsProvider.notifier)
.setAudioQuality('HI_RES'),
),
_QualityOption(
title: 'Hi-Res FLAC Max',
subtitle: '24-bit / up to 192kHz',
title: context.l10n.qualityHiResFlacMax,
subtitle: context.l10n.qualityHiResFlacMaxSubtitle,
isSelected: settings.audioQuality == 'HI_RES_LOSSLESS',
onTap: () => ref
.read(settingsProvider.notifier)
@@ -159,15 +160,15 @@ class DownloadSettingsPage extends ConsumerWidget {
),
// File settings section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'File Settings'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionFileSettings),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.text_fields,
title: 'Filename Format',
title: context.l10n.downloadFilenameFormat,
subtitle: settings.filenameFormat,
onTap: () => _showFormatEditor(
context,
@@ -177,17 +178,17 @@ class DownloadSettingsPage extends ConsumerWidget {
),
SettingsItem(
icon: Icons.folder_outlined,
title: 'Download Directory',
title: context.l10n.downloadDirectory,
subtitle: settings.downloadDirectory.isEmpty
? (Platform.isIOS
? 'App Documents Folder'
? context.l10n.setupAppDocumentsFolder
: 'Music/SpotiFLAC')
: settings.downloadDirectory,
onTap: () => _pickDirectory(context, ref),
),
SettingsSwitchItem(
icon: Icons.library_music_outlined,
title: 'Separate Singles Folder',
title: context.l10n.downloadSeparateSinglesFolder,
subtitle: settings.separateSingles
? 'Albums/ and Singles/ folders'
: 'All files in same structure',
@@ -199,7 +200,7 @@ class DownloadSettingsPage extends ConsumerWidget {
if (settings.separateSingles)
SettingsItem(
icon: Icons.folder_outlined,
title: 'Album Folder Structure',
title: context.l10n.downloadAlbumFolderStructure,
subtitle: _getAlbumFolderStructureLabel(settings.albumFolderStructure),
onTap: () => _showAlbumFolderStructurePicker(
context,
@@ -210,7 +211,7 @@ class DownloadSettingsPage extends ConsumerWidget {
if (!settings.separateSingles)
SettingsItem(
icon: Icons.create_new_folder_outlined,
title: 'Folder Organization',
title: context.l10n.downloadFolderOrganization,
subtitle: _getFolderOrganizationLabel(
settings.folderOrganization,
),
@@ -254,8 +255,8 @@ class DownloadSettingsPage extends ConsumerWidget {
children: [
ListTile(
leading: const Icon(Icons.folder_outlined),
title: const Text('Artist / Album'),
subtitle: const Text('Albums/Artist Name/Album Name/'),
title: Text(context.l10n.albumFolderArtistAlbum),
subtitle: Text(context.l10n.albumFolderArtistAlbumSubtitle),
trailing: current == 'artist_album' ? const Icon(Icons.check) : null,
onTap: () {
ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_album');
@@ -264,8 +265,8 @@ class DownloadSettingsPage extends ConsumerWidget {
),
ListTile(
leading: const Icon(Icons.calendar_today_outlined),
title: const Text('Artist / [Year] Album'),
subtitle: const Text('Albums/Artist Name/[2005] Album Name/'),
title: Text(context.l10n.albumFolderArtistYearAlbum),
subtitle: Text(context.l10n.albumFolderArtistYearAlbumSubtitle),
trailing: current == 'artist_year_album' ? const Icon(Icons.check) : null,
onTap: () {
ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_year_album');
@@ -274,8 +275,8 @@ class DownloadSettingsPage extends ConsumerWidget {
),
ListTile(
leading: const Icon(Icons.album_outlined),
title: const Text('Album Only'),
subtitle: const Text('Albums/Album Name/'),
title: Text(context.l10n.albumFolderAlbumOnly),
subtitle: Text(context.l10n.albumFolderAlbumOnlySubtitle),
trailing: current == 'album_only' ? const Icon(Icons.check) : null,
onTap: () {
ref.read(settingsProvider.notifier).setAlbumFolderStructure('album_only');
@@ -284,8 +285,8 @@ class DownloadSettingsPage extends ConsumerWidget {
),
ListTile(
leading: const Icon(Icons.event_outlined),
title: const Text('[Year] Album Only'),
subtitle: const Text('Albums/[2005] Album Name/'),
title: Text(context.l10n.albumFolderYearAlbum),
subtitle: Text(context.l10n.albumFolderYearAlbumSubtitle),
trailing: current == 'year_album' ? const Icon(Icons.check) : null,
onTap: () {
ref.read(settingsProvider.notifier).setAlbumFolderStructure('year_album');
@@ -367,7 +368,7 @@ class DownloadSettingsPage extends ConsumerWidget {
),
),
Text(
'Filename Format',
context.l10n.filenameFormat,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
@@ -433,7 +434,7 @@ class DownloadSettingsPage extends ConsumerWidget {
Row(
children: [
Expanded(
child: TextButton(
child: TextButton(
onPressed: () => Navigator.pop(context),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
@@ -441,7 +442,7 @@ class DownloadSettingsPage extends ConsumerWidget {
borderRadius: BorderRadius.circular(16),
),
),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
),
const SizedBox(width: 12),
@@ -460,7 +461,7 @@ class DownloadSettingsPage extends ConsumerWidget {
borderRadius: BorderRadius.circular(16),
),
),
child: const Text('Save Format'),
child: Text(context.l10n.dialogSave),
),
),
],
@@ -504,7 +505,7 @@ class DownloadSettingsPage extends ConsumerWidget {
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
'Download Location',
context.l10n.setupDownloadLocationTitle,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
@@ -513,7 +514,7 @@ class DownloadSettingsPage extends ConsumerWidget {
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'On iOS, downloads are saved to the app\'s Documents folder which is accessible via the Files app.',
context.l10n.setupDownloadLocationIosMessage,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -521,8 +522,8 @@ class DownloadSettingsPage extends ConsumerWidget {
),
ListTile(
leading: Icon(Icons.folder_special, color: colorScheme.primary),
title: const Text('App Documents Folder'),
subtitle: const Text('Recommended - accessible via Files app'),
title: Text(context.l10n.setupAppDocumentsFolder),
subtitle: Text(context.l10n.setupAppDocumentsFolderSubtitle),
trailing: Icon(Icons.check_circle, color: colorScheme.primary),
onTap: () async {
final dir = await getApplicationDocumentsDirectory();
@@ -534,8 +535,8 @@ class DownloadSettingsPage extends ConsumerWidget {
),
ListTile(
leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant),
title: const Text('Choose from Files'),
subtitle: const Text('Select iCloud or other location'),
title: Text(context.l10n.setupChooseFromFiles),
subtitle: Text(context.l10n.setupChooseFromFilesSubtitle),
onTap: () async {
Navigator.pop(ctx);
// Note: iOS requires folder to have at least one file to be selectable
@@ -565,7 +566,7 @@ class DownloadSettingsPage extends ConsumerWidget {
const SizedBox(width: 12),
Expanded(
child: Text(
'iOS limitation: Empty folders cannot be selected. Create a file inside first or use App Documents.',
context.l10n.setupIosEmptyFolderWarning,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onTertiaryContainer,
),
@@ -589,7 +590,7 @@ class DownloadSettingsPage extends ConsumerWidget {
case 'album':
return 'By Album';
case 'artist_album':
return 'By Artist & Album';
return 'Artist/Album';
default:
return 'None';
}
@@ -629,15 +630,15 @@ class DownloadSettingsPage extends ConsumerWidget {
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'Organize downloaded files into folders',
context.l10n.folderOrganizationDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
_FolderOption(
title: 'None',
subtitle: 'All files in download folder',
title: context.l10n.folderOrganizationNone,
subtitle: context.l10n.folderOrganizationNoneSubtitle,
example: 'SpotiFLAC/Track.flac',
isSelected: current == 'none',
onTap: () {
@@ -646,8 +647,8 @@ class DownloadSettingsPage extends ConsumerWidget {
},
),
_FolderOption(
title: 'By Artist',
subtitle: 'Separate folder for each artist',
title: context.l10n.folderOrganizationByArtist,
subtitle: context.l10n.folderOrganizationByArtistSubtitle,
example: 'SpotiFLAC/Artist Name/Track.flac',
isSelected: current == 'artist',
onTap: () {
@@ -656,8 +657,8 @@ class DownloadSettingsPage extends ConsumerWidget {
},
),
_FolderOption(
title: 'By Album',
subtitle: 'Separate folder for each album',
title: context.l10n.folderOrganizationByAlbum,
subtitle: context.l10n.folderOrganizationByAlbumSubtitle,
example: 'SpotiFLAC/Album Name/Track.flac',
isSelected: current == 'album',
onTap: () {
@@ -666,8 +667,8 @@ class DownloadSettingsPage extends ConsumerWidget {
},
),
_FolderOption(
title: 'By Artist & Album',
subtitle: 'Nested folders for artist and album',
title: context.l10n.folderOrganizationByArtistAlbum,
subtitle: context.l10n.folderOrganizationByArtistAlbumSubtitle,
example: 'SpotiFLAC/Artist/Album/Track.flac',
isSelected: current == 'artist_album',
onTap: () {
+34 -34
View File
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/store_provider.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
@@ -186,12 +187,12 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
),
],
const SizedBox(height: 16),
_InfoRow(label: 'Author', value: extension.author),
_InfoRow(label: 'ID', value: extension.id),
_InfoRow(label: 'Version', value: 'v${extension.version}'),
_InfoRow(label: context.l10n.extensionAuthor, value: extension.author),
_InfoRow(label: context.l10n.extensionId, value: extension.id),
_InfoRow(label: context.l10n.extensionsVersion(extension.version), value: ''),
if (hasError && extension.errorMessage != null)
_InfoRow(
label: 'Error',
label: context.l10n.extensionError,
value: extension.errorMessage!,
isError: true,
),
@@ -202,50 +203,50 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
),
// Capabilities
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Capabilities'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionCapabilities),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_CapabilityItem(
icon: Icons.search,
title: 'Metadata Provider',
title: context.l10n.extensionMetadataProvider,
enabled: extension.hasMetadataProvider,
),
_CapabilityItem(
icon: Icons.download,
title: 'Download Provider',
title: context.l10n.extensionDownloadProvider,
enabled: extension.hasDownloadProvider,
),
_CapabilityItem(
icon: Icons.manage_search,
title: 'Custom Search',
title: context.l10n.extensionsSearchProvider,
enabled: extension.hasCustomSearch,
subtitle: extension.searchBehavior?.placeholder,
),
_CapabilityItem(
icon: Icons.compare_arrows,
title: 'Custom Track Matching',
title: context.l10n.extensionCustomTrackMatching,
enabled: extension.hasCustomMatching,
subtitle: extension.trackMatching?.strategy != null
? 'Strategy: ${extension.trackMatching!.strategy}'
? context.l10n.extensionStrategy(extension.trackMatching!.strategy!)
: null,
),
_CapabilityItem(
icon: Icons.auto_fix_high,
title: 'Post-Processing',
title: context.l10n.extensionPostProcessing,
enabled: extension.hasPostProcessing,
subtitle: extension.postProcessing?.hooks.isNotEmpty == true
? '${extension.postProcessing!.hooks.length} hook(s) available'
? context.l10n.extensionHooksAvailable(extension.postProcessing!.hooks.length)
: null,
),
_CapabilityItem(
icon: Icons.link,
title: 'URL Handler',
title: context.l10n.extensionUrlHandler,
enabled: extension.hasURLHandler,
subtitle: extension.urlHandler?.patterns.isNotEmpty == true
? '${extension.urlHandler!.patterns.length} pattern(s)'
? context.l10n.extensionPatternsCount(extension.urlHandler!.patterns.length)
: null,
showDivider: false,
),
@@ -257,8 +258,8 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
// URL Handler Section (if extension handles URLs)
if (extension.hasURLHandler && extension.urlHandler!.patterns.isNotEmpty) ...[
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'URL Handler'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionUrlHandler),
),
SliverToBoxAdapter(
child: SettingsGroup(
@@ -273,8 +274,8 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
// Quality Options Section (for download providers)
if (extension.hasDownloadProvider && extension.qualityOptions.isNotEmpty) ...[
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Quality Options'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionQualityOptions),
),
SliverToBoxAdapter(
child: SettingsGroup(
@@ -292,8 +293,8 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
// Post-Processing Hooks (if available)
if (extension.hasPostProcessing && extension.postProcessing!.hooks.isNotEmpty) ...[
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Post-Processing Hooks'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionPostProcessingHooks),
),
SliverToBoxAdapter(
child: SettingsGroup(
@@ -311,8 +312,8 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
// Permissions
if (extension.permissions.isNotEmpty) ...[
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Permissions'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionPermissions),
),
SliverToBoxAdapter(
child: SettingsGroup(
@@ -330,8 +331,8 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
// Settings
if (extension.settings.isNotEmpty) ...[
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Settings'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionSettings),
),
if (_isLoadingSettings)
const SliverToBoxAdapter(
@@ -364,7 +365,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
child: OutlinedButton.icon(
onPressed: () => _confirmRemove(context),
icon: const Icon(Icons.delete_outline),
label: const Text('Remove Extension'),
label: Text(context.l10n.extensionRemoveButton),
style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.error,
side: BorderSide(color: colorScheme.error),
@@ -398,22 +399,21 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Remove Extension'),
content: const Text(
'Are you sure you want to remove this extension? '
'This action cannot be undone.',
title: Text(context.l10n.dialogRemoveExtension),
content: Text(
context.l10n.dialogRemoveExtensionMessage,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
style: FilledButton.styleFrom(
backgroundColor: colorScheme.error,
),
child: const Text('Remove'),
child: Text(context.l10n.dialogRemove),
),
],
),
@@ -725,7 +725,7 @@ class _SettingItem extends StatelessWidget {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () {
@@ -735,7 +735,7 @@ class _SettingItem extends StatelessWidget {
onChanged(newValue);
Navigator.pop(context);
},
child: const Text('Save'),
child: Text(context.l10n.dialogSave),
),
],
),
+29 -29
View File
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/settings/extension_detail_page.dart';
@@ -74,7 +75,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
'Extensions',
context.l10n.extensionsTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
@@ -123,8 +124,8 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
),
// Provider Priority
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Provider Priority'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionsProviderPrioritySection),
),
SliverToBoxAdapter(
child: SettingsGroup(
@@ -137,8 +138,8 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
),
// Installed Extensions
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Installed Extensions'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionsInstalledSection),
),
if (extState.extensions.isEmpty && !extState.isLoading)
@@ -160,14 +161,14 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
),
const SizedBox(height: 12),
Text(
'No extensions installed',
context.l10n.extensionsNoExtensions,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
'Install .spotiflac-ext files to add new providers',
context.l10n.extensionsNoExtensionsSubtitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -209,7 +210,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
child: FilledButton.icon(
onPressed: _installExtension,
icon: const Icon(Icons.add),
label: const Text('Install Extension'),
label: Text(context.l10n.extensionsInstallButton),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
@@ -236,8 +237,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
const SizedBox(width: 12),
Expanded(
child: Text(
'Extensions can add new metadata and download providers. '
'Only install extensions from trusted sources.',
context.l10n.extensionsInfoTip,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onTertiaryContainer,
),
@@ -266,8 +266,8 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
if (!file.path!.endsWith('.spotiflac-ext')) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please select a .spotiflac-ext file'),
SnackBar(
content: Text(context.l10n.snackbarSelectExtFile),
),
);
}
@@ -282,7 +282,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
final extState = ref.read(extensionProvider);
String message;
if (success) {
message = 'Extension installed successfully';
message = context.l10n.extensionsInstalledSuccess;
} else {
// Parse friendly error message
message = _getFriendlyErrorMessage(extState.error);
@@ -404,8 +404,8 @@ class _ExtensionItem extends StatelessWidget {
const SizedBox(height: 2),
Text(
hasError
? extension.errorMessage ?? 'Error loading extension'
: 'v${extension.version} by ${extension.author}',
? extension.errorMessage ?? context.l10n.extensionsErrorLoading
: 'v${extension.version} ${context.l10n.extensionsAuthor(extension.author)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: hasError
? colorScheme.error
@@ -474,7 +474,7 @@ class _DownloadPriorityItem extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Download Priority',
context.l10n.extensionsDownloadPriority,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: hasDownloadExtensions
? null
@@ -484,8 +484,8 @@ class _DownloadPriorityItem extends ConsumerWidget {
const SizedBox(height: 2),
Text(
hasDownloadExtensions
? 'Set download service order'
: 'No extensions with download provider',
? context.l10n.extensionsDownloadPrioritySubtitle
: context.l10n.extensionsNoDownloadProvider,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -543,7 +543,7 @@ class _MetadataPriorityItem extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Metadata Priority',
context.l10n.extensionsMetadataPriority,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: hasMetadataExtensions
? null
@@ -553,8 +553,8 @@ class _MetadataPriorityItem extends ConsumerWidget {
const SizedBox(height: 2),
Text(
hasMetadataExtensions
? 'Set search & metadata source order'
: 'No extensions with metadata provider',
? context.l10n.extensionsMetadataPrioritySubtitle
: context.l10n.extensionsNoMetadataProvider,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -590,7 +590,7 @@ class _SearchProviderSelector extends ConsumerWidget {
.toList();
// Get current provider name
String currentProviderName = 'Default (Deezer/Spotify)';
String currentProviderName = context.l10n.extensionDefaultProvider;
if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) {
final ext = searchProviders.where((e) => e.id == settings.searchProvider).firstOrNull;
currentProviderName = ext?.displayName ?? settings.searchProvider!;
@@ -619,7 +619,7 @@ class _SearchProviderSelector extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Search Provider',
context.l10n.extensionsSearchProvider,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: searchProviders.isEmpty
? colorScheme.outline
@@ -629,7 +629,7 @@ class _SearchProviderSelector extends ConsumerWidget {
const SizedBox(height: 2),
Text(
searchProviders.isEmpty
? 'No extensions with custom search'
? context.l10n.extensionsNoCustomSearch
: currentProviderName,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
@@ -674,7 +674,7 @@ class _SearchProviderSelector extends ConsumerWidget {
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
'Search Provider',
ctx.l10n.extensionsSearchProvider,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
@@ -683,7 +683,7 @@ class _SearchProviderSelector extends ConsumerWidget {
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'Choose which service to use for searching tracks',
ctx.l10n.extensionsSearchProviderDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -692,8 +692,8 @@ class _SearchProviderSelector extends ConsumerWidget {
// Default option
ListTile(
leading: Icon(Icons.music_note, color: colorScheme.primary),
title: const Text('Default (Deezer/Spotify)'),
subtitle: const Text('Use built-in search'),
title: Text(ctx.l10n.extensionDefaultProvider),
subtitle: Text(ctx.l10n.extensionDefaultProviderSubtitle),
trailing: (settings.searchProvider == null || settings.searchProvider!.isEmpty)
? Icon(Icons.check_circle, color: colorScheme.primary)
: Icon(Icons.circle_outlined, color: colorScheme.outline),
@@ -706,7 +706,7 @@ class _SearchProviderSelector extends ConsumerWidget {
...searchProviders.map((ext) => ListTile(
leading: Icon(Icons.extension, color: colorScheme.secondary),
title: Text(ext.displayName),
subtitle: Text(ext.searchBehavior?.placeholder ?? 'Custom search'),
subtitle: Text(ext.searchBehavior?.placeholder ?? ctx.l10n.extensionsCustomSearch),
trailing: settings.searchProvider == ext.id
? Icon(Icons.check_circle, color: colorScheme.primary)
: Icon(Icons.circle_outlined, color: colorScheme.outline),
+23 -20
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus;
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
@@ -67,7 +68,7 @@ class _LogScreenState extends State<LogScreen> {
Clipboard.setData(ClipboardData(text: logs));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Logs copied to clipboard'),
content: Text(context.l10n.logCopied),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
duration: const Duration(seconds: 2),
@@ -84,19 +85,19 @@ class _LogScreenState extends State<LogScreen> {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear Logs'),
content: const Text('Are you sure you want to clear all logs?'),
title: Text(context.l10n.logClearLogsTitle),
content: Text(context.l10n.logClearLogsMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () {
LogBuffer().clear();
Navigator.pop(context);
},
child: const Text('Clear'),
child: Text(context.l10n.dialogClear),
),
],
),
@@ -166,19 +167,19 @@ class _LogScreenState extends State<LogScreen> {
}
},
itemBuilder: (context) => [
const PopupMenuItem(
PopupMenuItem(
value: 'share',
child: ListTile(
leading: Icon(Icons.share),
title: Text('Share logs'),
leading: const Icon(Icons.share),
title: Text(context.l10n.logShareLogs),
contentPadding: EdgeInsets.zero,
),
),
const PopupMenuItem(
PopupMenuItem(
value: 'clear',
child: ListTile(
leading: Icon(Icons.delete_outline),
title: Text('Clear logs'),
leading: const Icon(Icons.delete_outline),
title: Text(context.l10n.logClearLogs),
contentPadding: EdgeInsets.zero,
),
),
@@ -195,7 +196,7 @@ class _LogScreenState extends State<LogScreen> {
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
'Logs',
context.l10n.logTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
@@ -208,8 +209,8 @@ class _LogScreenState extends State<LogScreen> {
),
// Filter section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Filter'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.logFilterSection),
),
SliverToBoxAdapter(
child: SettingsGroup(
@@ -225,10 +226,10 @@ class _LogScreenState extends State<LogScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Level', style: Theme.of(context).textTheme.bodyLarge),
Text(context.l10n.logFilterLevel, style: Theme.of(context).textTheme.bodyLarge),
const SizedBox(height: 2),
Text(
'Filter logs by severity',
context.l10n.logFilterBySeverity,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -279,7 +280,7 @@ class _LogScreenState extends State<LogScreen> {
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search logs...',
hintText: context.l10n.logSearchHint,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
@@ -316,7 +317,9 @@ class _LogScreenState extends State<LogScreen> {
// Log entries section
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: 'Entries (${logs.length}${_selectedLevel != 'ALL' || _searchQuery.isNotEmpty ? ' filtered' : ''})',
title: _selectedLevel != 'ALL' || _searchQuery.isNotEmpty
? context.l10n.logEntriesFiltered(logs.length)
: context.l10n.logEntries(logs.length),
),
),
@@ -342,14 +345,14 @@ class _LogScreenState extends State<LogScreen> {
),
const SizedBox(height: 16),
Text(
'No logs yet',
context.l10n.logNoLogsYet,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
'Logs will appear here as you use the app',
context.l10n.logNoLogsYetSubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
class MetadataProviderPriorityPage extends ConsumerStatefulWidget {
@@ -81,7 +82,7 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
if (_hasChanges)
TextButton(
onPressed: _saveChanges,
child: const Text('Save'),
child: Text(context.l10n.dialogSave),
),
],
flexibleSpace: LayoutBuilder(
@@ -96,7 +97,7 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
'Metadata Priority',
context.l10n.metadataProviderPriorityTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
@@ -113,8 +114,7 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Drag to reorder metadata providers. The app will try providers '
'from top to bottom when searching for tracks and fetching metadata.',
context.l10n.metadataProviderPriorityDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -166,8 +166,7 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
const SizedBox(width: 12),
Expanded(
child: Text(
'Deezer has no rate limits and is recommended as primary. '
'Spotify may rate limit after many requests.',
context.l10n.metadataProviderPriorityInfo,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onTertiaryContainer,
),
@@ -190,16 +189,16 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Discard Changes?'),
content: const Text('You have unsaved changes. Do you want to discard them?'),
title: Text(context.l10n.dialogDiscardChanges),
content: Text(context.l10n.dialogUnsavedChanges),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Discard'),
child: Text(context.l10n.dialogDiscard),
),
],
),
@@ -214,7 +213,7 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Metadata provider priority saved')),
SnackBar(content: Text(context.l10n.snackbarMetadataProviderSaved)),
);
}
}
@@ -246,7 +245,7 @@ class _MetadataProviderItem extends StatelessWidget {
)
: colorScheme.surfaceContainerHigh;
final info = _getProviderInfo(provider);
final info = _getProviderInfo(context, provider);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
@@ -323,20 +322,20 @@ class _MetadataProviderItem extends StatelessWidget {
);
}
_MetadataProviderInfo _getProviderInfo(String provider) {
_MetadataProviderInfo _getProviderInfo(BuildContext context, String provider) {
switch (provider) {
case 'deezer':
return _MetadataProviderInfo(
name: 'Deezer',
icon: Icons.album,
description: 'No rate limits',
description: context.l10n.metadataNoRateLimits,
isBuiltIn: true,
);
case 'spotify':
return _MetadataProviderInfo(
name: 'Spotify',
icon: Icons.music_note,
description: 'May rate limit',
description: context.l10n.metadataMayRateLimit,
isBuiltIn: true,
);
default:
@@ -344,7 +343,7 @@ class _MetadataProviderItem extends StatelessWidget {
return _MetadataProviderInfo(
name: provider,
icon: Icons.extension,
description: 'Extension',
description: context.l10n.providerExtension,
isBuiltIn: false,
);
}
+70 -69
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
@@ -50,7 +51,7 @@ class OptionsSettingsPage extends ConsumerWidget {
bottom: 16,
),
title: Text(
'Options',
context.l10n.optionsTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
@@ -63,8 +64,8 @@ class OptionsSettingsPage extends ConsumerWidget {
),
// Search Source section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Search Source'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionSearchSource),
),
SliverToBoxAdapter(
child: SettingsGroup(
@@ -93,7 +94,7 @@ class OptionsSettingsPage extends ConsumerWidget {
const SizedBox(width: 12),
Expanded(
child: Text(
'Spotify requires your own API credentials. Get them free from developer.spotify.com',
context.l10n.optionsSpotifyWarning,
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer,
fontSize: 12,
@@ -107,10 +108,10 @@ class OptionsSettingsPage extends ConsumerWidget {
),
SettingsItem(
icon: Icons.key,
title: 'Spotify Credentials',
title: context.l10n.optionsSpotifyCredentials,
subtitle: settings.spotifyClientId.isNotEmpty
? 'Client ID: ${settings.spotifyClientId.length > 8 ? '${settings.spotifyClientId.substring(0, 8)}...' : settings.spotifyClientId}'
: 'Required - tap to configure',
? context.l10n.optionsSpotifyCredentialsConfigured(settings.spotifyClientId.length > 8 ? settings.spotifyClientId.substring(0, 8) : settings.spotifyClientId)
: context.l10n.optionsSpotifyCredentialsRequired,
onTap: () =>
_showSpotifyCredentialsDialog(context, ref, settings),
trailing: Icon(
@@ -130,16 +131,16 @@ class OptionsSettingsPage extends ConsumerWidget {
),
// Download options section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Download'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionDownload),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.sync,
title: 'Auto Fallback',
subtitle: 'Try other services if download fails',
title: context.l10n.optionsAutoFallback,
subtitle: context.l10n.optionsAutoFallbackSubtitle,
value: settings.autoFallback,
onChanged: (v) =>
ref.read(settingsProvider.notifier).setAutoFallback(v),
@@ -147,10 +148,10 @@ class OptionsSettingsPage extends ConsumerWidget {
if (hasExtensions)
SettingsSwitchItem(
icon: Icons.extension,
title: 'Use Extension Providers',
title: context.l10n.optionsUseExtensionProviders,
subtitle: settings.useExtensionProviders
? 'Extensions will be tried first'
: 'Using built-in providers only',
? context.l10n.optionsUseExtensionProvidersOn
: context.l10n.optionsUseExtensionProvidersOff,
value: settings.useExtensionProviders,
onChanged: (v) => ref
.read(settingsProvider.notifier)
@@ -158,16 +159,16 @@ class OptionsSettingsPage extends ConsumerWidget {
),
SettingsSwitchItem(
icon: Icons.lyrics,
title: 'Embed Lyrics',
subtitle: 'Embed synced lyrics into FLAC files',
title: context.l10n.optionsEmbedLyrics,
subtitle: context.l10n.optionsEmbedLyricsSubtitle,
value: settings.embedLyrics,
onChanged: (v) =>
ref.read(settingsProvider.notifier).setEmbedLyrics(v),
),
SettingsSwitchItem(
icon: Icons.image,
title: 'Max Quality Cover',
subtitle: 'Download highest resolution cover art',
title: context.l10n.optionsMaxQualityCover,
subtitle: context.l10n.optionsMaxQualityCoverSubtitle,
value: settings.maxQualityCover,
onChanged: (v) => ref
.read(settingsProvider.notifier)
@@ -179,8 +180,8 @@ class OptionsSettingsPage extends ConsumerWidget {
),
// Performance section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Performance'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionPerformance),
),
SliverToBoxAdapter(
child: SettingsGroup(
@@ -196,16 +197,16 @@ class OptionsSettingsPage extends ConsumerWidget {
),
// App section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'App'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionApp),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.store,
title: 'Extension Store',
subtitle: 'Show Store tab in navigation',
title: context.l10n.optionsExtensionStore,
subtitle: context.l10n.optionsExtensionStoreSubtitle,
value: settings.showExtensionStore,
onChanged: (v) => ref
.read(settingsProvider.notifier)
@@ -213,8 +214,8 @@ class OptionsSettingsPage extends ConsumerWidget {
),
SettingsSwitchItem(
icon: Icons.system_update,
title: 'Check for Updates',
subtitle: 'Notify when new version is available',
title: context.l10n.optionsCheckUpdates,
subtitle: context.l10n.optionsCheckUpdatesSubtitle,
value: settings.checkForUpdates,
onChanged: (v) => ref
.read(settingsProvider.notifier)
@@ -230,16 +231,16 @@ class OptionsSettingsPage extends ConsumerWidget {
),
// Data section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Data'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionData),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.delete_forever,
title: 'Clear Download History',
subtitle: 'Remove all downloaded tracks from history',
title: context.l10n.optionsClearHistory,
subtitle: context.l10n.optionsClearHistorySubtitle,
onTap: () =>
_showClearHistoryDialog(context, ref, colorScheme),
showDivider: false,
@@ -249,18 +250,18 @@ class OptionsSettingsPage extends ConsumerWidget {
),
// Debug section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Debug'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionDebug),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.bug_report,
title: 'Detailed Logging',
title: context.l10n.optionsDetailedLogging,
subtitle: settings.enableLogging
? 'Detailed logs are being recorded'
: 'Enable for bug reports',
? context.l10n.optionsDetailedLoggingOn
: context.l10n.optionsDetailedLoggingOff,
value: settings.enableLogging,
onChanged: (v) =>
ref.read(settingsProvider.notifier).setEnableLogging(v),
@@ -285,14 +286,14 @@ class OptionsSettingsPage extends ConsumerWidget {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear History'),
content: const Text(
'Are you sure you want to clear all download history? This cannot be undone.',
title: Text(context.l10n.dialogClearHistoryTitle),
content: Text(
context.l10n.dialogClearHistoryMessage,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
TextButton(
onPressed: () {
@@ -300,9 +301,9 @@ class OptionsSettingsPage extends ConsumerWidget {
Navigator.pop(context);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('History cleared')));
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarHistoryCleared)));
},
child: Text('Clear', style: TextStyle(color: colorScheme.error)),
child: Text(context.l10n.dialogClear, style: TextStyle(color: colorScheme.error)),
),
],
),
@@ -353,7 +354,7 @@ class OptionsSettingsPage extends ConsumerWidget {
),
),
Text(
'Spotify Credentials',
context.l10n.credentialsTitle,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
@@ -361,7 +362,7 @@ class OptionsSettingsPage extends ConsumerWidget {
),
const SizedBox(height: 8),
Text(
'Enter your Client ID and Secret to use your own Spotify application quota.',
context.l10n.credentialsDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -373,8 +374,8 @@ class OptionsSettingsPage extends ConsumerWidget {
TextField(
controller: clientIdController,
decoration: InputDecoration(
labelText: 'Client ID',
hintText: 'Paste Client ID',
labelText: context.l10n.credentialsClientId,
hintText: context.l10n.credentialsClientIdHint,
filled: true,
fillColor: colorScheme.surfaceContainerHighest.withValues(
alpha: 0.3,
@@ -412,8 +413,8 @@ class OptionsSettingsPage extends ConsumerWidget {
controller: clientSecretController,
obscureText: true,
decoration: InputDecoration(
labelText: 'Client Secret',
hintText: 'Paste Client Secret',
labelText: context.l10n.credentialsClientSecret,
hintText: context.l10n.credentialsClientSecretHint,
filled: true,
fillColor: colorScheme.surfaceContainerHighest.withValues(
alpha: 0.3,
@@ -458,12 +459,12 @@ class OptionsSettingsPage extends ConsumerWidget {
.setSpotifyCredentials(clientId, clientSecret);
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Credentials saved')),
SnackBar(content: Text(context.l10n.snackbarCredentialsSaved)),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please fill all fields'),
SnackBar(
content: Text(context.l10n.snackbarFillAllFields),
),
);
}
@@ -474,9 +475,9 @@ class OptionsSettingsPage extends ConsumerWidget {
borderRadius: BorderRadius.circular(16),
),
),
child: const Text(
'Save Credentials',
style: TextStyle(fontWeight: FontWeight.bold),
child: Text(
context.l10n.actionSaveCredentials,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
@@ -489,14 +490,14 @@ class OptionsSettingsPage extends ConsumerWidget {
.clearSpotifyCredentials();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Credentials cleared')),
SnackBar(content: Text(context.l10n.snackbarCredentialsCleared)),
);
},
style: TextButton.styleFrom(
foregroundColor: colorScheme.error,
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: const Text('Remove Credentials'),
child: Text(context.l10n.actionRemoveCredentials),
),
],
@@ -540,14 +541,14 @@ class _ConcurrentDownloadsItem extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Concurrent Downloads',
context.l10n.optionsConcurrentDownloads,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 2),
Text(
currentValue == 1
? 'Sequential (1 at a time)'
: '$currentValue parallel downloads',
? context.l10n.optionsConcurrentSequential
: context.l10n.optionsConcurrentParallel(currentValue),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -590,7 +591,7 @@ class _ConcurrentDownloadsItem extends StatelessWidget {
const SizedBox(width: 8),
Expanded(
child: Text(
'Parallel downloads may trigger rate limiting',
context.l10n.optionsConcurrentWarning,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: colorScheme.error),
@@ -682,14 +683,14 @@ class _UpdateChannelSelector extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Update Channel',
context.l10n.optionsUpdateChannel,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 2),
Text(
currentChannel == 'preview'
? 'Get preview releases'
: 'Stable releases only',
? context.l10n.optionsUpdateChannelPreview
: context.l10n.optionsUpdateChannelStable,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -703,13 +704,13 @@ class _UpdateChannelSelector extends StatelessWidget {
Row(
children: [
_ChannelChip(
label: 'Stable',
label: context.l10n.channelStable,
isSelected: currentChannel == 'stable',
onTap: () => onChanged('stable'),
),
const SizedBox(width: 8),
_ChannelChip(
label: 'Preview',
label: context.l10n.channelPreview,
isSelected: currentChannel == 'preview',
onTap: () => onChanged('preview'),
),
@@ -726,7 +727,7 @@ class _UpdateChannelSelector extends StatelessWidget {
const SizedBox(width: 8),
Expanded(
child: Text(
'Preview may contain bugs or incomplete features',
context.l10n.optionsUpdateChannelWarning,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -823,7 +824,7 @@ class _MetadataSourceSelector extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Primary Provider',
context.l10n.optionsPrimaryProvider,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500),
@@ -831,8 +832,8 @@ class _MetadataSourceSelector extends ConsumerWidget {
const SizedBox(height: 4),
Text(
hasExtensionSearch
? 'Using extension: $extensionName'
: 'Service used when searching by track name.',
? context.l10n.optionsUsingExtension(extensionName!)
: context.l10n.optionsPrimaryProviderSubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: hasExtensionSearch
? colorScheme.primary
@@ -883,7 +884,7 @@ class _MetadataSourceSelector extends ConsumerWidget {
const SizedBox(width: 8),
Expanded(
child: Text(
'Tap Deezer or Spotify to switch back from extension',
context.l10n.optionsSwitchBack,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
class ProviderPriorityPage extends ConsumerStatefulWidget {
@@ -82,7 +83,7 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
if (_hasChanges)
TextButton(
onPressed: _saveChanges,
child: const Text('Save'),
child: Text(context.l10n.dialogSave),
),
],
flexibleSpace: LayoutBuilder(
@@ -97,7 +98,7 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
'Provider Priority',
context.l10n.providerPriorityTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
@@ -114,8 +115,7 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Drag to reorder download providers. The app will try providers '
'from top to bottom when downloading tracks.',
context.l10n.providerPriorityDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -167,8 +167,7 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
const SizedBox(width: 12),
Expanded(
child: Text(
'If a track is not available on the first provider, '
'the app will automatically try the next one.',
context.l10n.providerPriorityInfo,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onTertiaryContainer,
),
@@ -191,16 +190,16 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Discard Changes?'),
content: const Text('You have unsaved changes. Do you want to discard them?'),
title: Text(context.l10n.dialogDiscardChanges),
content: Text(context.l10n.dialogUnsavedChanges),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Discard'),
child: Text(context.l10n.dialogDiscard),
),
],
),
@@ -215,7 +214,7 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Provider priority saved')),
SnackBar(content: Text(context.l10n.snackbarProviderPrioritySaved)),
);
}
}
@@ -304,7 +303,7 @@ class _ProviderItem extends StatelessWidget {
),
),
Text(
info.isBuiltIn ? 'Built-in' : 'Extension',
info.isBuiltIn ? context.l10n.providerBuiltIn : context.l10n.providerExtension,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
+58 -47
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/screens/settings/appearance_settings_page.dart';
import 'package:spotiflac_android/screens/settings/download_settings_page.dart';
import 'package:spotiflac_android/screens/settings/extensions_page.dart';
@@ -41,7 +42,7 @@ class SettingsTab extends ConsumerWidget {
expandedTitleScale: 1.0,
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
title: Text(
'Settings',
context.l10n.settingsTitle,
style: TextStyle(
fontSize: 20 + (14 * expandRatio), // 20 -> 34
fontWeight: FontWeight.bold,
@@ -55,57 +56,67 @@ class SettingsTab extends ConsumerWidget {
// First group: Appearance & Download
SliverToBoxAdapter(
child: SettingsGroup(
margin: const EdgeInsets.fromLTRB(16, 16, 16, 4),
children: [
SettingsItem(
icon: Icons.palette_outlined,
title: 'Appearance',
subtitle: 'Theme, colors, display',
onTap: () =>
_navigateTo(context, const AppearanceSettingsPage()),
),
SettingsItem(
icon: Icons.download_outlined,
title: 'Download',
subtitle: 'Service, quality, filename format',
onTap: () => _navigateTo(context, const DownloadSettingsPage()),
),
SettingsItem(
icon: Icons.tune_outlined,
title: 'Options',
subtitle: 'Fallback, lyrics, cover art, updates',
onTap: () => _navigateTo(context, const OptionsSettingsPage()),
),
SettingsItem(
icon: Icons.extension_outlined,
title: 'Extensions',
subtitle: 'Manage download providers',
onTap: () => _navigateTo(context, const ExtensionsPage()),
showDivider: false,
),
],
child: Builder(
builder: (context) {
final l10n = context.l10n;
return SettingsGroup(
margin: const EdgeInsets.fromLTRB(16, 16, 16, 4),
children: [
SettingsItem(
icon: Icons.palette_outlined,
title: l10n.settingsAppearance,
subtitle: l10n.settingsAppearanceSubtitle,
onTap: () =>
_navigateTo(context, const AppearanceSettingsPage()),
),
SettingsItem(
icon: Icons.download_outlined,
title: l10n.settingsDownload,
subtitle: l10n.settingsDownloadSubtitle,
onTap: () => _navigateTo(context, const DownloadSettingsPage()),
),
SettingsItem(
icon: Icons.tune_outlined,
title: l10n.settingsOptions,
subtitle: l10n.settingsOptionsSubtitle,
onTap: () => _navigateTo(context, const OptionsSettingsPage()),
),
SettingsItem(
icon: Icons.extension_outlined,
title: l10n.settingsExtensions,
subtitle: l10n.settingsExtensionsSubtitle,
onTap: () => _navigateTo(context, const ExtensionsPage()),
showDivider: false,
),
],
);
},
),
),
// Second group: Logs & About
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.article_outlined,
title: 'Logs',
subtitle: 'View app logs for debugging',
onTap: () => _navigateTo(context, const LogScreen()),
),
SettingsItem(
icon: Icons.info_outline,
title: 'About',
subtitle: 'Version ${AppInfo.version}, credits, GitHub',
onTap: () => _navigateTo(context, const AboutPage()),
showDivider: false,
),
],
child: Builder(
builder: (context) {
final l10n = context.l10n;
return SettingsGroup(
children: [
SettingsItem(
icon: Icons.article_outlined,
title: l10n.logTitle,
subtitle: l10n.settingsLogsSubtitle,
onTap: () => _navigateTo(context, const LogScreen()),
),
SettingsItem(
icon: Icons.info_outline,
title: l10n.settingsAbout,
subtitle: '${l10n.aboutVersion} ${AppInfo.version}',
onTap: () => _navigateTo(context, const AboutPage()),
showDivider: false,
),
],
);
},
),
),
+60 -60
View File
@@ -8,6 +8,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:go_router/go_router.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
class SetupScreen extends ConsumerStatefulWidget {
const SetupScreen({super.key});
@@ -123,19 +124,19 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
final shouldOpen = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Storage Access Required'),
content: const Text(
'SpotiFLAC needs "All files access" permission to save music files to your chosen folder.\n\n'
'Please enable "Allow access to manage all files" in the next screen.',
title: Text(context.l10n.setupStorageAccessRequired),
content: Text(
'${context.l10n.setupStorageAccessMessage}\n\n'
'${context.l10n.setupAllowAccessToManageFiles}',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Open Settings'),
child: Text(context.l10n.setupOpenSettings),
),
],
),
@@ -166,19 +167,19 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
final shouldOpen = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Storage Access Required'),
content: const Text(
'Android 11+ requires "All files access" permission to save music files.\n\n'
'Please enable "Allow access to manage all files" in the next screen.',
title: Text(context.l10n.setupStorageAccessRequired),
content: Text(
'${context.l10n.setupStorageAccessMessageAndroid11}\n\n'
'${context.l10n.setupAllowAccessToManageFiles}',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Open Settings'),
child: Text(context.l10n.setupOpenSettings),
),
],
),
@@ -211,7 +212,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Permission denied. Please grant all permissions to continue.')),
SnackBar(content: Text(context.l10n.setupPermissionDeniedMessage)),
);
}
}
@@ -256,22 +257,21 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('$permissionType Permission Required'),
title: Text(context.l10n.setupPermissionRequired(permissionType)),
content: Text(
'$permissionType permission is required for the best experience. '
'Please grant permission in app settings.',
context.l10n.setupPermissionRequiredMessage(permissionType),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
TextButton(
onPressed: () {
Navigator.pop(context);
openAppSettings();
},
child: const Text('Open Settings'),
child: Text(context.l10n.setupOpenSettings),
),
],
),
@@ -288,7 +288,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
} else {
// Android: Use file picker
String? selectedDirectory = await FilePicker.platform.getDirectoryPath(
dialogTitle: 'Select Download Folder',
dialogTitle: context.l10n.setupSelectDownloadFolder,
);
if (selectedDirectory != null) {
@@ -299,11 +299,11 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
final useDefault = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Use Default Folder?'),
content: Text('No folder selected. Would you like to use the default Music folder?\n\n$defaultDir'),
title: Text(context.l10n.setupUseDefaultFolder),
content: Text('${context.l10n.setupNoFolderSelected}\n\n$defaultDir'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Use Default')),
TextButton(onPressed: () => Navigator.pop(context, false), child: Text(context.l10n.dialogCancel)),
TextButton(onPressed: () => Navigator.pop(context, true), child: Text(context.l10n.setupUseDefault)),
],
),
);
@@ -333,19 +333,19 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text('Download Location', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
child: Text(context.l10n.setupDownloadLocationTitle, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'On iOS, downloads are saved to the app\'s Documents folder which is accessible via the Files app.',
context.l10n.setupDownloadLocationIosMessage,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
),
),
ListTile(
leading: Icon(Icons.folder_special, color: colorScheme.primary),
title: const Text('App Documents Folder'),
subtitle: const Text('Recommended - accessible via Files app'),
title: Text(context.l10n.setupAppDocumentsFolder),
subtitle: Text(context.l10n.setupAppDocumentsFolderSubtitle),
trailing: Icon(Icons.check_circle, color: colorScheme.primary),
onTap: () async {
final dir = await _getDefaultDirectory();
@@ -355,8 +355,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
),
ListTile(
leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant),
title: const Text('Choose from Files'),
subtitle: const Text('Select iCloud or other location'),
title: Text(context.l10n.setupChooseFromFiles),
subtitle: Text(context.l10n.setupChooseFromFilesSubtitle),
onTap: () async {
Navigator.pop(ctx);
// Note: iOS requires folder to have at least one file to be selectable
@@ -380,7 +380,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
const SizedBox(width: 12),
Expanded(
child: Text(
'iOS limitation: Empty folders cannot be selected. Create a file inside first or use App Documents.',
context.l10n.setupIosEmptyFolderWarning,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onTertiaryContainer),
),
),
@@ -486,16 +486,16 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Column(
children: [
const SizedBox(height: 24),
ClipRRect(
ClipRRect(
borderRadius: BorderRadius.circular(24),
child: Image.asset('assets/images/logo.png', width: 96, height: 96),
),
const SizedBox(height: 12),
Text('SpotiFLAC',
Text(context.l10n.appName,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold, color: colorScheme.primary)),
const SizedBox(height: 4),
Text('Download Spotify tracks in FLAC',
Text(context.l10n.setupDownloadInFlac,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant)),
],
@@ -529,8 +529,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Widget _buildStepIndicator(ColorScheme colorScheme) {
final steps = _androidSdkVersion >= 33
? ['Storage', 'Notification', 'Folder', 'Spotify']
: ['Permission', 'Folder', 'Spotify'];
? [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,
@@ -653,7 +653,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
),
const SizedBox(height: 20),
Text(
_storagePermissionGranted ? 'Storage Permission Granted!' : 'Storage Permission Required',
_storagePermissionGranted ? context.l10n.setupStorageGranted : context.l10n.setupStorageRequired,
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
@@ -662,8 +662,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
_storagePermissionGranted
? 'You can now proceed to the next step.'
: 'SpotiFLAC needs storage access to save downloaded music files to your device.',
? context.l10n.setupProceedToNextStep
: context.l10n.setupStorageDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
),
@@ -676,7 +676,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
? SizedBox(width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
: const Icon(Icons.security_rounded),
label: const Text('Grant Permission'),
label: Text(context.l10n.setupGrantPermission),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
@@ -707,7 +707,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
),
const SizedBox(height: 20),
Text(
_notificationPermissionGranted ? 'Notification Permission Granted!' : 'Enable Notifications',
_notificationPermissionGranted ? context.l10n.setupNotificationGranted : context.l10n.setupNotificationEnable,
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
@@ -716,8 +716,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
_notificationPermissionGranted
? 'You will receive download progress notifications.'
: 'Get notified about download progress and completion. This helps you track downloads when the app is in background.',
? context.l10n.setupNotificationProgressDescription
: context.l10n.setupNotificationBackgroundDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
),
@@ -730,7 +730,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
? SizedBox(width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
: const Icon(Icons.notifications_active_rounded),
label: const Text('Enable Notifications'),
label: Text(context.l10n.setupEnableNotifications),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
@@ -742,7 +742,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
child: const Text('Skip for now'),
child: Text(context.l10n.setupSkipForNow),
),
],
],
@@ -770,7 +770,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
),
const SizedBox(height: 20),
Text(
_selectedDirectory != null ? 'Download Folder Selected!' : 'Choose Download Folder',
_selectedDirectory != null ? context.l10n.setupFolderSelected : context.l10n.setupFolderChoose,
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
@@ -802,7 +802,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Select a folder where your downloaded music will be saved.',
context.l10n.setupFolderDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
),
@@ -814,7 +814,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
? 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 ? 'Change Folder' : 'Select Folder'),
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)),
@@ -845,7 +845,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
),
const SizedBox(height: 20),
Text(
'Spotify API (Optional)',
context.l10n.setupSpotifyApiOptional,
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
@@ -853,7 +853,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Add your Spotify API credentials for better search results, or skip to use Deezer instead.',
context.l10n.setupSpotifyApiDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
),
@@ -868,9 +868,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
clipBehavior: Clip.antiAlias,
child: SwitchListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
title: Text('Use Spotify API', style: Theme.of(context).textTheme.titleSmall),
title: Text(context.l10n.setupUseSpotifyApi, style: Theme.of(context).textTheme.titleSmall),
subtitle: Text(
_useSpotifyApi ? 'Enter your credentials below' : 'Using Deezer (no account needed)',
_useSpotifyApi ? context.l10n.setupEnterCredentialsBelow : context.l10n.setupUsingDeezer,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
),
secondary: Container(
@@ -907,12 +907,12 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Client ID
Text('Client ID', style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
Text(context.l10n.credentialsClientId, style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
const SizedBox(height: 8),
TextField(
controller: _clientIdController,
decoration: InputDecoration(
hintText: 'Enter Spotify Client ID',
hintText: context.l10n.setupEnterClientId,
prefixIcon: const Icon(Icons.key_rounded),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
@@ -926,13 +926,13 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
const SizedBox(height: 16),
// Client Secret
Text('Client Secret', style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
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: 'Enter Spotify Client Secret',
hintText: context.l10n.setupEnterClientSecret,
prefixIcon: const Icon(Icons.lock_rounded),
suffixIcon: IconButton(
icon: Icon(_showClientSecret ? Icons.visibility_off_rounded : Icons.visibility_rounded),
@@ -962,7 +962,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
const SizedBox(width: 12),
Expanded(
child: Text(
'Get credentials from developer.spotify.com',
context.l10n.setupGetCredentialsFromSpotify,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onTertiaryContainer),
),
),
@@ -995,7 +995,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
TextButton.icon(
onPressed: () => setState(() => _currentStep--),
icon: const Icon(Icons.arrow_back_rounded),
label: const Text('Back'),
label: Text(context.l10n.setupBack),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
@@ -1011,9 +1011,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
child: const Row(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [Text('Next'), SizedBox(width: 8), Icon(Icons.arrow_forward_rounded, size: 18)],
children: [Text(context.l10n.setupNext), const SizedBox(width: 8), const Icon(Icons.arrow_forward_rounded, size: 18)],
),
)
else
@@ -1029,7 +1029,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(_useSpotifyApi ? 'Get Started' : 'Skip & Start'),
Text(_useSpotifyApi ? context.l10n.setupGetStarted : context.l10n.setupSkipAndStart),
const SizedBox(width: 8),
const Icon(Icons.check_rounded, size: 18),
],
+31 -30
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/store_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
@@ -40,7 +41,7 @@ class _ExtensionDetailsScreenState
_buildInfoCard(context, liveExtension, colorScheme, isDownloading),
_buildSectionHeader(
context,
'About',
context.l10n.aboutTitle,
Icons.info_outline,
colorScheme,
),
@@ -61,7 +62,7 @@ class _ExtensionDetailsScreenState
_buildSectionHeader(
context,
'Capabilities',
context.l10n.extensionCapabilities,
Icons.extension_outlined,
colorScheme,
),
@@ -173,9 +174,9 @@ class _ExtensionDetailsScreenState
color: colorScheme.onSurface,
),
),
const SizedBox(height: 4),
const SizedBox(height: 4),
Text(
'by ${ext.author}',
context.l10n.extensionsAuthor(ext.author),
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: colorScheme.onSurfaceVariant),
),
@@ -204,7 +205,7 @@ class _ExtensionDetailsScreenState
),
if (ext.isInstalled)
_Badge(
label: 'Installed',
label: context.l10n.storeInstalled,
color: colorScheme.primaryContainer,
textColor: colorScheme.onPrimaryContainer,
icon: Icons.check,
@@ -226,7 +227,7 @@ class _ExtensionDetailsScreenState
FilledButton.icon(
onPressed: () => _updateExtension(ext),
icon: const Icon(Icons.update),
label: Text('Update to v${ext.version}'),
label: Text('${context.l10n.storeUpdate} v${ext.version}'),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52),
shape: RoundedRectangleBorder(
@@ -241,7 +242,7 @@ class _ExtensionDetailsScreenState
child: OutlinedButton.icon(
onPressed: null,
icon: const Icon(Icons.check),
label: const Text('Installed'),
label: Text(context.l10n.storeInstalled),
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 52),
shape: RoundedRectangleBorder(
@@ -262,7 +263,7 @@ class _ExtensionDetailsScreenState
borderRadius: BorderRadius.circular(16),
),
),
tooltip: 'Uninstall',
tooltip: context.l10n.extensionsUninstall,
),
],
)
@@ -270,7 +271,7 @@ class _ExtensionDetailsScreenState
FilledButton.icon(
onPressed: () => _installExtension(ext),
icon: const Icon(Icons.download),
label: const Text('Install Extension'),
label: Text(context.l10n.storeInstall),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52),
shape: RoundedRectangleBorder(
@@ -380,19 +381,19 @@ class _ExtensionDetailsScreenState
child: Column(
children: [
_MetadataRow(
label: 'Updated',
label: context.l10n.extensionUpdated,
value: ext.updatedAt.isNotEmpty
? _formatDate(ext.updatedAt)
? _formatDate(context, ext.updatedAt)
: '-',
colorScheme: colorScheme,
),
_MetadataRow(
label: 'ID',
label: context.l10n.extensionId,
value: ext.id,
colorScheme: colorScheme,
),
_MetadataRow(
label: 'Min App Version',
label: context.l10n.extensionMinAppVersion,
value: ext.minAppVersion ?? 'Any',
colorScheme: colorScheme,
isLast: true,
@@ -428,19 +429,19 @@ class _ExtensionDetailsScreenState
children: [
_CapabilityRow(
icon: Icons.search,
label: 'Metadata Provider',
label: context.l10n.extensionMetadataProvider,
enabled: isMetadataProvider,
colorScheme: colorScheme,
),
_CapabilityRow(
icon: Icons.download,
label: 'Download Provider',
label: context.l10n.extensionDownloadProvider,
enabled: isDownloadProvider,
colorScheme: colorScheme,
),
_CapabilityRow(
icon: Icons.lyrics,
label: 'Lyrics Provider',
label: context.l10n.extensionLyricsProvider,
enabled: isLyricsProvider,
colorScheme: colorScheme,
),
@@ -458,22 +459,22 @@ class _ExtensionDetailsScreenState
);
}
String _formatDate(String dateStr) {
String _formatDate(BuildContext context, String dateStr) {
try {
final date = DateTime.parse(dateStr);
final now = DateTime.now();
final diff = now.difference(date);
if (diff.inDays == 0) {
return 'Today';
return context.l10n.dateToday;
} else if (diff.inDays == 1) {
return 'Yesterday';
return context.l10n.dateYesterday;
} else if (diff.inDays < 7) {
return '${diff.inDays} days ago';
return context.l10n.dateDaysAgo(diff.inDays);
} else if (diff.inDays < 30) {
return '${(diff.inDays / 7).floor()} weeks ago';
return context.l10n.dateWeeksAgo((diff.inDays / 7).floor());
} else if (diff.inDays < 365) {
return '${(diff.inDays / 30).floor()} months ago';
return context.l10n.dateMonthsAgo((diff.inDays / 30).floor());
} else {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
@@ -530,8 +531,8 @@ class _ExtensionDetailsScreenState
SnackBar(
content: Text(
success
? '${ext.displayName} installed.'
: 'Failed to install ${ext.displayName}',
? context.l10n.snackbarExtensionInstalled(ext.displayName)
: context.l10n.snackbarFailedToInstall,
),
behavior: SnackBarBehavior.floating,
),
@@ -551,8 +552,8 @@ class _ExtensionDetailsScreenState
SnackBar(
content: Text(
success
? '${ext.displayName} updated.'
: 'Failed to update ${ext.displayName}',
? context.l10n.snackbarExtensionUpdated(ext.displayName)
: context.l10n.snackbarFailedToUpdate,
),
behavior: SnackBarBehavior.floating,
),
@@ -564,17 +565,17 @@ class _ExtensionDetailsScreenState
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Uninstall Extension?'),
content: Text('Are you sure you want to remove ${ext.displayName}?'),
title: Text(context.l10n.dialogUninstallExtension),
content: Text(context.l10n.dialogUninstallExtensionMessage(ext.displayName)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text(
'Uninstall',
context.l10n.dialogUninstall,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
+13 -12
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/store_provider.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/screens/store/extension_details_screen.dart';
@@ -74,7 +75,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
expandedTitleScale: 1.0,
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
title: Text(
'Store',
context.l10n.storeTitle,
style: TextStyle(
fontSize: 20 + (14 * expandRatio),
fontWeight: FontWeight.bold,
@@ -93,7 +94,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search extensions...',
hintText: context.l10n.storeSearch,
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
@@ -141,7 +142,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
child: Row(
children: [
_CategoryChip(
label: 'All',
label: context.l10n.storeFilterAll,
icon: Icons.apps,
isSelected: state.selectedCategory == null,
onTap: () =>
@@ -149,7 +150,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
),
const SizedBox(width: 8),
_CategoryChip(
label: 'Metadata',
label: context.l10n.storeFilterMetadata,
icon: Icons.label_outline,
isSelected:
state.selectedCategory == StoreCategory.metadata,
@@ -159,7 +160,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
),
const SizedBox(width: 8),
_CategoryChip(
label: 'Download',
label: context.l10n.storeFilterDownload,
icon: Icons.download_outlined,
isSelected:
state.selectedCategory == StoreCategory.download,
@@ -169,7 +170,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
),
const SizedBox(width: 8),
_CategoryChip(
label: 'Utility',
label: context.l10n.storeFilterUtility,
icon: Icons.build_outlined,
isSelected:
state.selectedCategory == StoreCategory.utility,
@@ -179,7 +180,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
),
const SizedBox(width: 8),
_CategoryChip(
label: 'Lyrics',
label: context.l10n.storeFilterLyrics,
icon: Icons.lyrics_outlined,
isSelected:
state.selectedCategory == StoreCategory.lyrics,
@@ -189,7 +190,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
),
const SizedBox(width: 8),
_CategoryChip(
label: 'Integration',
label: context.l10n.storeFilterIntegration,
icon: Icons.link,
isSelected:
state.selectedCategory == StoreCategory.integration,
@@ -286,7 +287,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
onPressed: () =>
ref.read(storeProvider.notifier).refresh(forceRefresh: true),
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
label: Text(context.l10n.dialogRetry),
),
],
),
@@ -321,7 +322,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
_searchController.clear();
ref.read(storeProvider.notifier).clearSearch();
},
child: const Text('Clear filters'),
child: Text(context.l10n.storeClearFilters),
),
],
],
@@ -574,7 +575,7 @@ class _ExtensionItem extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 12),
minimumSize: const Size(0, 36),
),
child: const Text('Update'),
child: Text(context.l10n.storeUpdate),
)
else if (extension.isInstalled)
OutlinedButton(
@@ -602,7 +603,7 @@ class _ExtensionItem extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 12),
minimumSize: const Size(0, 36),
),
child: const Text('Install'),
child: Text(context.l10n.storeInstall),
),
],
),
+39 -40
View File
@@ -9,6 +9,7 @@ import 'package:url_launcher/url_launcher.dart';
import 'package:share_plus/share_plus.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
/// Screen to display detailed metadata for a downloaded track
/// Designed with Material Expressive 3 style
@@ -325,7 +326,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
const SizedBox(width: 6),
Text(
'File not found',
context.l10n.trackFileNotFound,
style: TextStyle(
color: colorScheme.onErrorContainer,
fontSize: 12,
@@ -361,7 +362,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
const SizedBox(width: 8),
Text(
'Metadata',
context.l10n.trackMetadata,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
@@ -383,7 +384,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return OutlinedButton.icon(
onPressed: () => _openServiceUrl(context),
icon: const Icon(Icons.open_in_new, size: 18),
label: Text(isDeezer ? 'Open in Deezer' : 'Open in Spotify'),
label: Text(isDeezer ? context.l10n.trackOpenInDeezer : context.l10n.trackOpenInSpotify),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
shape: RoundedRectangleBorder(
@@ -440,7 +441,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if (context.mounted) {
_copyToClipboard(context, webUrl);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${isDeezer ? 'Deezer' : 'Spotify'} URL copied to clipboard')),
SnackBar(content: Text(context.l10n.snackbarUrlCopied(isDeezer ? 'Deezer' : 'Spotify'))),
);
}
}
@@ -456,21 +457,21 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
final items = <_MetadataItem>[
_MetadataItem('Track name', trackName),
_MetadataItem('Artist', artistName),
_MetadataItem(context.l10n.trackTrackName, trackName),
_MetadataItem(context.l10n.trackArtist, artistName),
if (albumArtist != null && albumArtist != artistName)
_MetadataItem('Album artist', albumArtist!),
_MetadataItem('Album', albumName),
_MetadataItem(context.l10n.trackAlbumArtist, albumArtist!),
_MetadataItem(context.l10n.trackAlbum, albumName),
if (trackNumber != null && trackNumber! > 0)
_MetadataItem('Track number', trackNumber.toString()),
_MetadataItem(context.l10n.trackTrackNumber, trackNumber.toString()),
if (discNumber != null && discNumber! > 0)
_MetadataItem('Disc number', discNumber.toString()),
_MetadataItem(context.l10n.trackDiscNumber, discNumber.toString()),
if (item.duration != null)
_MetadataItem('Duration', _formatDuration(item.duration!)),
_MetadataItem(context.l10n.trackDuration, _formatDuration(item.duration!)),
if (audioQualityStr != null)
_MetadataItem('Audio quality', audioQualityStr),
_MetadataItem(context.l10n.trackAudioQuality, audioQualityStr),
if (releaseDate != null && releaseDate!.isNotEmpty)
_MetadataItem('Release date', releaseDate!),
_MetadataItem(context.l10n.trackReleaseDate, releaseDate!),
if (isrc != null && isrc!.isNotEmpty)
_MetadataItem('ISRC', isrc!),
];
@@ -482,8 +483,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
items.addAll([
_MetadataItem('Service', item.service.toUpperCase()),
_MetadataItem('Downloaded', _formatFullDate(item.downloadedAt)),
_MetadataItem(context.l10n.trackMetadataService, item.service.toUpperCase()),
_MetadataItem(context.l10n.trackDownloaded, _formatFullDate(item.downloadedAt)),
]);
return Column(
@@ -557,7 +558,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
const SizedBox(width: 8),
Text(
'File Info',
context.l10n.trackFileInfo,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
@@ -708,7 +709,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
const SizedBox(width: 8),
Text(
'Lyrics',
context.l10n.trackLyrics,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
@@ -719,7 +720,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
IconButton(
icon: const Icon(Icons.copy, size: 20),
onPressed: () => _copyToClipboard(context, _lyrics!),
tooltip: 'Copy lyrics',
tooltip: context.l10n.trackCopyLyrics,
),
],
),
@@ -751,7 +752,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
TextButton(
onPressed: _fetchLyrics,
child: const Text('Retry'),
child: Text(context.l10n.dialogRetry),
),
],
),
@@ -774,7 +775,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
child: FilledButton.tonalIcon(
onPressed: _fetchLyrics,
icon: const Icon(Icons.download),
label: const Text('Load Lyrics'),
label: Text(context.l10n.trackLoadLyrics),
),
),
],
@@ -806,7 +807,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if (mounted) {
if (result.isEmpty) {
setState(() {
_lyricsError = 'Lyrics not available for this track';
_lyricsError = context.l10n.trackLyricsNotAvailable;
_lyricsLoading = false;
});
} else {
@@ -821,8 +822,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} catch (e) {
if (mounted) {
final errorMsg = e.toString().contains('TimeoutException')
? 'Request timed out. Try again later.'
: 'Failed to load lyrics';
? context.l10n.trackLyricsTimeout
: context.l10n.trackLyricsLoadFailed;
setState(() {
_lyricsError = errorMsg;
_lyricsLoading = false;
@@ -856,7 +857,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
child: FilledButton.icon(
onPressed: fileExists ? () => _openFile(context, cleanFilePath) : null,
icon: const Icon(Icons.play_arrow),
label: const Text('Play'),
label: Text(context.l10n.trackMetadataPlay),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
@@ -872,7 +873,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
child: OutlinedButton.icon(
onPressed: () => _confirmDelete(context, ref, colorScheme),
icon: Icon(Icons.delete_outline, color: colorScheme.error),
label: Text('Delete', style: TextStyle(color: colorScheme.error)),
label: Text(context.l10n.trackMetadataDelete, style: TextStyle(color: colorScheme.error)),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
@@ -908,7 +909,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
const SizedBox(height: 16),
ListTile(
leading: const Icon(Icons.copy),
title: const Text('Copy file path'),
title: Text(context.l10n.trackCopyFilePath),
onTap: () {
Navigator.pop(context);
_copyToClipboard(context, cleanFilePath);
@@ -916,7 +917,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
ListTile(
leading: const Icon(Icons.share),
title: const Text('Share'),
title: Text(context.l10n.trackMetadataShare),
onTap: () {
Navigator.pop(context);
_shareFile(context);
@@ -924,7 +925,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
ListTile(
leading: Icon(Icons.delete, color: colorScheme.error),
title: Text('Remove from device', style: TextStyle(color: colorScheme.error)),
title: Text(context.l10n.trackRemoveFromDevice, style: TextStyle(color: colorScheme.error)),
onTap: () {
Navigator.pop(context);
_confirmDelete(context, ref, colorScheme);
@@ -941,14 +942,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Remove from device?'),
content: const Text(
'This will permanently delete the downloaded file and remove it from your history.',
),
title: Text(context.l10n.trackDeleteConfirmTitle),
content: Text(context.l10n.trackDeleteConfirmMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
TextButton(
onPressed: () async {
@@ -970,7 +969,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
Navigator.pop(context); // Go back to history
}
},
child: Text('Delete', style: TextStyle(color: colorScheme.error)),
child: Text(context.l10n.dialogDelete, style: TextStyle(color: colorScheme.error)),
),
],
),
@@ -983,13 +982,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final result = await OpenFilex.open(filePath, type: mimeType);
if (result.type != ResultType.done && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Cannot open: ${result.message}')),
SnackBar(content: Text(context.l10n.trackCannotOpen(result.message))),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Cannot open file: $e')),
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(e.toString()))),
);
}
}
@@ -998,9 +997,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
void _copyToClipboard(BuildContext context, String text) {
Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Copied to clipboard'),
duration: Duration(seconds: 2),
SnackBar(
content: Text(context.l10n.trackCopiedToClipboard),
duration: const Duration(seconds: 2),
),
);
}
@@ -1010,7 +1009,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if (!await file.exists()) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('File not found')),
SnackBar(content: Text(context.l10n.snackbarFileNotFound)),
);
}
return;
+4 -3
View File
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
/// Built-in service info with quality options
class BuiltInService {
@@ -167,7 +168,7 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text(
'Download From',
context.l10n.downloadFrom,
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
@@ -202,7 +203,7 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text(
'Select Quality',
context.l10n.downloadSelectQuality,
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
@@ -212,7 +213,7 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
child: Text(
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
context.l10n.qualityNote,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
+14 -13
View File
@@ -4,6 +4,7 @@ import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/services/update_checker.dart';
import 'package:spotiflac_android/services/apk_downloader.dart';
import 'package:spotiflac_android/services/notification_service.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
class UpdateDialog extends StatefulWidget {
final UpdateInfo updateInfo;
@@ -42,7 +43,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
setState(() {
_isDownloading = true;
_progress = 0;
_statusText = 'Starting download...';
_statusText = context.l10n.updateStartingDownload;
});
final notificationService = NotificationService();
@@ -91,11 +92,11 @@ class _UpdateDialogState extends State<UpdateDialog> {
if (mounted) {
setState(() {
_isDownloading = false;
_statusText = 'Download failed';
_statusText = context.l10n.updateDownloadFailed;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to download update')),
SnackBar(content: Text(context.l10n.updateFailedMessage)),
);
}
}
@@ -131,9 +132,9 @@ class _UpdateDialogState extends State<UpdateDialog> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Update Available', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
Text(context.l10n.updateAvailable, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 2),
Text('A new version is ready', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
Text(context.l10n.updateNewVersionReady, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
],
),
),
@@ -154,11 +155,11 @@ class _UpdateDialogState extends State<UpdateDialog> {
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_VersionChip(version: AppInfo.version, label: 'Current', colorScheme: colorScheme),
_VersionChip(version: AppInfo.version, label: context.l10n.updateCurrent, colorScheme: colorScheme),
const SizedBox(width: 12),
Icon(Icons.arrow_forward_rounded, size: 20, color: colorScheme.primary),
const SizedBox(width: 12),
_VersionChip(version: widget.updateInfo.version, label: 'New', colorScheme: colorScheme, isNew: true),
_VersionChip(version: widget.updateInfo.version, label: context.l10n.updateNew, colorScheme: colorScheme, isNew: true),
],
),
),
@@ -184,7 +185,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.primary),
),
const SizedBox(width: 12),
Text('Downloading...', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
Text(context.l10n.updateDownloading, style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
],
),
const SizedBox(height: 12),
@@ -209,7 +210,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
),
] else ...[
// Changelog section
Text("What's New", style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)),
Text(context.l10n.updateWhatsNew, style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Container(
constraints: const BoxConstraints(maxHeight: 180),
@@ -240,7 +241,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
)
else
@@ -251,7 +252,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
child: FilledButton.icon(
onPressed: _downloadAndInstall,
icon: const Icon(Icons.download_rounded, size: 20),
label: const Text('Download & Install'),
label: Text(context.l10n.updateDownloadInstall),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
@@ -271,7 +272,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: Text("Don't remind", style: TextStyle(color: colorScheme.onSurfaceVariant)),
child: Text(context.l10n.updateDontRemind, style: TextStyle(color: colorScheme.onSurfaceVariant)),
),
),
const SizedBox(width: 8),
@@ -285,7 +286,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: const Text('Later'),
child: Text(context.l10n.updateLater),
),
),
],
+13
View File
@@ -382,6 +382,11 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.3"
flutter_localizations:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
@@ -488,6 +493,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.7.2"
intl:
dependency: "direct main"
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.20.2"
io:
dependency: transitive
description:
+6
View File
@@ -10,6 +10,11 @@ dependencies:
flutter:
sdk: flutter
# Localization
flutter_localizations:
sdk: flutter
intl: any
# State Management
flutter_riverpod: ^3.1.0
riverpod_annotation: ^4.0.0
@@ -77,6 +82,7 @@ flutter_launcher_icons:
flutter:
uses-material-design: true
generate: true
assets:
- assets/images/