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
No known key found for this signature in database
GPG key ID: D22AEB239271AACA
34 changed files with 9758 additions and 601 deletions

6
l10n.yaml Normal file
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

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
lib/l10n/arb/app_en.arb Normal file
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
lib/l10n/arb/app_id.arb Normal file
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
lib/l10n/l10n.dart Normal file
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);
}

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 {

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,

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,

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)),
],
),
),

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,
),
];

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 {

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)),
),
],
),

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,
),

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,

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/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'),
),

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/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,
),
@ -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: () {

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),
),
],
),

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),

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),
),

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/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,
);
}

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,
),

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/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,
),

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(
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: 'Appearance',
subtitle: 'Theme, colors, display',
title: l10n.settingsAppearance,
subtitle: l10n.settingsAppearanceSubtitle,
onTap: () =>
_navigateTo(context, const AppearanceSettingsPage()),
),
SettingsItem(
icon: Icons.download_outlined,
title: 'Download',
subtitle: 'Service, quality, filename format',
title: l10n.settingsDownload,
subtitle: l10n.settingsDownloadSubtitle,
onTap: () => _navigateTo(context, const DownloadSettingsPage()),
),
SettingsItem(
icon: Icons.tune_outlined,
title: 'Options',
subtitle: 'Fallback, lyrics, cover art, updates',
title: l10n.settingsOptions,
subtitle: l10n.settingsOptionsSubtitle,
onTap: () => _navigateTo(context, const OptionsSettingsPage()),
),
SettingsItem(
icon: Icons.extension_outlined,
title: 'Extensions',
subtitle: 'Manage download providers',
title: l10n.settingsExtensions,
subtitle: l10n.settingsExtensionsSubtitle,
onTap: () => _navigateTo(context, const ExtensionsPage()),
showDivider: false,
),
],
);
},
),
),
// Second group: Logs & About
SliverToBoxAdapter(
child: SettingsGroup(
child: Builder(
builder: (context) {
final l10n = context.l10n;
return SettingsGroup(
children: [
SettingsItem(
icon: Icons.article_outlined,
title: 'Logs',
subtitle: 'View app logs for debugging',
title: l10n.logTitle,
subtitle: l10n.settingsLogsSubtitle,
onTap: () => _navigateTo(context, const LogScreen()),
),
SettingsItem(
icon: Icons.info_outline,
title: 'About',
subtitle: 'Version ${AppInfo.version}, credits, GitHub',
title: l10n.settingsAbout,
subtitle: '${l10n.aboutVersion} ${AppInfo.version}',
onTap: () => _navigateTo(context, const AboutPage()),
showDivider: false,
),
],
);
},
),
),

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),
),
),
@ -491,11 +491,11 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
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),
],

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,
),
@ -175,7 +176,7 @@ class _ExtensionDetailsScreenState
),
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),
),
),

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),
),
],
),

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;

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,

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),
),
),
],

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:

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/