feat: add language selector in Appearance settings

- Add locale field to AppSettings model with 'system' default
- Add Language section in Appearance settings page
- Support System Default, English, and Indonesian options
- App language changes take effect immediately
- Add localization strings for language selector
This commit is contained in:
zarzet
2026-01-16 06:25:24 +07:00
parent 7fff55da96
commit 01306afc2d
10 changed files with 264 additions and 0 deletions
+8
View File
@@ -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,
+36
View File
@@ -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:
+18
View File
@@ -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';
+18
View File
@@ -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';
+13
View File
@@ -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"},
+7
View File
@@ -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",
+4
View File
@@ -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,
);
}
+2
View File
@@ -35,6 +35,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
albumFolderStructure:
json['albumFolderStructure'] as String? ?? 'artist_album',
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
locale: json['locale'] as String? ?? 'system',
);
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
@@ -65,4 +66,5 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'separateSingles': instance.separateSingles,
'albumFolderStructure': instance.albumFolderStructure,
'showExtensionStore': instance.showExtensionStore,
'locale': instance.locale,
};
+5
View File
@@ -230,6 +230,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
state = state.copyWith(showExtensionStore: enabled);
_saveSettings();
}
void setLocale(String locale) {
state = state.copyWith(locale: locale);
_saveSettings();
}
}
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
@@ -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<String> 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,
),
],
),
),
),
),
),
);
}
}