feat(i18n): add localization foundation
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m52s
Build & Deploy Pluriwave / Análisis de código (push) Successful in 24s

This commit is contained in:
2026-05-22 13:29:52 +02:00
parent d85dee6fa8
commit 3f548fd53e
13 changed files with 986 additions and 65 deletions
+4 -3
View File
@@ -2,10 +2,11 @@
## Internacionalización AAA ## 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. - [ ] 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. - [ ] Soportar formatos locales de fecha, hora, números y duración usando helpers centralizados.
- [ ] 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.
- [ ] Resolver correctamente singular/plural y variantes por cantidad, por ejemplo `1 emisora` vs `2 emisoras`. - [ ] 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. - [ ] 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. - [ ] Revisar la aplicación de Farolero como referencia para detectar el conjunto de idiomas que nos interesa mantener.
+6
View File
@@ -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
+74 -44
View File
@@ -3,6 +3,8 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'estado/estado_radio.dart'; import 'estado/estado_radio.dart';
import 'estado/estado_alarmas.dart'; import 'estado/estado_alarmas.dart';
import 'estado/estado_idioma.dart';
import 'l10n/gen/app_localizations.dart';
import 'modelos/alarma_musical.dart'; import 'modelos/alarma_musical.dart';
import 'pantallas/pantalla_alarmas.dart'; import 'pantallas/pantalla_alarmas.dart';
import 'pantallas/pantalla_alarma_sonando.dart'; import 'pantallas/pantalla_alarma_sonando.dart';
@@ -27,14 +29,21 @@ class PluriWaveApp extends StatelessWidget {
providers: [ providers: [
ChangeNotifierProvider(create: (_) => EstadoRadio()), ChangeNotifierProvider(create: (_) => EstadoRadio()),
ChangeNotifierProvider(create: (_) => EstadoAlarmas()), ChangeNotifierProvider(create: (_) => EstadoAlarmas()),
ChangeNotifierProvider(create: (_) => EstadoIdioma()),
], ],
child: MaterialApp( child: Consumer<EstadoIdioma>(
title: 'PluriWave', builder:
debugShowCheckedModeBanner: false, (context, estadoIdioma, _) => MaterialApp(
theme: PluriWaveTheme.dark(), title: 'PluriWave',
darkTheme: PluriWaveTheme.dark(), debugShowCheckedModeBanner: false,
themeMode: ThemeMode.dark, theme: PluriWaveTheme.dark(),
home: const _PaginaPrincipal(), 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(), PantallaAjustes(),
]; ];
static const _navItems = [ List<PluriNavItem> _navItems(AppLocalizations l10n) => [
PluriNavItem( PluriNavItem(glyph: PluriIconGlyph.home, label: l10n.navHome),
glyph: PluriIconGlyph.home, PluriNavItem(glyph: PluriIconGlyph.search, label: l10n.navSearch),
label: 'Inicio', PluriNavItem(glyph: PluriIconGlyph.favorites, label: l10n.navFavorites),
), PluriNavItem(glyph: PluriIconGlyph.alarm, label: l10n.navAlarms),
PluriNavItem( PluriNavItem(glyph: PluriIconGlyph.settings, label: l10n.navSettings),
glyph: PluriIconGlyph.search,
label: 'Buscar',
),
PluriNavItem(
glyph: PluriIconGlyph.favorites,
label: 'Favoritos',
),
PluriNavItem(
glyph: PluriIconGlyph.alarm,
label: 'Alarmas',
),
PluriNavItem(
glyph: PluriIconGlyph.settings,
label: 'Ajustes',
),
]; ];
@override @override
@@ -101,7 +95,10 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
SnackBar( SnackBar(
content: Text(msg), content: Text(msg),
duration: const Duration(seconds: 3), 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return PluriWaveScaffold( return PluriWaveScaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('PluriWave'), title: Text(l10n.appTitle),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.bedtime_outlined), icon: const Icon(Icons.bedtime_outlined),
tooltip: 'Timer de sueño', tooltip: l10n.sleepTimer,
onPressed: () => _mostrarTimerDialog(context), onPressed: () => _mostrarTimerDialog(context),
), ),
], ],
@@ -177,7 +176,7 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
children: [ children: [
const MiniReproductor(), const MiniReproductor(),
PluriBottomNavigation( PluriBottomNavigation(
items: _navItems, items: _navItems(l10n),
selectedIndex: _indice, selectedIndex: _indice,
onSelected: (i) => setState(() => _indice = i), onSelected: (i) => setState(() => _indice = i),
), ),
@@ -212,7 +211,11 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
setState(() => _indice = 3); setState(() => _indice = 3);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Omitida esta ejecución de ${alarma.nombre}.'), content: Text(
AppLocalizations.of(context).skipCurrentAlarmExecution(
alarma.nombre,
),
),
), ),
); );
return; return;
@@ -252,12 +255,12 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Timer de sueño', AppLocalizations.of(ctx).sleepTimer,
style: Theme.of(ctx).textTheme.titleLarge, style: Theme.of(ctx).textTheme.titleLarge,
), ),
const SizedBox(height: PluriLayout.sectionGap), const SizedBox(height: PluriLayout.sectionGap),
Text( Text(
'Apagado suave de la radio con cuenta atrás exacta.', AppLocalizations.of(ctx).sleepTimerDescription,
style: Theme.of(ctx).textTheme.bodySmall, style: Theme.of(ctx).textTheme.bodySmall,
), ),
const SizedBox(height: PluriLayout.panelGap), const SizedBox(height: PluriLayout.panelGap),
@@ -280,7 +283,9 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
estado.cancelarTimer(); estado.cancelarTimer();
Navigator.pop(ctx); Navigator.pop(ctx);
}, },
child: const Text('Cancelar timer'), child: Text(
AppLocalizations.of(ctx).cancelTimer,
),
), ),
], ],
); );
@@ -308,7 +313,9 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
), ),
ActionChip( ActionChip(
avatar: const Icon(Icons.tune_rounded, size: 18), avatar: const Icon(Icons.tune_rounded, size: 18),
label: const Text('Otro'), label: Text(
AppLocalizations.of(ctx).optionOther,
),
onPressed: () async { onPressed: () async {
final duracion = final duracion =
await _pedirDuracionPersonalizada(ctx); await _pedirDuracionPersonalizada(ctx);
@@ -382,7 +389,11 @@ class _TimerPersonalizadoSheetState extends State<_TimerPersonalizadoSheet> {
); );
if (duracion <= Duration.zero) { if (duracion <= Duration.zero) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Elegí una duración mayor que cero.')), SnackBar(
content: Text(
AppLocalizations.of(context).durationGreaterThanZero,
),
),
); );
return; return;
} }
@@ -403,30 +414,49 @@ class _TimerPersonalizadoSheetState extends State<_TimerPersonalizadoSheet> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( Text(
'Duración personalizada', AppLocalizations.of(context).customDurationTitle,
style: Theme.of(context).textTheme.titleLarge, style: Theme.of(context).textTheme.titleLarge,
), ),
const SizedBox(height: PluriLayout.sectionGap), const SizedBox(height: PluriLayout.sectionGap),
Row( Row(
children: [ children: [
Expanded(child: _campoTiempo(_horasCtrl, 'Horas')), Expanded(
child: _campoTiempo(
_horasCtrl,
AppLocalizations.of(context).hoursLabel,
),
),
const SizedBox(width: PluriLayout.compactGap), const SizedBox(width: PluriLayout.compactGap),
Expanded(child: _campoTiempo(_minutosCtrl, 'Minutos')), Expanded(
child: _campoTiempo(
_minutosCtrl,
AppLocalizations.of(context).minutesLabel,
),
),
const SizedBox(width: PluriLayout.compactGap), const SizedBox(width: PluriLayout.compactGap),
Expanded(child: _campoTiempo(_segundosCtrl, 'Segundos')), Expanded(
child: _campoTiempo(
_segundosCtrl,
AppLocalizations.of(context).secondsLabel,
),
),
], ],
), ),
const SizedBox(height: PluriLayout.compactGap), const SizedBox(height: PluriLayout.compactGap),
SwitchListTile.adaptive( SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: const Text('Guardar como acceso rápido'), title: Text(
AppLocalizations.of(context).saveQuickAccess,
),
value: _guardarPreset, value: _guardarPreset,
onChanged: (value) => setState(() => _guardarPreset = value), onChanged: (value) => setState(() => _guardarPreset = value),
), ),
const SizedBox(height: PluriLayout.sectionGap), const SizedBox(height: PluriLayout.sectionGap),
FilledButton.icon( FilledButton.icon(
icon: const Icon(Icons.bedtime_rounded), icon: const Icon(Icons.bedtime_rounded),
label: const Text('Iniciar timer'), label: Text(
AppLocalizations.of(context).startTimer,
),
onPressed: _confirmar, onPressed: _confirmar,
), ),
], ],
+68
View File
@@ -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<void> seleccionarSistema() async {
_localeSeleccionado = null;
notifyListeners();
final prefs = await _resolverPrefs();
await prefs.remove(_keyLocale);
}
Future<void> seleccionarLocale(Locale locale) async {
final tag = _serializarLocale(locale);
_localeSeleccionado = locale;
notifyListeners();
final prefs = await _resolverPrefs();
await prefs.setString(_keyLocale, tag);
}
Future<void> _cargar() async {
final prefs = await _resolverPrefs();
final localeGuardado = prefs.getString(_keyLocale);
_localeSeleccionado = _parsearLocale(localeGuardado);
notifyListeners();
}
Future<SharedPreferences> _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';
}
}
+47
View File
@@ -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"
}
+47
View File
@@ -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"
}
+338
View File
@@ -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, youll need to edit this
/// file.
///
/// First, open your projects ios/Runner.xcworkspace Xcode workspace file.
/// Then, in the Project Navigator, open the Info.plist file under the Runner
/// projects 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<AppLocalizations>(context, AppLocalizations)!;
}
static const LocalizationsDelegate<AppLocalizations> 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<LocalizationsDelegate<dynamic>> localizationsDelegates =
<LocalizationsDelegate<dynamic>>[
delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
];
/// A list of this localizations delegate's supported locales.
static const List<Locale> supportedLocales = <Locale>[
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<AppLocalizations> {
const _AppLocalizationsDelegate();
@override
Future<AppLocalizations> load(Locale locale) {
return SynchronousFuture<AppLocalizations>(lookupAppLocalizations(locale));
}
@override
bool isSupported(Locale locale) =>
<String>['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.',
);
}
+120
View File
@@ -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';
}
+120
View File
@@ -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';
}
+119 -17
View File
@@ -9,7 +9,9 @@ import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart' show Share, XFile; import 'package:share_plus/share_plus.dart' show Share, XFile;
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../estado/estado_idioma.dart';
import '../estado/estado_radio.dart'; import '../estado/estado_radio.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart'; import '../modelos/emisora.dart';
import '../widgets/ecualizador_widget.dart'; import '../widgets/ecualizador_widget.dart';
import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_glass_surface.dart';
@@ -22,20 +24,21 @@ class PantallaAjustes extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return ListView( return ListView(
padding: PluriLayout.pageListPadding, padding: PluriLayout.pageListPadding,
children: const [ children: [
PluriScreenHeader( PluriScreenHeader(
title: 'Ajustes', title: l10n.settingsTitle,
subtitle: subtitle: l10n.settingsSubtitle,
'Control fino de sonido, copias de seguridad y emisoras personalizadas.',
glyph: PluriIconGlyph.settings, glyph: PluriIconGlyph.settings,
trailing: PluriStatusPill( trailing: const PluriStatusPill(
icon: Icons.security_rounded, icon: Icons.security_rounded,
label: 'Seguro', label: 'Seguro',
), ),
), ),
Padding( const Padding(
padding: PluriLayout.pageContentPadding, padding: PluriLayout.pageContentPadding,
child: _AjustesContent(), child: _AjustesContent(),
), ),
@@ -57,6 +60,8 @@ class _AjustesContent extends StatelessWidget {
SizedBox(height: 12), SizedBox(height: 12),
_SeccionTimerSueno(), _SeccionTimerSueno(),
SizedBox(height: 12), SizedBox(height: 12),
_SeccionIdioma(),
SizedBox(height: 12),
_SeccionEmisoraPreferida(), _SeccionEmisoraPreferida(),
SizedBox(height: 12), SizedBox(height: 12),
_SeccionEmisoras(), _SeccionEmisoras(),
@@ -165,6 +170,8 @@ class _SeccionTimerSueno extends StatelessWidget {
const _SeccionTimerSueno(); const _SeccionTimerSueno();
Future<void> _anadirPreset(BuildContext context) async { Future<void> _anadirPreset(BuildContext context) async {
final l10n = AppLocalizations.of(context);
final duracion = await showModalBottomSheet<Duration>( final duracion = await showModalBottomSheet<Duration>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
@@ -177,7 +184,7 @@ class _SeccionTimerSueno extends StatelessWidget {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
'Acceso rápido añadido: ${_formatearDuracionTimer(duracion)}', '${l10n.saveQuickAccessButton}: ${_formatearDuracionTimer(duracion)}',
), ),
), ),
); );
@@ -185,6 +192,8 @@ class _SeccionTimerSueno extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final estado = context.watch<EstadoRadio>(); final estado = context.watch<EstadoRadio>();
final presets = estado.timerSuenoPresetsSegundos; final presets = estado.timerSuenoPresetsSegundos;
return PluriGlassSurface( return PluriGlassSurface(
@@ -196,20 +205,20 @@ class _SeccionTimerSueno extends StatelessWidget {
const Icon(Icons.bedtime_rounded), const Icon(Icons.bedtime_rounded),
const SizedBox(width: 12), const SizedBox(width: 12),
Text( Text(
'Timer de sueño', l10n.timerSectionTitle,
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
const Spacer(), const Spacer(),
TextButton.icon( TextButton.icon(
icon: const Icon(Icons.add_rounded), icon: const Icon(Icons.add_rounded),
label: const Text('Añadir'), label: Text(l10n.timerSectionAdd),
onPressed: () => _anadirPreset(context), onPressed: () => _anadirPreset(context),
), ),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Personalizá los accesos rápidos que aparecen al apagar la radio automáticamente.', l10n.timerSectionDescription,
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -236,7 +245,7 @@ class _SeccionTimerSueno extends StatelessWidget {
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: TextButton.icon( child: TextButton.icon(
icon: const Icon(Icons.restore_rounded), icon: const Icon(Icons.restore_rounded),
label: const Text('Restaurar tiempos recomendados'), label: Text(l10n.timerSectionRestoreRecommended),
onPressed: onPressed:
() => context.read<EstadoRadio>().restaurarTimerSuenoPresets(), () => context.read<EstadoRadio>().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<EstadoIdioma>();
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<String>(
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<EstadoIdioma>().seleccionarSistema();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.languageUpdatedSystem)),
);
return;
}
final locale = Locale(codigo);
await context.read<EstadoIdioma>().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 { class _FormularioDuracionTimer extends StatefulWidget {
const _FormularioDuracionTimer(); const _FormularioDuracionTimer();
@@ -270,6 +368,8 @@ class _FormularioDuracionTimerState extends State<_FormularioDuracionTimer> {
int _leer(TextEditingController ctrl) => int.tryParse(ctrl.text.trim()) ?? 0; int _leer(TextEditingController ctrl) => int.tryParse(ctrl.text.trim()) ?? 0;
void _guardar() { void _guardar() {
final l10n = AppLocalizations.of(context);
final duracion = Duration( final duracion = Duration(
hours: _leer(_horasCtrl), hours: _leer(_horasCtrl),
minutes: _leer(_minutosCtrl), minutes: _leer(_minutosCtrl),
@@ -277,7 +377,7 @@ class _FormularioDuracionTimerState extends State<_FormularioDuracionTimer> {
); );
if (duracion <= Duration.zero) { if (duracion <= Duration.zero) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Elegí una duración mayor que cero.')), SnackBar(content: Text(l10n.durationGreaterThanZero)),
); );
return; return;
} }
@@ -286,6 +386,8 @@ class _FormularioDuracionTimerState extends State<_FormularioDuracionTimer> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final bottom = MediaQuery.viewInsetsOf(context).bottom; final bottom = MediaQuery.viewInsetsOf(context).bottom;
return SafeArea( return SafeArea(
child: Padding( child: Padding(
@@ -295,23 +397,23 @@ class _FormularioDuracionTimerState extends State<_FormularioDuracionTimer> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( Text(
'Nuevo acceso rápido', l10n.newQuickAccessTitle,
style: Theme.of(context).textTheme.titleLarge, style: Theme.of(context).textTheme.titleLarge,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
children: [ children: [
Expanded(child: _campo(_horasCtrl, 'Horas')), Expanded(child: _campo(_horasCtrl, l10n.hoursLabel)),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded(child: _campo(_minutosCtrl, 'Minutos')), Expanded(child: _campo(_minutosCtrl, l10n.minutesLabel)),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded(child: _campo(_segundosCtrl, 'Segundos')), Expanded(child: _campo(_segundosCtrl, l10n.secondsLabel)),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
FilledButton.icon( FilledButton.icon(
icon: const Icon(Icons.save_rounded), icon: const Icon(Icons.save_rounded),
label: const Text('Guardar acceso rápido'), label: Text(l10n.saveQuickAccessButton),
onPressed: _guardar, onPressed: _guardar,
), ),
], ],
+1 -1
View File
@@ -350,7 +350,7 @@ packages:
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
intl: intl:
dependency: transitive dependency: "direct main"
description: description:
name: intl name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
+2
View File
@@ -11,6 +11,7 @@ dependencies:
sdk: flutter sdk: flutter
flutter_localizations: flutter_localizations:
sdk: flutter sdk: flutter
intl: ^0.20.2
# Audio # Audio
just_audio: ^0.9.42 just_audio: ^0.9.42
@@ -60,6 +61,7 @@ dev_dependencies:
flutter_lints: ^5.0.0 flutter_lints: ^5.0.0
flutter: flutter:
generate: true
uses-material-design: true uses-material-design: true
assets: assets:
+40
View File
@@ -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<void>.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<void>.delayed(Duration.zero);
await estado.seleccionarLocale(const Locale('en'));
expect(estado.usaSistema, isFalse);
expect(estado.localeSeleccionado?.languageCode, 'en');
final recargado = EstadoIdioma();
await Future<void>.delayed(Duration.zero);
expect(recargado.localeSeleccionado?.languageCode, 'en');
await recargado.seleccionarSistema();
expect(recargado.usaSistema, isTrue);
expect(recargado.localeSeleccionado, isNull);
});
});
}