diff --git a/TODO.md b/TODO.md index 263e3bb..5d11b48 100644 --- a/TODO.md +++ b/TODO.md @@ -2,10 +2,11 @@ ## Internacionalización AAA +- [x] Diseñar una base de internacionalización profesional con ficheros ARB separados por idioma. +- [x] Permitir que el usuario cambie el idioma manualmente desde la aplicación, sin depender únicamente del idioma del sistema. +- [x] Añadir traducción inicial español/inglés para el shell, navegación, timer de sueño y selector de idioma. - [ ] Repasar absolutamente todos los literales de la aplicación en todas las pantallas, componentes, servicios con mensajes visibles y notificaciones. -- [ ] Diseñar una arquitectura de internacionalización profesional con ficheros separados por idioma. -- [ ] Permitir que el usuario cambie el idioma manualmente desde la aplicación, sin depender únicamente del idioma del sistema. -- [ ] Soportar formatos locales de fecha, hora, números y duración. +- [ ] Soportar formatos locales de fecha, hora, números y duración usando helpers centralizados. - [ ] Resolver correctamente singular/plural y variantes por cantidad, por ejemplo `1 emisora` vs `2 emisoras`. - [ ] Preparar traducciones para la mayoría de idiomas más usados del planeta. - [ ] Revisar la aplicación de Farolero como referencia para detectar el conjunto de idiomas que nos interesa mantener. diff --git a/l10n.yaml b/l10n.yaml new file mode 100644 index 0000000..b5ee0e2 --- /dev/null +++ b/l10n.yaml @@ -0,0 +1,6 @@ +arb-dir: lib/l10n +template-arb-file: app_es.arb +output-localization-file: app_localizations.dart +output-class: AppLocalizations +output-dir: lib/l10n/gen +nullable-getter: false diff --git a/lib/app.dart b/lib/app.dart index c0d42bf..ae89d56 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -3,6 +3,8 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'estado/estado_radio.dart'; import 'estado/estado_alarmas.dart'; +import 'estado/estado_idioma.dart'; +import 'l10n/gen/app_localizations.dart'; import 'modelos/alarma_musical.dart'; import 'pantallas/pantalla_alarmas.dart'; import 'pantallas/pantalla_alarma_sonando.dart'; @@ -27,14 +29,21 @@ class PluriWaveApp extends StatelessWidget { providers: [ ChangeNotifierProvider(create: (_) => EstadoRadio()), ChangeNotifierProvider(create: (_) => EstadoAlarmas()), + ChangeNotifierProvider(create: (_) => EstadoIdioma()), ], - child: MaterialApp( - title: 'PluriWave', - debugShowCheckedModeBanner: false, - theme: PluriWaveTheme.dark(), - darkTheme: PluriWaveTheme.dark(), - themeMode: ThemeMode.dark, - home: const _PaginaPrincipal(), + child: Consumer( + builder: + (context, estadoIdioma, _) => MaterialApp( + title: 'PluriWave', + debugShowCheckedModeBanner: false, + theme: PluriWaveTheme.dark(), + darkTheme: PluriWaveTheme.dark(), + themeMode: ThemeMode.dark, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: estadoIdioma.localeSeleccionado, + home: const _PaginaPrincipal(), + ), ), ); } @@ -63,27 +72,12 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> { PantallaAjustes(), ]; - static const _navItems = [ - PluriNavItem( - glyph: PluriIconGlyph.home, - label: 'Inicio', - ), - PluriNavItem( - glyph: PluriIconGlyph.search, - label: 'Buscar', - ), - PluriNavItem( - glyph: PluriIconGlyph.favorites, - label: 'Favoritos', - ), - PluriNavItem( - glyph: PluriIconGlyph.alarm, - label: 'Alarmas', - ), - PluriNavItem( - glyph: PluriIconGlyph.settings, - label: 'Ajustes', - ), + List _navItems(AppLocalizations l10n) => [ + PluriNavItem(glyph: PluriIconGlyph.home, label: l10n.navHome), + PluriNavItem(glyph: PluriIconGlyph.search, label: l10n.navSearch), + PluriNavItem(glyph: PluriIconGlyph.favorites, label: l10n.navFavorites), + PluriNavItem(glyph: PluriIconGlyph.alarm, label: l10n.navAlarms), + PluriNavItem(glyph: PluriIconGlyph.settings, label: l10n.navSettings), ]; @override @@ -101,7 +95,10 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> { SnackBar( content: Text(msg), duration: const Duration(seconds: 3), - action: SnackBarAction(label: 'OK', onPressed: () {}), + action: SnackBarAction( + label: AppLocalizations.of(context).actionOk, + onPressed: () {}, + ), ), ); }); @@ -133,13 +130,15 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return PluriWaveScaffold( appBar: AppBar( - title: const Text('PluriWave'), + title: Text(l10n.appTitle), actions: [ IconButton( icon: const Icon(Icons.bedtime_outlined), - tooltip: 'Timer de sueño', + tooltip: l10n.sleepTimer, onPressed: () => _mostrarTimerDialog(context), ), ], @@ -177,7 +176,7 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> { children: [ const MiniReproductor(), PluriBottomNavigation( - items: _navItems, + items: _navItems(l10n), selectedIndex: _indice, onSelected: (i) => setState(() => _indice = i), ), @@ -212,7 +211,11 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> { setState(() => _indice = 3); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Omitida esta ejecución de ${alarma.nombre}.'), + content: Text( + AppLocalizations.of(context).skipCurrentAlarmExecution( + alarma.nombre, + ), + ), ), ); return; @@ -252,12 +255,12 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Timer de sueño', + AppLocalizations.of(ctx).sleepTimer, style: Theme.of(ctx).textTheme.titleLarge, ), const SizedBox(height: PluriLayout.sectionGap), Text( - 'Apagado suave de la radio con cuenta atrás exacta.', + AppLocalizations.of(ctx).sleepTimerDescription, style: Theme.of(ctx).textTheme.bodySmall, ), const SizedBox(height: PluriLayout.panelGap), @@ -280,7 +283,9 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> { estado.cancelarTimer(); Navigator.pop(ctx); }, - child: const Text('Cancelar timer'), + child: Text( + AppLocalizations.of(ctx).cancelTimer, + ), ), ], ); @@ -308,7 +313,9 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> { ), ActionChip( avatar: const Icon(Icons.tune_rounded, size: 18), - label: const Text('Otro'), + label: Text( + AppLocalizations.of(ctx).optionOther, + ), onPressed: () async { final duracion = await _pedirDuracionPersonalizada(ctx); @@ -382,7 +389,11 @@ class _TimerPersonalizadoSheetState extends State<_TimerPersonalizadoSheet> { ); if (duracion <= Duration.zero) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Elegí una duración mayor que cero.')), + SnackBar( + content: Text( + AppLocalizations.of(context).durationGreaterThanZero, + ), + ), ); return; } @@ -403,30 +414,49 @@ class _TimerPersonalizadoSheetState extends State<_TimerPersonalizadoSheet> { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( - 'Duración personalizada', + AppLocalizations.of(context).customDurationTitle, style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: PluriLayout.sectionGap), Row( children: [ - Expanded(child: _campoTiempo(_horasCtrl, 'Horas')), + Expanded( + child: _campoTiempo( + _horasCtrl, + AppLocalizations.of(context).hoursLabel, + ), + ), const SizedBox(width: PluriLayout.compactGap), - Expanded(child: _campoTiempo(_minutosCtrl, 'Minutos')), + Expanded( + child: _campoTiempo( + _minutosCtrl, + AppLocalizations.of(context).minutesLabel, + ), + ), const SizedBox(width: PluriLayout.compactGap), - Expanded(child: _campoTiempo(_segundosCtrl, 'Segundos')), + Expanded( + child: _campoTiempo( + _segundosCtrl, + AppLocalizations.of(context).secondsLabel, + ), + ), ], ), const SizedBox(height: PluriLayout.compactGap), SwitchListTile.adaptive( contentPadding: EdgeInsets.zero, - title: const Text('Guardar como acceso rápido'), + title: Text( + AppLocalizations.of(context).saveQuickAccess, + ), value: _guardarPreset, onChanged: (value) => setState(() => _guardarPreset = value), ), const SizedBox(height: PluriLayout.sectionGap), FilledButton.icon( icon: const Icon(Icons.bedtime_rounded), - label: const Text('Iniciar timer'), + label: Text( + AppLocalizations.of(context).startTimer, + ), onPressed: _confirmar, ), ], diff --git a/lib/estado/estado_idioma.dart b/lib/estado/estado_idioma.dart new file mode 100644 index 0000000..82196b2 --- /dev/null +++ b/lib/estado/estado_idioma.dart @@ -0,0 +1,68 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class EstadoIdioma extends ChangeNotifier { + EstadoIdioma({SharedPreferences? sharedPreferences}) + : _sharedPreferences = sharedPreferences { + _cargar(); + } + + static const String _keyLocale = 'idioma_manual_v1'; + + final SharedPreferences? _sharedPreferences; + + Locale? _localeSeleccionado; + + Locale? get localeSeleccionado => _localeSeleccionado; + bool get usaSistema => _localeSeleccionado == null; + + Future seleccionarSistema() async { + _localeSeleccionado = null; + notifyListeners(); + final prefs = await _resolverPrefs(); + await prefs.remove(_keyLocale); + } + + Future seleccionarLocale(Locale locale) async { + final tag = _serializarLocale(locale); + _localeSeleccionado = locale; + notifyListeners(); + final prefs = await _resolverPrefs(); + await prefs.setString(_keyLocale, tag); + } + + Future _cargar() async { + final prefs = await _resolverPrefs(); + final localeGuardado = prefs.getString(_keyLocale); + _localeSeleccionado = _parsearLocale(localeGuardado); + notifyListeners(); + } + + Future _resolverPrefs() async { + return _sharedPreferences ?? SharedPreferences.getInstance(); + } + + Locale? _parsearLocale(String? value) { + if (value == null || value.trim().isEmpty) return null; + final partes = value.split('_'); + final languageCode = partes.first; + if (languageCode.isEmpty) return null; + final countryCode = partes.length > 1 && partes[1].isNotEmpty + ? partes[1] + : null; + return Locale.fromSubtags( + languageCode: languageCode, + countryCode: countryCode, + ); + } + + String _serializarLocale(Locale locale) { + final countryCode = locale.countryCode; + if (countryCode == null || countryCode.isEmpty) { + return locale.languageCode; + } + return '${locale.languageCode}_$countryCode'; + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb new file mode 100644 index 0000000..2e69a75 --- /dev/null +++ b/lib/l10n/app_en.arb @@ -0,0 +1,47 @@ +{ + "@@locale": "en", + "appTitle": "PluriWave", + "navHome": "Home", + "navSearch": "Search", + "navFavorites": "Favorites", + "navAlarms": "Alarms", + "navSettings": "Settings", + "actionOk": "OK", + "sleepTimer": "Sleep timer", + "sleepTimerDescription": "Smooth radio shutdown with an exact countdown.", + "cancelTimer": "Cancel timer", + "optionOther": "Other", + "customDurationTitle": "Custom duration", + "durationGreaterThanZero": "Choose a duration greater than zero.", + "hoursLabel": "Hours", + "minutesLabel": "Minutes", + "secondsLabel": "Seconds", + "saveQuickAccess": "Save as quick access", + "startTimer": "Start timer", + "skipCurrentAlarmExecution": "Skipped this execution of {alarmName}.", + "@skipCurrentAlarmExecution": { + "placeholders": { + "alarmName": {} + } + }, + "settingsTitle": "Settings", + "settingsSubtitle": "Fine-grained sound control, backups, and custom stations.", + "languageSectionTitle": "Language", + "languageSectionDescription": "Choose how the app language is displayed.", + "languageSystemDefault": "System", + "languageSpanish": "Spanish", + "languageEnglish": "English", + "languageUpdated": "Language updated: {languageName}", + "@languageUpdated": { + "placeholders": { + "languageName": {} + } + }, + "languageUpdatedSystem": "Language updated: System", + "timerSectionTitle": "Sleep timer", + "timerSectionAdd": "Add", + "timerSectionDescription": "Customize the quick presets shown when automatically stopping the radio.", + "timerSectionRestoreRecommended": "Restore recommended times", + "newQuickAccessTitle": "New quick access", + "saveQuickAccessButton": "Save quick access" +} diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb new file mode 100644 index 0000000..d476546 --- /dev/null +++ b/lib/l10n/app_es.arb @@ -0,0 +1,47 @@ +{ + "@@locale": "es", + "appTitle": "PluriWave", + "navHome": "Inicio", + "navSearch": "Buscar", + "navFavorites": "Favoritos", + "navAlarms": "Alarmas", + "navSettings": "Ajustes", + "actionOk": "OK", + "sleepTimer": "Timer de sueño", + "sleepTimerDescription": "Apagado suave de la radio con cuenta atrás exacta.", + "cancelTimer": "Cancelar timer", + "optionOther": "Otro", + "customDurationTitle": "Duración personalizada", + "durationGreaterThanZero": "Elegí una duración mayor que cero.", + "hoursLabel": "Horas", + "minutesLabel": "Minutos", + "secondsLabel": "Segundos", + "saveQuickAccess": "Guardar como acceso rápido", + "startTimer": "Iniciar timer", + "skipCurrentAlarmExecution": "Omitida esta ejecución de {alarmName}.", + "@skipCurrentAlarmExecution": { + "placeholders": { + "alarmName": {} + } + }, + "settingsTitle": "Ajustes", + "settingsSubtitle": "Control fino de sonido, copias de seguridad y emisoras personalizadas.", + "languageSectionTitle": "Idioma", + "languageSectionDescription": "Elegí cómo se muestra el idioma de la app.", + "languageSystemDefault": "Sistema", + "languageSpanish": "Español", + "languageEnglish": "Inglés", + "languageUpdated": "Idioma actualizado: {languageName}", + "@languageUpdated": { + "placeholders": { + "languageName": {} + } + }, + "languageUpdatedSystem": "Idioma actualizado: Sistema", + "timerSectionTitle": "Timer de sueño", + "timerSectionAdd": "Añadir", + "timerSectionDescription": "Personalizá los accesos rápidos que aparecen al apagar la radio automáticamente.", + "timerSectionRestoreRecommended": "Restaurar tiempos recomendados", + "newQuickAccessTitle": "Nuevo acceso rápido", + "saveQuickAccessButton": "Guardar acceso rápido" +} diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart new file mode 100644 index 0000000..f17d6da --- /dev/null +++ b/lib/l10n/gen/app_localizations.dart @@ -0,0 +1,338 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'app_localizations_en.dart'; +import 'app_localizations_es.dart'; + +// ignore_for_file: type=lint + +/// Callers can lookup localized strings with an instance of AppLocalizations +/// returned by `AppLocalizations.of(context)`. +/// +/// Applications need to include `AppLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'gen/app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: AppLocalizations.localizationsDelegates, +/// supportedLocales: AppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the AppLocalizations.supportedLocales +/// property. +abstract class AppLocalizations { + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static AppLocalizations of(BuildContext context) { + return Localizations.of(context, AppLocalizations)!; + } + + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [ + Locale('en'), + Locale('es'), + ]; + + /// No description provided for @appTitle. + /// + /// In es, this message translates to: + /// **'PluriWave'** + String get appTitle; + + /// No description provided for @navHome. + /// + /// In es, this message translates to: + /// **'Inicio'** + String get navHome; + + /// No description provided for @navSearch. + /// + /// In es, this message translates to: + /// **'Buscar'** + String get navSearch; + + /// No description provided for @navFavorites. + /// + /// In es, this message translates to: + /// **'Favoritos'** + String get navFavorites; + + /// No description provided for @navAlarms. + /// + /// In es, this message translates to: + /// **'Alarmas'** + String get navAlarms; + + /// No description provided for @navSettings. + /// + /// In es, this message translates to: + /// **'Ajustes'** + String get navSettings; + + /// No description provided for @actionOk. + /// + /// In es, this message translates to: + /// **'OK'** + String get actionOk; + + /// No description provided for @sleepTimer. + /// + /// In es, this message translates to: + /// **'Timer de sueño'** + String get sleepTimer; + + /// No description provided for @sleepTimerDescription. + /// + /// In es, this message translates to: + /// **'Apagado suave de la radio con cuenta atrás exacta.'** + String get sleepTimerDescription; + + /// No description provided for @cancelTimer. + /// + /// In es, this message translates to: + /// **'Cancelar timer'** + String get cancelTimer; + + /// No description provided for @optionOther. + /// + /// In es, this message translates to: + /// **'Otro'** + String get optionOther; + + /// No description provided for @customDurationTitle. + /// + /// In es, this message translates to: + /// **'Duración personalizada'** + String get customDurationTitle; + + /// No description provided for @durationGreaterThanZero. + /// + /// In es, this message translates to: + /// **'Elegí una duración mayor que cero.'** + String get durationGreaterThanZero; + + /// No description provided for @hoursLabel. + /// + /// In es, this message translates to: + /// **'Horas'** + String get hoursLabel; + + /// No description provided for @minutesLabel. + /// + /// In es, this message translates to: + /// **'Minutos'** + String get minutesLabel; + + /// No description provided for @secondsLabel. + /// + /// In es, this message translates to: + /// **'Segundos'** + String get secondsLabel; + + /// No description provided for @saveQuickAccess. + /// + /// In es, this message translates to: + /// **'Guardar como acceso rápido'** + String get saveQuickAccess; + + /// No description provided for @startTimer. + /// + /// In es, this message translates to: + /// **'Iniciar timer'** + String get startTimer; + + /// No description provided for @skipCurrentAlarmExecution. + /// + /// In es, this message translates to: + /// **'Omitida esta ejecución de {alarmName}.'** + String skipCurrentAlarmExecution(Object alarmName); + + /// No description provided for @settingsTitle. + /// + /// In es, this message translates to: + /// **'Ajustes'** + String get settingsTitle; + + /// No description provided for @settingsSubtitle. + /// + /// In es, this message translates to: + /// **'Control fino de sonido, copias de seguridad y emisoras personalizadas.'** + String get settingsSubtitle; + + /// No description provided for @languageSectionTitle. + /// + /// In es, this message translates to: + /// **'Idioma'** + String get languageSectionTitle; + + /// No description provided for @languageSectionDescription. + /// + /// In es, this message translates to: + /// **'Elegí cómo se muestra el idioma de la app.'** + String get languageSectionDescription; + + /// No description provided for @languageSystemDefault. + /// + /// In es, this message translates to: + /// **'Sistema'** + String get languageSystemDefault; + + /// No description provided for @languageSpanish. + /// + /// In es, this message translates to: + /// **'Español'** + String get languageSpanish; + + /// No description provided for @languageEnglish. + /// + /// In es, this message translates to: + /// **'Inglés'** + String get languageEnglish; + + /// No description provided for @languageUpdated. + /// + /// In es, this message translates to: + /// **'Idioma actualizado: {languageName}'** + String languageUpdated(Object languageName); + + /// No description provided for @languageUpdatedSystem. + /// + /// In es, this message translates to: + /// **'Idioma actualizado: Sistema'** + String get languageUpdatedSystem; + + /// No description provided for @timerSectionTitle. + /// + /// In es, this message translates to: + /// **'Timer de sueño'** + String get timerSectionTitle; + + /// No description provided for @timerSectionAdd. + /// + /// In es, this message translates to: + /// **'Añadir'** + String get timerSectionAdd; + + /// No description provided for @timerSectionDescription. + /// + /// In es, this message translates to: + /// **'Personalizá los accesos rápidos que aparecen al apagar la radio automáticamente.'** + String get timerSectionDescription; + + /// No description provided for @timerSectionRestoreRecommended. + /// + /// In es, this message translates to: + /// **'Restaurar tiempos recomendados'** + String get timerSectionRestoreRecommended; + + /// No description provided for @newQuickAccessTitle. + /// + /// In es, this message translates to: + /// **'Nuevo acceso rápido'** + String get newQuickAccessTitle; + + /// No description provided for @saveQuickAccessButton. + /// + /// In es, this message translates to: + /// **'Guardar acceso rápido'** + String get saveQuickAccessButton; +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => + ['en', 'es'].contains(locale.languageCode); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +AppLocalizations lookupAppLocalizations(Locale locale) { + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'en': + return AppLocalizationsEn(); + case 'es': + return AppLocalizationsEs(); + } + + throw FlutterError( + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.', + ); +} diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart new file mode 100644 index 0000000..5028c07 --- /dev/null +++ b/lib/l10n/gen/app_localizations_en.dart @@ -0,0 +1,120 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for English (`en`). +class AppLocalizationsEn extends AppLocalizations { + AppLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get appTitle => 'PluriWave'; + + @override + String get navHome => 'Home'; + + @override + String get navSearch => 'Search'; + + @override + String get navFavorites => 'Favorites'; + + @override + String get navAlarms => 'Alarms'; + + @override + String get navSettings => 'Settings'; + + @override + String get actionOk => 'OK'; + + @override + String get sleepTimer => 'Sleep timer'; + + @override + String get sleepTimerDescription => + 'Smooth radio shutdown with an exact countdown.'; + + @override + String get cancelTimer => 'Cancel timer'; + + @override + String get optionOther => 'Other'; + + @override + String get customDurationTitle => 'Custom duration'; + + @override + String get durationGreaterThanZero => 'Choose a duration greater than zero.'; + + @override + String get hoursLabel => 'Hours'; + + @override + String get minutesLabel => 'Minutes'; + + @override + String get secondsLabel => 'Seconds'; + + @override + String get saveQuickAccess => 'Save as quick access'; + + @override + String get startTimer => 'Start timer'; + + @override + String skipCurrentAlarmExecution(Object alarmName) { + return 'Skipped this execution of $alarmName.'; + } + + @override + String get settingsTitle => 'Settings'; + + @override + String get settingsSubtitle => + 'Fine-grained sound control, backups, and custom stations.'; + + @override + String get languageSectionTitle => 'Language'; + + @override + String get languageSectionDescription => + 'Choose how the app language is displayed.'; + + @override + String get languageSystemDefault => 'System'; + + @override + String get languageSpanish => 'Spanish'; + + @override + String get languageEnglish => 'English'; + + @override + String languageUpdated(Object languageName) { + return 'Language updated: $languageName'; + } + + @override + String get languageUpdatedSystem => 'Language updated: System'; + + @override + String get timerSectionTitle => 'Sleep timer'; + + @override + String get timerSectionAdd => 'Add'; + + @override + String get timerSectionDescription => + 'Customize the quick presets shown when automatically stopping the radio.'; + + @override + String get timerSectionRestoreRecommended => 'Restore recommended times'; + + @override + String get newQuickAccessTitle => 'New quick access'; + + @override + String get saveQuickAccessButton => 'Save quick access'; +} diff --git a/lib/l10n/gen/app_localizations_es.dart b/lib/l10n/gen/app_localizations_es.dart new file mode 100644 index 0000000..9c3e197 --- /dev/null +++ b/lib/l10n/gen/app_localizations_es.dart @@ -0,0 +1,120 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Spanish Castilian (`es`). +class AppLocalizationsEs extends AppLocalizations { + AppLocalizationsEs([String locale = 'es']) : super(locale); + + @override + String get appTitle => 'PluriWave'; + + @override + String get navHome => 'Inicio'; + + @override + String get navSearch => 'Buscar'; + + @override + String get navFavorites => 'Favoritos'; + + @override + String get navAlarms => 'Alarmas'; + + @override + String get navSettings => 'Ajustes'; + + @override + String get actionOk => 'OK'; + + @override + String get sleepTimer => 'Timer de sueño'; + + @override + String get sleepTimerDescription => + 'Apagado suave de la radio con cuenta atrás exacta.'; + + @override + String get cancelTimer => 'Cancelar timer'; + + @override + String get optionOther => 'Otro'; + + @override + String get customDurationTitle => 'Duración personalizada'; + + @override + String get durationGreaterThanZero => 'Elegí una duración mayor que cero.'; + + @override + String get hoursLabel => 'Horas'; + + @override + String get minutesLabel => 'Minutos'; + + @override + String get secondsLabel => 'Segundos'; + + @override + String get saveQuickAccess => 'Guardar como acceso rápido'; + + @override + String get startTimer => 'Iniciar timer'; + + @override + String skipCurrentAlarmExecution(Object alarmName) { + return 'Omitida esta ejecución de $alarmName.'; + } + + @override + String get settingsTitle => 'Ajustes'; + + @override + String get settingsSubtitle => + 'Control fino de sonido, copias de seguridad y emisoras personalizadas.'; + + @override + String get languageSectionTitle => 'Idioma'; + + @override + String get languageSectionDescription => + 'Elegí cómo se muestra el idioma de la app.'; + + @override + String get languageSystemDefault => 'Sistema'; + + @override + String get languageSpanish => 'Español'; + + @override + String get languageEnglish => 'Inglés'; + + @override + String languageUpdated(Object languageName) { + return 'Idioma actualizado: $languageName'; + } + + @override + String get languageUpdatedSystem => 'Idioma actualizado: Sistema'; + + @override + String get timerSectionTitle => 'Timer de sueño'; + + @override + String get timerSectionAdd => 'Añadir'; + + @override + String get timerSectionDescription => + 'Personalizá los accesos rápidos que aparecen al apagar la radio automáticamente.'; + + @override + String get timerSectionRestoreRecommended => 'Restaurar tiempos recomendados'; + + @override + String get newQuickAccessTitle => 'Nuevo acceso rápido'; + + @override + String get saveQuickAccessButton => 'Guardar acceso rápido'; +} diff --git a/lib/pantallas/pantalla_ajustes.dart b/lib/pantallas/pantalla_ajustes.dart index 7b97fb7..6b76739 100644 --- a/lib/pantallas/pantalla_ajustes.dart +++ b/lib/pantallas/pantalla_ajustes.dart @@ -9,7 +9,9 @@ import 'package:provider/provider.dart'; import 'package:share_plus/share_plus.dart' show Share, XFile; import 'package:uuid/uuid.dart'; +import '../estado/estado_idioma.dart'; import '../estado/estado_radio.dart'; +import '../l10n/gen/app_localizations.dart'; import '../modelos/emisora.dart'; import '../widgets/ecualizador_widget.dart'; import '../widgets/pluri_glass_surface.dart'; @@ -22,20 +24,21 @@ class PantallaAjustes extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return ListView( padding: PluriLayout.pageListPadding, - children: const [ + children: [ PluriScreenHeader( - title: 'Ajustes', - subtitle: - 'Control fino de sonido, copias de seguridad y emisoras personalizadas.', + title: l10n.settingsTitle, + subtitle: l10n.settingsSubtitle, glyph: PluriIconGlyph.settings, - trailing: PluriStatusPill( + trailing: const PluriStatusPill( icon: Icons.security_rounded, label: 'Seguro', ), ), - Padding( + const Padding( padding: PluriLayout.pageContentPadding, child: _AjustesContent(), ), @@ -57,6 +60,8 @@ class _AjustesContent extends StatelessWidget { SizedBox(height: 12), _SeccionTimerSueno(), SizedBox(height: 12), + _SeccionIdioma(), + SizedBox(height: 12), _SeccionEmisoraPreferida(), SizedBox(height: 12), _SeccionEmisoras(), @@ -165,6 +170,8 @@ class _SeccionTimerSueno extends StatelessWidget { const _SeccionTimerSueno(); Future _anadirPreset(BuildContext context) async { + final l10n = AppLocalizations.of(context); + final duracion = await showModalBottomSheet( context: context, isScrollControlled: true, @@ -177,7 +184,7 @@ class _SeccionTimerSueno extends StatelessWidget { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - 'Acceso rápido añadido: ${_formatearDuracionTimer(duracion)}', + '${l10n.saveQuickAccessButton}: ${_formatearDuracionTimer(duracion)}', ), ), ); @@ -185,6 +192,8 @@ class _SeccionTimerSueno extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final estado = context.watch(); final presets = estado.timerSuenoPresetsSegundos; return PluriGlassSurface( @@ -196,20 +205,20 @@ class _SeccionTimerSueno extends StatelessWidget { const Icon(Icons.bedtime_rounded), const SizedBox(width: 12), Text( - 'Timer de sueño', + l10n.timerSectionTitle, style: Theme.of(context).textTheme.titleMedium, ), const Spacer(), TextButton.icon( icon: const Icon(Icons.add_rounded), - label: const Text('Añadir'), + label: Text(l10n.timerSectionAdd), onPressed: () => _anadirPreset(context), ), ], ), const SizedBox(height: 8), Text( - 'Personalizá los accesos rápidos que aparecen al apagar la radio automáticamente.', + l10n.timerSectionDescription, style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 12), @@ -236,7 +245,7 @@ class _SeccionTimerSueno extends StatelessWidget { alignment: Alignment.centerLeft, child: TextButton.icon( icon: const Icon(Icons.restore_rounded), - label: const Text('Restaurar tiempos recomendados'), + label: Text(l10n.timerSectionRestoreRecommended), onPressed: () => context.read().restaurarTimerSuenoPresets(), ), @@ -247,6 +256,95 @@ class _SeccionTimerSueno extends StatelessWidget { } } +class _SeccionIdioma extends StatelessWidget { + const _SeccionIdioma(); + + static const _codigoSistema = 'system'; + static const _codigoEspanol = 'es'; + static const _codigoIngles = 'en'; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final estadoIdioma = context.watch(); + final locale = estadoIdioma.localeSeleccionado; + final valorActual = switch (locale?.languageCode) { + _codigoEspanol => _codigoEspanol, + _codigoIngles => _codigoIngles, + _ => _codigoSistema, + }; + + return PluriGlassSurface( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.language_rounded), + const SizedBox(width: 12), + Text( + l10n.languageSectionTitle, + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + const SizedBox(height: 8), + Text( + l10n.languageSectionDescription, + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: valorActual, + decoration: InputDecoration( + labelText: l10n.languageSectionTitle, + border: const OutlineInputBorder(), + ), + items: [ + DropdownMenuItem( + value: _codigoSistema, + child: Text(l10n.languageSystemDefault), + ), + DropdownMenuItem( + value: _codigoEspanol, + child: Text(l10n.languageSpanish), + ), + DropdownMenuItem( + value: _codigoIngles, + child: Text(l10n.languageEnglish), + ), + ], + onChanged: (codigo) async { + if (codigo == null) return; + if (codigo == _codigoSistema) { + await context.read().seleccionarSistema(); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.languageUpdatedSystem)), + ); + return; + } + + final locale = Locale(codigo); + await context.read().seleccionarLocale(locale); + if (!context.mounted) return; + + final nombre = switch (codigo) { + _codigoEspanol => l10n.languageSpanish, + _codigoIngles => l10n.languageEnglish, + _ => l10n.languageSystemDefault, + }; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.languageUpdated(nombre))), + ); + }, + ), + ], + ), + ); + } +} + class _FormularioDuracionTimer extends StatefulWidget { const _FormularioDuracionTimer(); @@ -270,6 +368,8 @@ class _FormularioDuracionTimerState extends State<_FormularioDuracionTimer> { int _leer(TextEditingController ctrl) => int.tryParse(ctrl.text.trim()) ?? 0; void _guardar() { + final l10n = AppLocalizations.of(context); + final duracion = Duration( hours: _leer(_horasCtrl), minutes: _leer(_minutosCtrl), @@ -277,7 +377,7 @@ class _FormularioDuracionTimerState extends State<_FormularioDuracionTimer> { ); if (duracion <= Duration.zero) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Elegí una duración mayor que cero.')), + SnackBar(content: Text(l10n.durationGreaterThanZero)), ); return; } @@ -286,6 +386,8 @@ class _FormularioDuracionTimerState extends State<_FormularioDuracionTimer> { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final bottom = MediaQuery.viewInsetsOf(context).bottom; return SafeArea( child: Padding( @@ -295,23 +397,23 @@ class _FormularioDuracionTimerState extends State<_FormularioDuracionTimer> { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( - 'Nuevo acceso rápido', + l10n.newQuickAccessTitle, style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 12), Row( children: [ - Expanded(child: _campo(_horasCtrl, 'Horas')), + Expanded(child: _campo(_horasCtrl, l10n.hoursLabel)), const SizedBox(width: 8), - Expanded(child: _campo(_minutosCtrl, 'Minutos')), + Expanded(child: _campo(_minutosCtrl, l10n.minutesLabel)), const SizedBox(width: 8), - Expanded(child: _campo(_segundosCtrl, 'Segundos')), + Expanded(child: _campo(_segundosCtrl, l10n.secondsLabel)), ], ), const SizedBox(height: 16), FilledButton.icon( icon: const Icon(Icons.save_rounded), - label: const Text('Guardar acceso rápido'), + label: Text(l10n.saveQuickAccessButton), onPressed: _guardar, ), ], diff --git a/pubspec.lock b/pubspec.lock index 8fb3194..60e3709 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -350,7 +350,7 @@ packages: source: hosted version: "4.1.2" intl: - dependency: transitive + dependency: "direct main" description: name: intl sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" diff --git a/pubspec.yaml b/pubspec.yaml index fc1a857..0cdd153 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,6 +11,7 @@ dependencies: sdk: flutter flutter_localizations: sdk: flutter + intl: ^0.20.2 # Audio just_audio: ^0.9.42 @@ -60,6 +61,7 @@ dev_dependencies: flutter_lints: ^5.0.0 flutter: + generate: true uses-material-design: true assets: diff --git a/test/estado/estado_idioma_test.dart b/test/estado/estado_idioma_test.dart new file mode 100644 index 0000000..f54712b --- /dev/null +++ b/test/estado/estado_idioma_test.dart @@ -0,0 +1,40 @@ +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_idioma.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('EstadoIdioma', () { + test('arranca en modo sistema cuando no hay preferencia', () async { + SharedPreferences.setMockInitialValues({}); + + final estado = EstadoIdioma(); + await Future.delayed(Duration.zero); + + expect(estado.usaSistema, isTrue); + expect(estado.localeSeleccionado, isNull); + }); + + test('persiste locale manual y permite volver a sistema', () async { + SharedPreferences.setMockInitialValues({}); + + final estado = EstadoIdioma(); + await Future.delayed(Duration.zero); + await estado.seleccionarLocale(const Locale('en')); + + expect(estado.usaSistema, isFalse); + expect(estado.localeSeleccionado?.languageCode, 'en'); + + final recargado = EstadoIdioma(); + await Future.delayed(Duration.zero); + expect(recargado.localeSeleccionado?.languageCode, 'en'); + + await recargado.seleccionarSistema(); + expect(recargado.usaSistema, isTrue); + expect(recargado.localeSeleccionado, isNull); + }); + }); +}