diff --git a/lib/app.dart b/lib/app.dart index ed00039f..64a4b34c 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -33,6 +33,13 @@ class SpotiFLACApp extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final router = ref.watch(_routerProvider); + final localeString = ref.watch(settingsProvider.select((s) => s.locale)); + + // Convert locale string to Locale object + Locale? locale; + if (localeString != 'system') { + locale = Locale(localeString); + } return DynamicColorWrapper( builder: (lightTheme, darkTheme, themeMode) { @@ -46,6 +53,7 @@ class SpotiFLACApp extends ConsumerWidget { themeAnimationCurve: Curves.easeInOut, routerConfig: router, // Localization + locale: locale, // null = follow system localizationsDelegates: const [ AppLocalizations.delegate, GlobalMaterialLocalizations.delegate, diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index a23f0f4a..cb0e8562 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2588,6 +2588,42 @@ abstract class AppLocalizations { /// **'Layout'** String get sectionLayout; + /// Settings section header for language selection + /// + /// In en, this message translates to: + /// **'Language'** + String get sectionLanguage; + + /// Setting title for language selection + /// + /// In en, this message translates to: + /// **'App Language'** + String get appearanceLanguage; + + /// Subtitle for language setting + /// + /// In en, this message translates to: + /// **'Choose your preferred language'** + String get appearanceLanguageSubtitle; + + /// Use device system language + /// + /// In en, this message translates to: + /// **'System Default'** + String get languageSystem; + + /// English language option + /// + /// In en, this message translates to: + /// **'English'** + String get languageEnglish; + + /// Indonesian language option + /// + /// In en, this message translates to: + /// **'Bahasa Indonesia'** + String get languageIndonesian; + /// Appearance settings description /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 7881a64c..edc750fc 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1428,6 +1428,24 @@ class AppLocalizationsEn extends AppLocalizations { @override String get sectionLayout => 'Layout'; + @override + String get sectionLanguage => 'Language'; + + @override + String get appearanceLanguage => 'App Language'; + + @override + String get appearanceLanguageSubtitle => 'Choose your preferred language'; + + @override + String get languageSystem => 'System Default'; + + @override + String get languageEnglish => 'English'; + + @override + String get languageIndonesian => 'Bahasa Indonesia'; + @override String get settingsAppearanceSubtitle => 'Theme, colors, display'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 78c876c3..9df94cd8 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1438,6 +1438,24 @@ class AppLocalizationsId extends AppLocalizations { @override String get sectionLayout => 'Tata Letak'; + @override + String get sectionLanguage => 'Bahasa'; + + @override + String get appearanceLanguage => 'Bahasa Aplikasi'; + + @override + String get appearanceLanguageSubtitle => 'Pilih bahasa yang kamu inginkan'; + + @override + String get languageSystem => 'Bawaan Sistem'; + + @override + String get languageEnglish => 'English'; + + @override + String get languageIndonesian => 'Bahasa Indonesia'; + @override String get settingsAppearanceSubtitle => 'Tema, warna, tampilan'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 6567834f..45e244b5 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1046,6 +1046,19 @@ "@sectionTheme": {"description": "Settings section header"}, "sectionLayout": "Layout", "@sectionLayout": {"description": "Settings section header"}, + "sectionLanguage": "Language", + "@sectionLanguage": {"description": "Settings section header for language selection"}, + + "appearanceLanguage": "App Language", + "@appearanceLanguage": {"description": "Setting title for language selection"}, + "appearanceLanguageSubtitle": "Choose your preferred language", + "@appearanceLanguageSubtitle": {"description": "Subtitle for language setting"}, + "languageSystem": "System Default", + "@languageSystem": {"description": "Use device system language"}, + "languageEnglish": "English", + "@languageEnglish": {"description": "English language option"}, + "languageIndonesian": "Bahasa Indonesia", + "@languageIndonesian": {"description": "Indonesian language option"}, "settingsAppearanceSubtitle": "Theme, colors, display", "@settingsAppearanceSubtitle": {"description": "Appearance settings description"}, diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 602863f8..23837ca6 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -305,6 +305,13 @@ "sectionColor": "Warna", "sectionTheme": "Tema", "sectionLayout": "Tata Letak", + "sectionLanguage": "Bahasa", + + "appearanceLanguage": "Bahasa Aplikasi", + "appearanceLanguageSubtitle": "Pilih bahasa yang kamu inginkan", + "languageSystem": "Bawaan Sistem", + "languageEnglish": "English", + "languageIndonesian": "Bahasa Indonesia", "settingsAppearanceSubtitle": "Tema, warna, tampilan", "settingsDownloadSubtitle": "Layanan, kualitas, format nama file", diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 43882872..6b0b891a 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -30,6 +30,7 @@ class AppSettings { final bool separateSingles; // Separate singles/EPs into their own folder final String albumFolderStructure; // artist_album, album_only, artist_year_album, year_album final bool showExtensionStore; // Show Extension Store tab in navigation + final String locale; // App language: 'system', 'en', 'id', etc. const AppSettings({ this.defaultService = 'tidal', @@ -58,6 +59,7 @@ class AppSettings { this.separateSingles = false, // Default: disabled this.albumFolderStructure = 'artist_album', // Default: Albums/Artist/Album this.showExtensionStore = true, // Default: show store + this.locale = 'system', // Default: follow system language }); AppSettings copyWith({ @@ -88,6 +90,7 @@ class AppSettings { bool? separateSingles, String? albumFolderStructure, bool? showExtensionStore, + String? locale, }) { return AppSettings( defaultService: defaultService ?? this.defaultService, @@ -116,6 +119,7 @@ class AppSettings { separateSingles: separateSingles ?? this.separateSingles, albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure, showExtensionStore: showExtensionStore ?? this.showExtensionStore, + locale: locale ?? this.locale, ); } diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 47330d13..6094a638 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -35,6 +35,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( albumFolderStructure: json['albumFolderStructure'] as String? ?? 'artist_album', showExtensionStore: json['showExtensionStore'] as bool? ?? true, + locale: json['locale'] as String? ?? 'system', ); Map _$AppSettingsToJson(AppSettings instance) => @@ -65,4 +66,5 @@ Map _$AppSettingsToJson(AppSettings instance) => 'separateSingles': instance.separateSingles, 'albumFolderStructure': instance.albumFolderStructure, 'showExtensionStore': instance.showExtensionStore, + 'locale': instance.locale, }; diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index ba34cc72..39bb3900 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -230,6 +230,11 @@ class SettingsNotifier extends Notifier { state = state.copyWith(showExtensionStore: enabled); _saveSettings(); } + + void setLocale(String locale) { + state = state.copyWith(locale: locale); + _saveSettings(); + } } final settingsProvider = NotifierProvider( diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index acf843ee..ed66b334 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -108,6 +108,23 @@ class AppearanceSettingsPage extends ConsumerWidget { ), ), + // Language section + SliverToBoxAdapter( + child: SettingsSectionHeader(title: context.l10n.sectionLanguage), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + _LanguageSelector( + currentLocale: settings.locale, + onChanged: (locale) => ref + .read(settingsProvider.notifier) + .setLocale(locale), + ), + ], + ), + ), + // Layout section SliverToBoxAdapter( child: SettingsSectionHeader(title: context.l10n.sectionLayout), @@ -683,3 +700,139 @@ class _ViewModeChip extends StatelessWidget { ); } } + +class _LanguageSelector extends StatelessWidget { + final String currentLocale; + final ValueChanged onChanged; + const _LanguageSelector({ + required this.currentLocale, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8, bottom: 8), + child: Text( + context.l10n.appearanceLanguage, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + Row( + children: [ + _LanguageChip( + icon: Icons.phone_android, + label: context.l10n.languageSystem, + isSelected: currentLocale == 'system', + onTap: () => onChanged('system'), + ), + const SizedBox(width: 8), + _LanguageChip( + icon: Icons.language, + label: context.l10n.languageEnglish, + isSelected: currentLocale == 'en', + onTap: () => onChanged('en'), + ), + const SizedBox(width: 8), + _LanguageChip( + icon: Icons.language, + label: context.l10n.languageIndonesian, + isSelected: currentLocale == 'id', + onTap: () => onChanged('id'), + ), + ], + ), + ], + ), + ); + } +} + +class _LanguageChip extends StatelessWidget { + final IconData icon; + final String label; + final bool isSelected; + final VoidCallback onTap; + const _LanguageChip({ + required this.icon, + required this.label, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; + + final unselectedColor = isDark + ? Color.alphaBlend( + Colors.white.withValues(alpha: 0.05), + colorScheme.surface, + ) + : Color.alphaBlend( + Colors.black.withValues(alpha: 0.05), + colorScheme.surfaceContainerHighest, + ); + + return Expanded( + child: Container( + decoration: BoxDecoration( + color: isSelected ? colorScheme.primaryContainer : unselectedColor, + borderRadius: BorderRadius.circular(12), + border: !isDark && !isSelected + ? Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + width: 1, + ) + : null, + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14), + child: Column( + children: [ + Icon( + icon, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 6), + Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.normal, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + ), + ), + ); + } +}