diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index ea2e10cff3..3b6b58a4ef 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -883,5 +883,13 @@ "zulipAppTitle": "Zulip", "@zulipAppTitle": { "description": "The name of Zulip. This should be either 'Zulip' or a transliteration." + }, + "languageSettingsTitle": "Language", + "@languageSettingsTitle": { + "description": "Title for the language settings section in the app settings." + }, + "systemDefaultLanguage": "System default", + "@systemDefaultLanguage": { + "description": "Option to use the system default language setting." } } diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index e326703e4b..a732f6dc65 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -1303,6 +1303,18 @@ abstract class ZulipLocalizations { /// In en, this message translates to: /// **'Zulip'** String get zulipAppTitle; + + /// Title for the language settings section in the app settings. + /// + /// In en, this message translates to: + /// **'Language'** + String get languageSettingsTitle; + + /// Option to use the system default language setting. + /// + /// In en, this message translates to: + /// **'System default'** + String get systemDefaultLanguage; } class _ZulipLocalizationsDelegate diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 8d36fa6bd0..0d14b8700d 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -717,4 +717,10 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get zulipAppTitle => 'Zulip'; + + @override + String get languageSettingsTitle => 'Language'; + + @override + String get systemDefaultLanguage => 'System default'; } diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index a74a2e1eaf..bec08f0217 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -717,4 +717,10 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get zulipAppTitle => 'Zulip'; + + @override + String get languageSettingsTitle => 'Language'; + + @override + String get systemDefaultLanguage => 'System default'; } diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index c11a3fae23..b1ef4f53b1 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -717,4 +717,10 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get zulipAppTitle => 'Zulip'; + + @override + String get languageSettingsTitle => 'Language'; + + @override + String get systemDefaultLanguage => 'System default'; } diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index d23bd323fd..f34d9e2c44 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -717,4 +717,10 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get zulipAppTitle => 'Zulip'; + + @override + String get languageSettingsTitle => 'Language'; + + @override + String get systemDefaultLanguage => 'System default'; } diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index e1a6bd45f4..94083b1271 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -728,4 +728,10 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get zulipAppTitle => 'Zulip'; + + @override + String get languageSettingsTitle => 'Language'; + + @override + String get systemDefaultLanguage => 'System default'; } diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 78b68e812a..c36ad47b26 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -731,4 +731,10 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get zulipAppTitle => 'Zulip'; + + @override + String get languageSettingsTitle => 'Language'; + + @override + String get systemDefaultLanguage => 'System default'; } diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 193ac26d8e..02e7131c86 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -719,4 +719,10 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get zulipAppTitle => 'Zulip'; + + @override + String get languageSettingsTitle => 'Language'; + + @override + String get systemDefaultLanguage => 'System default'; } diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index c1898631fd..840735a837 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -731,4 +731,10 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get zulipAppTitle => 'Zulip'; + + @override + String get languageSettingsTitle => 'Language'; + + @override + String get systemDefaultLanguage => 'System default'; } diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index 54ba92588b..7e54b4c847 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -1,8 +1,11 @@ import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; +import 'package:path_provider/path_provider.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../log.dart'; @@ -47,6 +50,63 @@ class ZulipApp extends StatefulWidget { return completer.future; } + // static Locale? currentLocale; + // static final ValueNotifier _localeNotifier = ValueNotifier(currentLocale); + // + // static void setLocale(Locale? locale) { + // currentLocale = locale; + // _localeNotifier.value = locale; + // } + + static Locale? currentLocale; + static final ValueNotifier _localeNotifier = ValueNotifier(currentLocale); + static File? _prefsFile; + + static Future _getPrefsFile() async { + if (_prefsFile == null) { + final dir = await getApplicationDocumentsDirectory(); + _prefsFile = File('${dir.path}/language_prefs.json'); + if (!await _prefsFile!.exists()) { + await _prefsFile!.create(); + } + } + return _prefsFile!; + } + + static Future setLocale(Locale? locale) async { + currentLocale = locale; + _localeNotifier.value = locale; + + try { + final file = await _getPrefsFile(); + await file.writeAsString( + jsonEncode({'locale': locale?.toString()}), + flush: true, + ); + } catch (e) { + debugPrint('Error saving locale: $e'); + } + } + + static Future loadSavedLocale() async { + try { + final file = await _getPrefsFile(); + final content = await file.readAsString(); + if (content.isNotEmpty) { + final data = jsonDecode(content) as Map; + final localeString = data['locale'] as String?; + if (localeString != null && localeString.isNotEmpty) { + final parts = localeString.split('_'); + currentLocale = parts.length == 2 + ? Locale(parts[0], parts[1]) + : Locale(parts[0]); + _localeNotifier.value = currentLocale; + } + } + } catch (e) { + debugPrint('Error loading locale: $e'); + } + } /// A key for the navigator for the whole app. /// /// For code that exists entirely outside the widget tree and has no natural @@ -160,14 +220,26 @@ class _ZulipAppState extends State with WidgetsBindingObserver { void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); + ZulipApp._localeNotifier.addListener(_handleLocaleChange); + _initializeApp(); + } + + Future _initializeApp() async { + await ZulipApp.loadSavedLocale(); // Load saved locale first + ZulipApp._localeNotifier.addListener(_handleLocaleChange); } @override void dispose() { + ZulipApp._localeNotifier.removeListener(_handleLocaleChange); WidgetsBinding.instance.removeObserver(this); super.dispose(); } + void _handleLocaleChange() { + if (mounted) setState(() {}); + } + List> _handleGenerateInitialRoutes(String initialRoute) { // The `_ZulipAppState.context` lacks the required ancestors. Instead // we use the Navigator which should be available when this callback is @@ -220,6 +292,7 @@ class _ZulipAppState extends State with WidgetsBindingObserver { return GlobalStoreWidget( child: Builder(builder: (context) { return MaterialApp( + locale: ZulipApp.currentLocale, onGenerateTitle: (BuildContext context) { return ZulipLocalizations.of(context).zulipAppTitle; }, diff --git a/lib/widgets/settings.dart b/lib/widgets/settings.dart index 9e7581c539..36f00e9728 100644 --- a/lib/widgets/settings.dart +++ b/lib/widgets/settings.dart @@ -1,11 +1,94 @@ import 'package:flutter/material.dart'; - +import 'app.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../model/settings.dart'; import 'app_bar.dart'; import 'page.dart'; import 'store.dart'; +class LanguageOption { + final String name; + final String englishName; + final Locale locale; + + const LanguageOption(this.name, this.englishName, this.locale); +} + +final List _languageOptions = const [ + LanguageOption('English', 'English', Locale('en')), + LanguageOption('English (UK)', 'English (UK)', Locale('en', 'GB')), + LanguageOption('English (US)', 'English (US)', Locale('en', 'US')), + LanguageOption('Afrikaans', 'Afrikaans', Locale('af')), + LanguageOption('العربية', 'Arabic', Locale('ar')), + LanguageOption('Հայերեն', 'Armenian', Locale('hy')), + LanguageOption('Azərbaycanca', 'Azerbaijani', Locale('az')), + LanguageOption('Беларуская', 'Belarusian', Locale('be')), + LanguageOption('বাংলা', 'Bengali', Locale('bn')), + LanguageOption('Bosanski', 'Bosnian', Locale('bs')), + LanguageOption('Български', 'Bulgarian', Locale('bg')), + LanguageOption('Català', 'Catalan', Locale('ca')), + LanguageOption('中文 (简体)', 'Chinese (Simplified)', Locale('zh', 'CN')), + LanguageOption('中文 (繁體)', 'Chinese (Traditional)', Locale('zh', 'TW')), + LanguageOption('Hrvatski', 'Croatian', Locale('hr')), + LanguageOption('Čeština', 'Czech', Locale('cs')), + LanguageOption('Dansk', 'Danish', Locale('da')), + LanguageOption('Nederlands', 'Dutch', Locale('nl')), + LanguageOption('Eesti', 'Estonian', Locale('et')), + LanguageOption('Filipino', 'Filipino', Locale('fil')), + LanguageOption('Suomi', 'Finnish', Locale('fi')), + LanguageOption('Français', 'French', Locale('fr')), + LanguageOption('Français (Canada)', 'French (Canada)', Locale('fr', 'CA')), + LanguageOption('Galego', 'Galician', Locale('gl')), + LanguageOption('ქართული', 'Georgian', Locale('ka')), + LanguageOption('Deutsch', 'German', Locale('de')), + LanguageOption('Ελληνικά', 'Greek', Locale('el')), + LanguageOption('ગુજરાતી', 'Gujarati', Locale('gu')), + LanguageOption('עברית', 'Hebrew', Locale('he')), + LanguageOption('हिन्दी', 'Hindi', Locale('hi')), + LanguageOption('Magyar', 'Hungarian', Locale('hu')), + LanguageOption('Íslenska', 'Icelandic', Locale('is')), + LanguageOption('Bahasa Indonesia', 'Indonesian', Locale('id')), + LanguageOption('Gaeilge', 'Irish', Locale('ga')), + LanguageOption('Italiano', 'Italian', Locale('it')), + LanguageOption('日本語', 'Japanese', Locale('ja')), + LanguageOption('ಕನ್ನಡ', 'Kannada', Locale('kn')), + LanguageOption('Қазақ', 'Kazakh', Locale('kk')), + LanguageOption('ភាសាខ្មែរ', 'Khmer', Locale('km')), + LanguageOption('한국어', 'Korean', Locale('ko')), + LanguageOption('ລາວ', 'Lao', Locale('lo')), + LanguageOption('Latviešu', 'Latvian', Locale('lv')), + LanguageOption('Lietuvių', 'Lithuanian', Locale('lt')), + LanguageOption('Македонски', 'Macedonian', Locale('mk')), + LanguageOption('Malayalam', 'Malayalam', Locale('ml')), + LanguageOption('Bahasa Melayu', 'Malay', Locale('ms')), + LanguageOption('Монгол', 'Mongolian', Locale('mn')), + LanguageOption('नेपाली', 'Nepali', Locale('ne')), + LanguageOption('Norsk Bokmål', 'Norwegian (Bokmål)', Locale('nb')), + LanguageOption('فارسی', 'Persian', Locale('fa')), + LanguageOption('Polski', 'Polish', Locale('pl')), + LanguageOption('Português (Brasil)', 'Portuguese (Brazil)', Locale('pt', 'BR')), + LanguageOption('Português (Portugal)', 'Portuguese (Portugal)', Locale('pt', 'PT')), + LanguageOption('ਪੰਜਾਬੀ', 'Punjabi', Locale('pa')), + LanguageOption('Română', 'Romanian', Locale('ro')), + LanguageOption('Русский', 'Russian', Locale('ru')), + LanguageOption('Српски', 'Serbian', Locale('sr')), + LanguageOption('Slovenčina', 'Slovak', Locale('sk')), + LanguageOption('Slovenščina', 'Slovenian', Locale('sl')), + LanguageOption('Español', 'Spanish', Locale('es')), + LanguageOption('Español (Latinoamérica)', 'Spanish (Latin America)', Locale('es', '419')), + LanguageOption('Svenska', 'Swedish', Locale('sv')), + LanguageOption('தமிழ்', 'Tamil', Locale('ta')), + LanguageOption('తెలుగు', 'Telugu', Locale('te')), + LanguageOption('ไทย', 'Thai', Locale('th')), + LanguageOption('Türkçe', 'Turkish', Locale('tr')), + LanguageOption('Українська', 'Ukrainian', Locale('uk')), + LanguageOption('اردو', 'Urdu', Locale('ur')), + LanguageOption('Oʻzbek', 'Uzbek', Locale('uz')), + LanguageOption('Tiếng Việt', 'Vietnamese', Locale('vi')), + LanguageOption('Cymraeg', 'Welsh', Locale('cy')), + LanguageOption('isiZulu', 'Zulu', Locale('zu')), +]; + class SettingsPage extends StatelessWidget { const SettingsPage({super.key}); @@ -14,6 +97,19 @@ class SettingsPage extends StatelessWidget { context: context, page: const SettingsPage()); } + Future _getCurrentLanguageName() async { + await ZulipApp.loadSavedLocale(); // Ensure fresh load + final locale = ZulipApp.currentLocale; + if (locale == null) return 'System default'; + + final option = _languageOptions.firstWhere( + (opt) => opt.locale.languageCode == locale.languageCode, + orElse: () => const LanguageOption('English', 'English', Locale('en')), + ); + + return option.englishName; + } + @override Widget build(BuildContext context) { final zulipLocalizations = ZulipLocalizations.of(context); @@ -23,6 +119,25 @@ class SettingsPage extends StatelessWidget { body: Column(children: [ const _ThemeSetting(), const _BrowserPreferenceSetting(), + ListTile( + leading: const Icon(Icons.language), + title: const Text('Language'), + subtitle: FutureBuilder( + future: _getCurrentLanguageName(), + builder: (context, snapshot) { + return Text( + snapshot.data ?? 'System default', + style: Theme.of(context).textTheme.bodySmall, + ); + }, + ), + onTap: () => Navigator.push( + context, + MaterialPageRoute( // Explicit type parameter + builder: (context) => const LanguageSelectionScreen(), + ), + ), + ), if (GlobalSettingsStore.experimentalFeatureFlags.isNotEmpty) ListTile( title: Text(zulipLocalizations.experimentalFeatureSettingsPageTitle), @@ -32,6 +147,104 @@ class SettingsPage extends StatelessWidget { } } +class LanguageSelectionScreen extends StatefulWidget { + const LanguageSelectionScreen({super.key}); + + @override + State createState() => _LanguageSelectionScreenState(); +} + +class _LanguageSelectionScreenState extends State { + final TextEditingController _searchController = TextEditingController(); + List _filteredLanguages = []; + + @override + void initState() { + super.initState(); + _filteredLanguages = _languageOptions; + _searchController.addListener(_filterLanguages); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + void _filterLanguages() { + final query = _searchController.text.toLowerCase(); + setState(() { + _filteredLanguages = _languageOptions.where((lang) { + return lang.name.toLowerCase().contains(query) || + lang.englishName.toLowerCase().contains(query); + }).toList(); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Language'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search', + prefixIcon: const Icon(Icons.search), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + Expanded( + child: ListView.builder( + itemCount: _filteredLanguages.length, + itemBuilder: (context, index) { + final option = _filteredLanguages[index]; + final isSelected = ZulipApp.currentLocale?.languageCode == + option.locale.languageCode; + + return ListTile( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(option.name), + Text( + option.englishName, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).hintColor, + ), + ), + ], + ), + trailing: isSelected + ? const Icon(Icons.check, color: Colors.blue) + : null, + onTap: () { + ZulipApp.setLocale(option.locale); + Navigator.pop(context); + }, + ); + }, + ), + ), + ], + ), + ); + } +} + class _ThemeSetting extends StatelessWidget { const _ThemeSetting();