fix: completar migracion i18n de literales visibles
This commit is contained in:
@@ -11,6 +11,7 @@ import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../estado/estado_idioma.dart';
|
||||
import '../estado/estado_radio.dart';
|
||||
import '../l10n/app_localizations_ext.dart';
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
import '../modelos/grupo_favoritos.dart';
|
||||
@@ -982,7 +983,7 @@ class _SeccionEmisoras extends StatelessWidget {
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Añadir'),
|
||||
label: Text(AppLocalizations.of(context).customStationsAdd),
|
||||
onPressed: () => _mostrarFormularioAnadir(context),
|
||||
),
|
||||
],
|
||||
@@ -1079,6 +1080,7 @@ class _FormularioEmisoraState extends State<_FormularioEmisora> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final bottom = MediaQuery.of(context).viewInsets.bottom;
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
@@ -1094,7 +1096,7 @@ class _FormularioEmisoraState extends State<_FormularioEmisora> {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Añadir emisora',
|
||||
l10n.addStationTitle,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -1121,19 +1123,19 @@ class _FormularioEmisoraState extends State<_FormularioEmisora> {
|
||||
keyboardType: TextInputType.url,
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) {
|
||||
return AppLocalizations.of(context).requiredField;
|
||||
return l10n.requiredField;
|
||||
}
|
||||
final uri = Uri.tryParse(v.trim());
|
||||
if (uri == null || !uri.hasScheme) return 'URL no válida';
|
||||
if (uri == null || !uri.hasScheme) return l10n.invalidUrl;
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _paisCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'País (opcional)',
|
||||
border: OutlineInputBorder(),
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.countryOptionalLabel,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
@@ -1170,9 +1172,10 @@ class _SeccionBackup extends StatelessWidget {
|
||||
|
||||
await Share.shareXFiles(
|
||||
[XFile(file.path)],
|
||||
subject: 'PluriWave — copia de seguridad',
|
||||
text:
|
||||
'Configuración de PluriWave exportada el ${DateTime.now().toLocal()}',
|
||||
subject: AppLocalizations.of(context).backupShareSubject,
|
||||
text: AppLocalizations.of(
|
||||
context,
|
||||
).backupShareText(DateTime.now().toLocal()),
|
||||
);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
@@ -1204,9 +1207,9 @@ class _SeccionBackup extends StatelessWidget {
|
||||
context: context,
|
||||
builder:
|
||||
(ctx) => AlertDialog(
|
||||
title: const Text('Importar configuración'),
|
||||
content: const Text(
|
||||
'Esto añadirá los favoritos, emisoras y presets del fichero. ¿Continuar?',
|
||||
title: Text(AppLocalizations.of(ctx).backupImportTitle),
|
||||
content: Text(
|
||||
AppLocalizations.of(ctx).backupImportConfirmMessage,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@@ -1226,8 +1229,8 @@ class _SeccionBackup extends StatelessWidget {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
await estado.importarConfig(json);
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Configuración importada correctamente'),
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context).backupImportSuccess),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1264,14 +1267,14 @@ class _SeccionBackup extends StatelessWidget {
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.upload_outlined),
|
||||
title: const Text('Exportar configuración'),
|
||||
title: Text(AppLocalizations.of(context).backupExportTitle),
|
||||
subtitle: Text(AppLocalizations.of(context).backupExportSubtitle),
|
||||
onTap: () => _exportar(context),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.download_outlined),
|
||||
title: const Text('Importar configuración'),
|
||||
title: Text(AppLocalizations.of(context).backupImportTitle),
|
||||
subtitle: Text(AppLocalizations.of(context).backupImportSubtitle),
|
||||
onTap: () => _importar(context),
|
||||
),
|
||||
@@ -1297,14 +1300,14 @@ class _SeccionInfo extends StatelessWidget {
|
||||
final version =
|
||||
snap.hasData
|
||||
? 'v${snap.data!.version}+${snap.data!.buildNumber}'
|
||||
: 'Cargando versión...';
|
||||
: AppLocalizations.of(ctx).appVersionLoading;
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const PluriIcon(
|
||||
glyph: PluriIconGlyph.settings,
|
||||
variant: PluriIconVariant.filled,
|
||||
),
|
||||
title: const Text('PluriWave'),
|
||||
title: Text(AppLocalizations.of(ctx).appTitle),
|
||||
subtitle: Text(
|
||||
AppLocalizations.of(ctx).appVersionSubtitle(version),
|
||||
),
|
||||
@@ -1321,7 +1324,7 @@ class _SeccionInfo extends StatelessWidget {
|
||||
AppLocalizations.of(ctx).savedFavoritesTitle,
|
||||
),
|
||||
trailing: Text(
|
||||
snap.data?.toString() ?? '—',
|
||||
snap.data?.toString() ?? AppLocalizations.of(ctx).dash,
|
||||
style: Theme.of(ctx).textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
@@ -1329,8 +1332,8 @@ class _SeccionInfo extends StatelessWidget {
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.help_outline_rounded),
|
||||
title: Text(_helpTitle(ctx)),
|
||||
subtitle: Text(_helpSubtitle(ctx)),
|
||||
title: Text(AppLocalizations.of(ctx).helpTitle),
|
||||
subtitle: Text(AppLocalizations.of(ctx).helpSubtitle),
|
||||
trailing: const Icon(Icons.chevron_right_rounded),
|
||||
onTap: () => PluriOnboardingDialog.mostrar(ctx),
|
||||
),
|
||||
@@ -1343,12 +1346,14 @@ class _SeccionInfo extends StatelessWidget {
|
||||
),
|
||||
trailing: const Icon(Icons.check_circle, color: Colors.green),
|
||||
),
|
||||
const ListTile(
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(Icons.music_note_outlined),
|
||||
title: Text('Audio en background'),
|
||||
subtitle: Text('Continúa al apagar la pantalla'),
|
||||
trailing: Icon(Icons.check_circle, color: Colors.green),
|
||||
leading: const Icon(Icons.music_note_outlined),
|
||||
title: Text(AppLocalizations.of(ctx).backgroundAudioTitle),
|
||||
subtitle: Text(
|
||||
AppLocalizations.of(ctx).backgroundAudioSubtitle,
|
||||
),
|
||||
trailing: const Icon(Icons.check_circle, color: Colors.green),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1357,28 +1362,6 @@ class _SeccionInfo extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
String _helpTitle(BuildContext context) => switch (Localizations.localeOf(
|
||||
context,
|
||||
).languageCode) {
|
||||
'es' => 'Ayuda y tutorial',
|
||||
'fr' => 'Aide et tutoriel',
|
||||
'de' => 'Hilfe und Tutorial',
|
||||
'it' => 'Aiuto e tutorial',
|
||||
'pt' => 'Ajuda e tutorial',
|
||||
_ => 'Help and tutorial',
|
||||
};
|
||||
|
||||
String _helpSubtitle(BuildContext context) => switch (Localizations.localeOf(
|
||||
context,
|
||||
).languageCode) {
|
||||
'es' => 'Repasá funciones, consejos y novedades de PluriWave.',
|
||||
'fr' => 'Revoyez les fonctions, conseils et nouveautés de PluriWave.',
|
||||
'de' => 'Funktionen, Tipps und Neuigkeiten von PluriWave ansehen.',
|
||||
'it' => 'Rivedi funzioni, consigli e novità di PluriWave.',
|
||||
'pt' => 'Revê funções, dicas e novidades do PluriWave.',
|
||||
_ => 'Review PluriWave features, tips and what’s new.',
|
||||
};
|
||||
|
||||
String _formatearDuracionTimer(Duration duracion) {
|
||||
final horas = duracion.inHours;
|
||||
final minutos = duracion.inMinutes.remainder(60);
|
||||
|
||||
@@ -5,8 +5,9 @@ import 'package:just_audio/just_audio.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../estado/estado_alarmas.dart';
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../estado/estado_radio.dart';
|
||||
import '../l10n/app_localizations_ext.dart';
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../modelos/alarma_musical.dart';
|
||||
import '../servicios/servicio_audio.dart';
|
||||
import '../widgets/pluri_glass_surface.dart';
|
||||
@@ -189,10 +190,10 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_fallbackActivo
|
||||
? 'Sonando con audio seguro interno.'
|
||||
? l10n.alarmRingingFallbackActive
|
||||
: _radioIntentada
|
||||
? 'Intentando reproducir tu emisora con máxima calidad disponible.'
|
||||
: 'Preparando audio seguro interno.',
|
||||
? l10n.alarmRingingTryingStation
|
||||
: l10n.alarmRingingPreparingFallback,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
|
||||
+136
-144
@@ -3,6 +3,8 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../estado/estado_alarmas.dart';
|
||||
import '../estado/estado_radio.dart';
|
||||
import '../l10n/app_localizations_ext.dart';
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../modelos/alarma_musical.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
import '../widgets/pluri_glass_surface.dart';
|
||||
@@ -16,6 +18,7 @@ class PantallaAlarmas extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final estado = context.watch<EstadoAlarmas>();
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: estado.refrescarProgramacion,
|
||||
@@ -23,15 +26,14 @@ class PantallaAlarmas extends StatelessWidget {
|
||||
padding: PluriLayout.pageListPadding,
|
||||
children: [
|
||||
PluriScreenHeader(
|
||||
title: 'Despertar musical',
|
||||
subtitle:
|
||||
'Alarmas con radio, sonido seguro, vacaciones inteligentes y próxima ejecución siempre visible.',
|
||||
title: l10n.alarmScreenTitle,
|
||||
subtitle: l10n.alarmScreenSubtitle,
|
||||
glyph: PluriIconGlyph.alarm,
|
||||
primaryActionLabel: 'Crear alarma',
|
||||
primaryActionLabel: l10n.createAlarmAction,
|
||||
onPrimaryAction: () => _abrirEditor(context),
|
||||
trailing: PluriStatusPill(
|
||||
icon: Icons.alarm_on_rounded,
|
||||
label: '${estado.alarmas.length} alarmas',
|
||||
label: l10n.alarmsCount(estado.alarmas.length),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
@@ -79,12 +81,13 @@ class _PanelProximaAlarma extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final proxima = estado.proximaAlarma;
|
||||
final activasSinProxima =
|
||||
estado.alarmas
|
||||
.where((a) => a.activa && a.proximaProgramable == null)
|
||||
.length;
|
||||
final activasSinProxima = estado.alarmas
|
||||
.where((a) => a.activa && a.proximaProgramable == null)
|
||||
.length;
|
||||
final proximaProgramable = proxima?.proximaProgramable;
|
||||
|
||||
return PluriGlassSurface(
|
||||
glowColor: const Color(0xFFFFB86B).withValues(alpha: 0.28),
|
||||
child: Row(
|
||||
@@ -98,20 +101,25 @@ class _PanelProximaAlarma extends StatelessWidget {
|
||||
Text(
|
||||
proxima == null
|
||||
? activasSinProxima > 0
|
||||
? 'Alarmas activas sin próxima ejecución'
|
||||
: 'Sin alarmas activas'
|
||||
: 'Próxima alarma',
|
||||
? l10n.activeAlarmsWithoutNextTitle
|
||||
: l10n.activeAlarmsNoneTitle
|
||||
: l10n.nextAlarmTitle,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
proxima == null
|
||||
? activasSinProxima > 0
|
||||
? 'Hay $activasSinProxima alarma(s) activas, pero ahora mismo no tienen una fecha futura válida. Revisá fecha, días y vacaciones.'
|
||||
: 'Creá una alarma y PluriWave calculará la siguiente ejecución automáticamente.'
|
||||
: '${proxima.nombre} · ${_fechaHora(proximaProgramable!)}',
|
||||
? l10n.activeAlarmsWithoutNextSubtitle(
|
||||
activasSinProxima,
|
||||
)
|
||||
: l10n.createAlarmHint
|
||||
: l10n.alarmNextSummary(
|
||||
proxima.nombre,
|
||||
_fechaHora(l10n, proximaProgramable!),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -129,9 +137,10 @@ class _TarjetaAlarma extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final estado = context.watch<EstadoAlarmas>();
|
||||
final excepcion = estado.ultimaExcepcionPara(alarma.id);
|
||||
final mensajeVacaciones = _mensajeVacaciones(estado.vacaciones);
|
||||
final mensajeVacaciones = _mensajeVacaciones(l10n, estado.vacaciones);
|
||||
return PluriGlassSurface(
|
||||
glowColor: const Color(0xFF22D3EE).withValues(alpha: 0.22),
|
||||
child: Column(
|
||||
@@ -174,14 +183,14 @@ class _TarjetaAlarma extends StatelessWidget {
|
||||
children: [
|
||||
_InfoChip(
|
||||
icon: Icons.repeat_rounded,
|
||||
label: _programacion(alarma),
|
||||
label: _programacion(l10n, alarma),
|
||||
),
|
||||
_InfoChip(
|
||||
icon: Icons.beach_access_rounded,
|
||||
label:
|
||||
alarma.sonarEnVacaciones
|
||||
? 'Suena en vacaciones'
|
||||
: 'Pausa en vacaciones',
|
||||
? l10n.alarmVacationPlay
|
||||
: l10n.alarmVacationPause,
|
||||
),
|
||||
_InfoChip(
|
||||
icon: Icons.volume_up_rounded,
|
||||
@@ -189,7 +198,7 @@ class _TarjetaAlarma extends StatelessWidget {
|
||||
),
|
||||
_InfoChip(
|
||||
icon: Icons.trending_up_rounded,
|
||||
label: 'Fade-in ${alarma.fadeInSegundos}s',
|
||||
label: l10n.fadeInSeconds(alarma.fadeInSegundos),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -197,20 +206,22 @@ class _TarjetaAlarma extends StatelessWidget {
|
||||
if (alarma.proximaProgramable != null)
|
||||
_NoticeLine(
|
||||
icon: Icons.event_available_rounded,
|
||||
text:
|
||||
'Siguiente ejecución: ${_fechaHora(alarma.proximaProgramable!)}',
|
||||
text: l10n.alarmNextExecution(
|
||||
_fechaHora(l10n, alarma.proximaProgramable!),
|
||||
),
|
||||
)
|
||||
else
|
||||
const _NoticeLine(
|
||||
_NoticeLine(
|
||||
icon: Icons.pause_circle_outline_rounded,
|
||||
text: 'No tiene próxima ejecución activa.',
|
||||
text: l10n.alarmNoNextExecution,
|
||||
),
|
||||
if (excepcion != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
_NoticeLine(
|
||||
icon: Icons.skip_next_rounded,
|
||||
text:
|
||||
'Una ejecución fue omitida: ${_fechaHora(excepcion.ejecucion)}.',
|
||||
text: l10n.alarmSkippedExecution(
|
||||
_fechaHora(l10n, excepcion.ejecucion),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (mensajeVacaciones != null) ...[
|
||||
@@ -225,12 +236,12 @@ class _TarjetaAlarma extends StatelessWidget {
|
||||
children: [
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.edit_rounded),
|
||||
label: const Text('Editar'),
|
||||
label: Text(l10n.editAction),
|
||||
onPressed: () => _abrirEditor(context, alarma: alarma),
|
||||
),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.skip_next_rounded),
|
||||
label: const Text('Omitir siguiente'),
|
||||
label: Text(l10n.skipNextAction),
|
||||
onPressed:
|
||||
alarma.proximaProgramable == null
|
||||
? null
|
||||
@@ -250,8 +261,13 @@ class _TarjetaAlarma extends StatelessWidget {
|
||||
SnackBar(
|
||||
content: Text(
|
||||
actualizada?.proximaProgramable == null
|
||||
? 'Alarma omitida. No queda próxima ejecución.'
|
||||
: 'Alarma omitida. Volverá el ${_fechaHora(actualizada!.proximaProgramable!)}.',
|
||||
? l10n.alarmSkippedNoNextSnackbar
|
||||
: l10n.alarmSkippedReturnsSnackbar(
|
||||
_fechaHora(
|
||||
l10n,
|
||||
actualizada!.proximaProgramable!,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -260,7 +276,7 @@ class _TarjetaAlarma extends StatelessWidget {
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
tooltip: 'Eliminar',
|
||||
tooltip: l10n.deleteAction,
|
||||
icon: const Icon(Icons.delete_outline_rounded),
|
||||
onPressed: () => estado.eliminarAlarma(alarma.id),
|
||||
),
|
||||
@@ -271,7 +287,10 @@ class _TarjetaAlarma extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
String? _mensajeVacaciones(List<RangoVacaciones> vacaciones) {
|
||||
String? _mensajeVacaciones(
|
||||
AppLocalizations l10n,
|
||||
List<RangoVacaciones> vacaciones,
|
||||
) {
|
||||
if (alarma.sonarEnVacaciones) return null;
|
||||
final ahora = DateTime.now();
|
||||
RangoVacaciones? actual;
|
||||
@@ -283,12 +302,17 @@ class _TarjetaAlarma extends StatelessWidget {
|
||||
}
|
||||
if (actual != null) {
|
||||
if (alarma.proximaProgramable == null) {
|
||||
return 'Está pausada por vacaciones (${actual.nombre}) y sin próxima ejecución.';
|
||||
return l10n.alarmVacationPausedNoNext(actual.nombre);
|
||||
}
|
||||
return 'Está pausada por vacaciones (${actual.nombre}) y vuelve el ${_fechaHora(alarma.proximaProgramable!)}.';
|
||||
return l10n.alarmVacationPausedReturns(
|
||||
actual.nombre,
|
||||
_fechaHora(l10n, alarma.proximaProgramable!),
|
||||
);
|
||||
}
|
||||
if (alarma.proximaProgramable != null) {
|
||||
return 'Con vacaciones activas, volverá a sonar el ${_fechaHora(alarma.proximaProgramable!)}.';
|
||||
return l10n.alarmVacationReturns(
|
||||
_fechaHora(l10n, alarma.proximaProgramable!),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -330,9 +354,10 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
final alarma = widget.alarma;
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final ahora = DateTime.now().add(const Duration(minutes: 5));
|
||||
_nombreController = TextEditingController(
|
||||
text: alarma?.nombre ?? 'Despertador musical',
|
||||
text: alarma?.nombre ?? l10n.defaultAlarmName,
|
||||
);
|
||||
_hora = TimeOfDay(
|
||||
hour: alarma?.hora ?? ahora.hour,
|
||||
@@ -356,6 +381,7 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final radio = context.watch<EstadoRadio>();
|
||||
final bottom = MediaQuery.of(context).viewInsets.bottom;
|
||||
if (!_favoritosSolicitados) {
|
||||
@@ -393,7 +419,7 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.alarma == null ? 'Nueva alarma' : 'Editar alarma',
|
||||
widget.alarma == null ? l10n.newAlarmTitle : l10n.editAlarmTitle,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
@@ -408,7 +434,7 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
const SizedBox(height: 14),
|
||||
TextField(
|
||||
controller: _nombreController,
|
||||
decoration: const InputDecoration(labelText: 'Nombre'),
|
||||
decoration: InputDecoration(labelText: l10n.nameLabel),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
@@ -416,7 +442,7 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
Expanded(
|
||||
child: _PickerButton(
|
||||
icon: Icons.schedule_rounded,
|
||||
label: 'Hora',
|
||||
label: l10n.timeLabel,
|
||||
value: _hora.format(context),
|
||||
onTap: _elegirHora,
|
||||
),
|
||||
@@ -425,7 +451,7 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
Expanded(
|
||||
child: _PickerButton(
|
||||
icon: Icons.event_rounded,
|
||||
label: 'Fecha',
|
||||
label: l10n.dateLabel,
|
||||
value: _fechaCorta(_fecha),
|
||||
onTap:
|
||||
_tipo == TipoProgramacionAlarma.unica
|
||||
@@ -437,18 +463,18 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SegmentedButton<TipoProgramacionAlarma>(
|
||||
segments: const [
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: TipoProgramacionAlarma.unica,
|
||||
label: Text('Una vez'),
|
||||
label: Text(l10n.oneTimeOption),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: TipoProgramacionAlarma.diaria,
|
||||
label: Text('Diaria'),
|
||||
label: Text(l10n.dailyOption),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: TipoProgramacionAlarma.diasSemana,
|
||||
label: Text('Días'),
|
||||
label: Text(l10n.weekdaysOption),
|
||||
),
|
||||
],
|
||||
selected: {_tipo},
|
||||
@@ -462,7 +488,7 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
children: [
|
||||
for (var i = DateTime.monday; i <= DateTime.sunday; i++)
|
||||
FilterChip(
|
||||
label: Text(_diaCorto(i)),
|
||||
label: Text(l10n.weekdayShort(i)),
|
||||
selected: _diasSemana.contains(i),
|
||||
onSelected:
|
||||
(selected) => setState(() {
|
||||
@@ -477,7 +503,7 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
const SizedBox(height: 14),
|
||||
_SectionLabel(
|
||||
icon: 'assets/icons/alarmas/fallback_sound.png',
|
||||
text: 'Sonido y volumen',
|
||||
text: l10n.soundAndVolumeTitle,
|
||||
),
|
||||
Slider(
|
||||
value: _volumen,
|
||||
@@ -490,11 +516,11 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Fade-in de alarma'),
|
||||
title: Text(l10n.alarmFadeInTitle),
|
||||
subtitle: Text(
|
||||
_fadeInSegundos == 0
|
||||
? '0 s (sin transición)'
|
||||
: '$_fadeInSegundos s (de 5% al volumen elegido)',
|
||||
? l10n.alarmFadeInOff
|
||||
: l10n.alarmFadeInProgress(_fadeInSegundos),
|
||||
),
|
||||
),
|
||||
Slider(
|
||||
@@ -509,21 +535,21 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
),
|
||||
DropdownButtonFormField<SonidoInternoAlarma>(
|
||||
initialValue: _sonidoInterno,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Sonido seguro interno',
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.soundInternalSafe,
|
||||
),
|
||||
items: const [
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: SonidoInternoAlarma.amanecer,
|
||||
child: Text('Amanecer cálido'),
|
||||
child: Text(l10n.soundWarmSunrise),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: SonidoInternoAlarma.campanaSuave,
|
||||
child: Text('Campana suave'),
|
||||
child: Text(l10n.soundSoftBell),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: SonidoInternoAlarma.pulsoDigital,
|
||||
child: Text('Pulso digital'),
|
||||
child: Text(l10n.soundDigitalPulse),
|
||||
),
|
||||
],
|
||||
onChanged:
|
||||
@@ -535,14 +561,14 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
DropdownButtonFormField<String>(
|
||||
key: ValueKey(_emisora?.uuid ?? 'sin-emisora'),
|
||||
initialValue: _emisora?.uuid,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Emisora favorita',
|
||||
prefixIcon: Icon(Icons.radio_rounded),
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.favoriteStationLabel,
|
||||
prefixIcon: const Icon(Icons.radio_rounded),
|
||||
),
|
||||
items: [
|
||||
const DropdownMenuItem<String>(
|
||||
DropdownMenuItem<String>(
|
||||
value: '',
|
||||
child: Text('Sin emisora: usar sonido interno'),
|
||||
child: Text(l10n.noStationUseInternalSound),
|
||||
),
|
||||
for (final emisora in favoritas)
|
||||
DropdownMenuItem<String>(
|
||||
@@ -564,9 +590,7 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
),
|
||||
if (favoritas.isEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'Guardá emisoras en Favoritos para usarlas como alarma musical.',
|
||||
),
|
||||
Text(l10n.saveFavoritesAlarmHint),
|
||||
],
|
||||
if (radio.emisoraActual != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
@@ -576,7 +600,7 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
onPressed:
|
||||
() => setState(() => _emisora = radio.emisoraActual),
|
||||
icon: const Icon(Icons.add_task_rounded),
|
||||
label: const Text('Usar emisora actual'),
|
||||
label: Text(l10n.useCurrentStationAction),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -584,22 +608,19 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
SwitchListTile.adaptive(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: _sonarEnVacaciones,
|
||||
onChanged:
|
||||
(value) => setState(() => _sonarEnVacaciones = value),
|
||||
onChanged: (value) => setState(() => _sonarEnVacaciones = value),
|
||||
secondary: const _AssetIcon(
|
||||
'assets/icons/alarmas/vacation_wave.png',
|
||||
size: 42,
|
||||
),
|
||||
title: const Text('Sonar durante vacaciones'),
|
||||
subtitle: const Text(
|
||||
'Si lo apagás, la próxima ejecución saltará al primer día válido.',
|
||||
),
|
||||
title: Text(l10n.playDuringVacations),
|
||||
subtitle: Text(l10n.playDuringVacationsHint),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: _guardar,
|
||||
icon: const Icon(Icons.check_rounded),
|
||||
label: const Text('Guardar alarma'),
|
||||
label: Text(l10n.saveAlarmAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -627,7 +648,7 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
Future<void> _guardar() async {
|
||||
if (_tipo == TipoProgramacionAlarma.diasSemana && _diasSemana.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Elegí al menos un día de la semana.')),
|
||||
SnackBar(content: Text(AppLocalizations.of(context).chooseOneWeekdayError)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -645,7 +666,7 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
.copyWith(
|
||||
nombre:
|
||||
_nombreController.text.trim().isEmpty
|
||||
? 'Despertador musical'
|
||||
? AppLocalizations.of(context).defaultAlarmName
|
||||
: _nombreController.text.trim(),
|
||||
hora: _hora.hour,
|
||||
minuto: _hora.minute,
|
||||
@@ -688,7 +709,11 @@ class _AccesoDiagnostico extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final diag = estado.diagnostico;
|
||||
final exactStatus = diag?.puedeProgramarExactas == true ? l10n.statusOk : l10n.statusPending;
|
||||
final notificationStatus = diag?.notificacionesPermitidas == true ? l10n.statusOk : l10n.statusPending;
|
||||
final screenStatus = diag?.puedeUsarPantallaCompleta == true ? l10n.statusOk : l10n.statusPending;
|
||||
return TextButton.icon(
|
||||
icon: const _AssetIcon(
|
||||
'assets/icons/alarmas/android_reliability.png',
|
||||
@@ -696,8 +721,12 @@ class _AccesoDiagnostico extends StatelessWidget {
|
||||
),
|
||||
label: Text(
|
||||
diag == null
|
||||
? 'Revisar fiabilidad Android'
|
||||
: 'Fiabilidad: exactas ${diag.puedeProgramarExactas ? 'OK' : 'pendiente'} ? notificaciones ${diag.notificacionesPermitidas ? 'OK' : 'pendiente'} ? pantalla ${diag.puedeUsarPantallaCompleta ? 'OK' : 'pendiente'}',
|
||||
? l10n.androidReliabilityTitle
|
||||
: l10n.androidReliabilityStatus(
|
||||
exactStatus,
|
||||
notificationStatus,
|
||||
screenStatus,
|
||||
),
|
||||
),
|
||||
onPressed: () async {
|
||||
if (diag != null && !diag.puedeProgramarExactas) {
|
||||
@@ -722,6 +751,7 @@ class _PanelVacaciones extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final vacaciones = [...estado.vacaciones]
|
||||
..sort((a, b) => a.inicioDia.compareTo(b.inicioDia));
|
||||
return PluriGlassSurface(
|
||||
@@ -738,7 +768,7 @@ class _PanelVacaciones extends StatelessWidget {
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Rangos de vacaciones',
|
||||
l10n.vacationRangesTitle,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
@@ -747,17 +777,14 @@ class _PanelVacaciones extends StatelessWidget {
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => _abrirAlta(context),
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
label: const Text('Agregar'),
|
||||
label: Text(l10n.addAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Si una alarma tiene "Pausa en vacaciones", se salta automáticamente estos rangos.',
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(l10n.vacationRangesHint),
|
||||
if (vacaciones.isEmpty)
|
||||
const Text('Sin rangos cargados.')
|
||||
Text(l10n.vacationRangesEmpty)
|
||||
else
|
||||
for (final rango in vacaciones)
|
||||
ListTile(
|
||||
@@ -768,7 +795,7 @@ class _PanelVacaciones extends StatelessWidget {
|
||||
'${_fechaCorta(rango.inicioDia)} → ${_fechaCorta(rango.finDia)}',
|
||||
),
|
||||
trailing: IconButton(
|
||||
tooltip: 'Eliminar rango',
|
||||
tooltip: l10n.deleteRangeAction,
|
||||
onPressed: () => estado.eliminarRangoVacaciones(rango.id),
|
||||
icon: const Icon(Icons.delete_outline_rounded),
|
||||
),
|
||||
@@ -807,7 +834,9 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
|
||||
final hoy = DateTime.now();
|
||||
_inicio = DateTime(hoy.year, hoy.month, hoy.day);
|
||||
_fin = _inicio.add(const Duration(days: 2));
|
||||
_nombreController = TextEditingController(text: 'Vacaciones');
|
||||
_nombreController = TextEditingController(
|
||||
text: AppLocalizations.of(context).vacationsDefaultName,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -818,6 +847,7 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final bottom = MediaQuery.of(context).viewInsets.bottom;
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(12, 12, 12, bottom + 12),
|
||||
@@ -829,7 +859,7 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Nuevo rango de vacaciones',
|
||||
l10n.newVacationRangeTitle,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900),
|
||||
@@ -837,7 +867,7 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _nombreController,
|
||||
decoration: const InputDecoration(labelText: 'Nombre'),
|
||||
decoration: InputDecoration(labelText: l10n.nameLabel),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
@@ -845,7 +875,7 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
|
||||
Expanded(
|
||||
child: _PickerButton(
|
||||
icon: Icons.play_arrow_rounded,
|
||||
label: 'Inicio',
|
||||
label: l10n.startLabel,
|
||||
value: _fechaCorta(_inicio),
|
||||
onTap: () => _elegirFecha(esInicio: true),
|
||||
),
|
||||
@@ -854,7 +884,7 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
|
||||
Expanded(
|
||||
child: _PickerButton(
|
||||
icon: Icons.stop_rounded,
|
||||
label: 'Fin',
|
||||
label: l10n.endLabel,
|
||||
value: _fechaCorta(_fin),
|
||||
onTap: () => _elegirFecha(esInicio: false),
|
||||
),
|
||||
@@ -865,7 +895,7 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
|
||||
FilledButton.icon(
|
||||
onPressed: _guardar,
|
||||
icon: const Icon(Icons.check_rounded),
|
||||
label: const Text('Guardar rango'),
|
||||
label: Text(l10n.saveRangeAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1012,14 +1042,15 @@ class _EmptyAlarmas extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const PluriGlassSurface(
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return PluriGlassSurface(
|
||||
child: Column(
|
||||
children: [
|
||||
_AssetIcon('assets/icons/alarmas/alarm_music.png', size: 92),
|
||||
SizedBox(height: 12),
|
||||
Text('Todavía no hay alarmas.'),
|
||||
SizedBox(height: 4),
|
||||
Text('Creá una para diseñar tu despertar musical.'),
|
||||
const _AssetIcon('assets/icons/alarmas/alarm_music.png', size: 92),
|
||||
const SizedBox(height: 12),
|
||||
Text(l10n.noAlarmsYetTitle),
|
||||
const SizedBox(height: 4),
|
||||
Text(l10n.noAlarmsYetSubtitle),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -1029,59 +1060,20 @@ class _EmptyAlarmas extends StatelessWidget {
|
||||
String _hora(AlarmaMusical alarma) =>
|
||||
'${alarma.hora.toString().padLeft(2, '0')}:${alarma.minuto.toString().padLeft(2, '0')}';
|
||||
|
||||
String _programacion(AlarmaMusical alarma) {
|
||||
String _programacion(AppLocalizations l10n, AlarmaMusical alarma) {
|
||||
return switch (alarma.tipoProgramacion) {
|
||||
TipoProgramacionAlarma.unica =>
|
||||
'Una vez · ${_fechaCorta(alarma.fechaUnica ?? DateTime.now())}',
|
||||
TipoProgramacionAlarma.diaria => 'Diaria',
|
||||
l10n.alarmScheduleOnce(_fechaCorta(alarma.fechaUnica ?? DateTime.now())),
|
||||
TipoProgramacionAlarma.diaria => l10n.dailyOption,
|
||||
TipoProgramacionAlarma.diasSemana =>
|
||||
'Días: ${alarma.diasSemana.map(_diaCorto).join(', ')}',
|
||||
l10n.alarmScheduleWeekdays(
|
||||
alarma.diasSemana.map(l10n.weekdayShort).join(', '),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
String _fechaHora(DateTime fecha) {
|
||||
final local = fecha.toLocal();
|
||||
return '${_diaLargo(local.weekday)} ${local.day} de ${_mes(local.month)} a las '
|
||||
'${local.hour.toString().padLeft(2, '0')}:${local.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
String _fechaHora(AppLocalizations l10n, DateTime fecha) =>
|
||||
l10n.dateTimeSentence(fecha);
|
||||
|
||||
String _fechaCorta(DateTime fecha) =>
|
||||
'${fecha.day.toString().padLeft(2, '0')}/${fecha.month.toString().padLeft(2, '0')}/${fecha.year}';
|
||||
|
||||
String _diaCorto(int dia) => switch (dia) {
|
||||
DateTime.monday => 'Lun',
|
||||
DateTime.tuesday => 'Mar',
|
||||
DateTime.wednesday => 'Mié',
|
||||
DateTime.thursday => 'Jue',
|
||||
DateTime.friday => 'Vie',
|
||||
DateTime.saturday => 'Sáb',
|
||||
DateTime.sunday => 'Dom',
|
||||
_ => '?',
|
||||
};
|
||||
|
||||
String _diaLargo(int dia) => switch (dia) {
|
||||
DateTime.monday => 'lunes',
|
||||
DateTime.tuesday => 'martes',
|
||||
DateTime.wednesday => 'miércoles',
|
||||
DateTime.thursday => 'jueves',
|
||||
DateTime.friday => 'viernes',
|
||||
DateTime.saturday => 'sábado',
|
||||
DateTime.sunday => 'domingo',
|
||||
_ => 'día',
|
||||
};
|
||||
|
||||
String _mes(int mes) => switch (mes) {
|
||||
1 => 'enero',
|
||||
2 => 'febrero',
|
||||
3 => 'marzo',
|
||||
4 => 'abril',
|
||||
5 => 'mayo',
|
||||
6 => 'junio',
|
||||
7 => 'julio',
|
||||
8 => 'agosto',
|
||||
9 => 'septiembre',
|
||||
10 => 'octubre',
|
||||
11 => 'noviembre',
|
||||
12 => 'diciembre',
|
||||
_ => 'mes',
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../estado/estado_radio.dart';
|
||||
import '../l10n/app_localizations_ext.dart';
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../widgets/pluri_glass_surface.dart';
|
||||
import '../widgets/pluri_icon.dart';
|
||||
|
||||
@@ -49,10 +49,10 @@ class PantallaFavoritos extends StatelessWidget {
|
||||
}
|
||||
|
||||
final gruposVisibles = grupos.isEmpty
|
||||
? const [
|
||||
? [
|
||||
GrupoFavoritos(
|
||||
id: GrupoFavoritos.sinAsignarId,
|
||||
nombre: 'Sin asignar',
|
||||
nombre: l10n.favoriteGroupsUnassigned,
|
||||
orden: 0,
|
||||
protegido: true,
|
||||
),
|
||||
|
||||
@@ -4,6 +4,8 @@ import 'package:provider/provider.dart';
|
||||
import 'package:shimmer/shimmer.dart' as shimmer;
|
||||
|
||||
import '../estado/estado_radio.dart';
|
||||
import '../l10n/app_localizations_ext.dart';
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../widgets/pluri_glass_surface.dart';
|
||||
import '../widgets/pluri_icon.dart';
|
||||
import '../widgets/pluri_layout.dart';
|
||||
@@ -41,52 +43,61 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
||||
Widget build(BuildContext context) {
|
||||
final estado = context.watch<EstadoRadio>();
|
||||
final theme = Theme.of(context);
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: estado.cargarPopulares,
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: _heroHeader(context, estado)),
|
||||
SliverToBoxAdapter(child: _seccionCercanas(estado, theme)),
|
||||
SliverToBoxAdapter(child: _seccionTendencias(estado, theme)),
|
||||
SliverToBoxAdapter(child: _chipGeneros(context, theme)),
|
||||
SliverToBoxAdapter(child: _heroHeader(context, estado, l10n)),
|
||||
SliverToBoxAdapter(child: _seccionCercanas(estado, theme, l10n)),
|
||||
SliverToBoxAdapter(child: _seccionTendencias(estado, theme, l10n)),
|
||||
SliverToBoxAdapter(child: _chipGeneros(context, theme, l10n)),
|
||||
if (estado.error != null)
|
||||
SliverToBoxAdapter(child: _errorBanner(estado, theme)),
|
||||
SliverToBoxAdapter(child: _errorBanner(estado, theme, l10n)),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(PluriLayout.horizontal, 0, PluriLayout.horizontal, PluriLayout.bottomChromeInset),
|
||||
sliver: _gridEmisoras(estado),
|
||||
sliver: _gridEmisoras(estado, l10n),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _heroHeader(BuildContext context, EstadoRadio estado) {
|
||||
Widget _heroHeader(
|
||||
BuildContext context,
|
||||
EstadoRadio estado,
|
||||
AppLocalizations l10n,
|
||||
) {
|
||||
return PluriScreenHeader(
|
||||
title: 'PluriWave',
|
||||
subtitle: 'Radio global en vivo con senales limpias, favoritos inteligentes y una experiencia visual de concurso.',
|
||||
title: l10n.appTitle,
|
||||
subtitle: l10n.homeScreenSubtitle,
|
||||
glyph: PluriIconGlyph.home,
|
||||
primaryActionLabel: 'Explorar emisoras',
|
||||
primaryActionLabel: l10n.exploreStations,
|
||||
onPrimaryAction: estado.cargarPopulares,
|
||||
trailing: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
PluriStatusPill(
|
||||
icon: Icons.public_rounded,
|
||||
label: '${estado.emisorasInicio.length} radios',
|
||||
label: l10n.homeStationsCount(estado.emisorasInicio.length),
|
||||
accent: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const PluriStatusPill(
|
||||
PluriStatusPill(
|
||||
icon: Icons.hd_rounded,
|
||||
label: 'Calidad HD',
|
||||
label: l10n.qualityHd,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _seccionCercanas(EstadoRadio estado, ThemeData theme) {
|
||||
Widget _seccionCercanas(
|
||||
EstadoRadio estado,
|
||||
ThemeData theme,
|
||||
AppLocalizations l10n,
|
||||
) {
|
||||
final pais = estado.paisCercanoDetectado;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(PluriLayout.horizontal, 8, PluriLayout.horizontal, 0),
|
||||
@@ -99,7 +110,7 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
pais == null ? 'Cerca de vos' : 'Cerca de vos ? $pais',
|
||||
pais == null ? l10n.nearYou : l10n.nearYouInCountry(pais),
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
@@ -116,7 +127,7 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.my_location_rounded, size: 18),
|
||||
label: const Text('Detectar'),
|
||||
label: Text(l10n.detectAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -155,7 +166,11 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _seccionTendencias(EstadoRadio estado, ThemeData theme) {
|
||||
Widget _seccionTendencias(
|
||||
EstadoRadio estado,
|
||||
ThemeData theme,
|
||||
AppLocalizations l10n,
|
||||
) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(PluriLayout.horizontal, 8, PluriLayout.horizontal, 0),
|
||||
child: PluriGlassSurface(
|
||||
@@ -163,7 +178,7 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Radar en directo', style: theme.textTheme.titleMedium),
|
||||
Text(l10n.liveRadar, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 56,
|
||||
@@ -199,7 +214,11 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _chipGeneros(BuildContext context, ThemeData theme) {
|
||||
Widget _chipGeneros(
|
||||
BuildContext context,
|
||||
ThemeData theme,
|
||||
AppLocalizations l10n,
|
||||
) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(PluriLayout.horizontal, 16, PluriLayout.horizontal, 8),
|
||||
child: PluriGlassSurface(
|
||||
@@ -207,7 +226,7 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Géneros', style: theme.textTheme.titleMedium),
|
||||
Text(l10n.genresTitle, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
@@ -216,7 +235,7 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
||||
_generos.map((g) {
|
||||
final seleccionado = _generoSeleccionado == g;
|
||||
return FilterChip(
|
||||
label: Text(g),
|
||||
label: Text(l10n.genreName(g)),
|
||||
selected: seleccionado,
|
||||
onSelected: (_) {
|
||||
setState(() {
|
||||
@@ -237,7 +256,11 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _errorBanner(EstadoRadio estado, ThemeData theme) {
|
||||
Widget _errorBanner(
|
||||
EstadoRadio estado,
|
||||
ThemeData theme,
|
||||
AppLocalizations l10n,
|
||||
) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: PluriGlassSurface(
|
||||
@@ -249,7 +272,7 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
||||
Expanded(child: Text(estado.error!)),
|
||||
TextButton(
|
||||
onPressed: estado.cargarPopulares,
|
||||
child: const Text('Reintentar'),
|
||||
child: Text(l10n.retryAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -257,7 +280,7 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _gridEmisoras(EstadoRadio estado) {
|
||||
Widget _gridEmisoras(EstadoRadio estado, AppLocalizations l10n) {
|
||||
final emisoras =
|
||||
_generoSeleccionado != null
|
||||
? estado.resultadosBusqueda
|
||||
@@ -282,11 +305,11 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
||||
}
|
||||
|
||||
if (emisoras.isEmpty) {
|
||||
return const SliverFillRemaining(
|
||||
return SliverFillRemaining(
|
||||
child: PluriEmptyState(
|
||||
glyph: PluriIconGlyph.home,
|
||||
title: 'No hay emisoras disponibles',
|
||||
subtitle: 'Proba refrescar o elegir otro género para volver a capturar señal.',
|
||||
title: l10n.noStationsAvailable,
|
||||
subtitle: l10n.noStationsAvailableSubtitle,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import 'package:provider/provider.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
|
||||
import '../estado/estado_radio.dart';
|
||||
import '../l10n/app_localizations_ext.dart';
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
import '../servicios/servicio_audio.dart';
|
||||
import '../servicios/servicio_timer.dart';
|
||||
@@ -75,6 +77,7 @@ class _PantallaReproductorState extends State<PantallaReproductor>
|
||||
final theme = Theme.of(context);
|
||||
final tokens = context.pluriTokens;
|
||||
final estado = context.watch<EstadoRadio>();
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final emisoraActiva = estado.emisoraActual ?? widget.emisora;
|
||||
final esFavorito = estado.listaFavoritos.any(
|
||||
(e) => e.uuid == emisoraActiva.uuid,
|
||||
@@ -86,7 +89,7 @@ class _PantallaReproductorState extends State<PantallaReproductor>
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.keyboard_arrow_down_rounded, size: 32),
|
||||
tooltip: 'Cerrar',
|
||||
tooltip: l10n.closeAction,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
actions: [
|
||||
@@ -99,8 +102,8 @@ class _PantallaReproductorState extends State<PantallaReproductor>
|
||||
),
|
||||
tooltip:
|
||||
estado.ecualizadorActivo
|
||||
? 'Desactivar ecualizador'
|
||||
: 'Activar ecualizador',
|
||||
? l10n.equalizerDisable
|
||||
: l10n.equalizerEnable,
|
||||
onPressed:
|
||||
() =>
|
||||
estado.cambiarEcualizadorActivo(!estado.ecualizadorActivo),
|
||||
@@ -112,7 +115,7 @@ class _PantallaReproductorState extends State<PantallaReproductor>
|
||||
: Icons.favorite_outline_rounded,
|
||||
color: esFavorito ? theme.colorScheme.error : null,
|
||||
),
|
||||
tooltip: esFavorito ? 'Quitar de favoritos' : 'Anadir a favoritos',
|
||||
tooltip: esFavorito ? l10n.favoritesRemoveTooltip : l10n.favoritesAddTooltip,
|
||||
onPressed: () async => estado.toggleFavorito(emisoraActiva),
|
||||
),
|
||||
],
|
||||
@@ -148,7 +151,7 @@ class _PantallaReproductorState extends State<PantallaReproductor>
|
||||
const SizedBox(height: 6),
|
||||
if (emisoraActiva.codec != null || emisoraActiva.bitrate != null)
|
||||
Text(
|
||||
_codecInfo(emisoraActiva),
|
||||
_codecInfo(context, emisoraActiva),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: Colors.white.withValues(alpha: 0.72),
|
||||
),
|
||||
@@ -186,13 +189,13 @@ class _PantallaReproductorState extends State<PantallaReproductor>
|
||||
);
|
||||
}
|
||||
|
||||
String _codecInfo(Emisora e) {
|
||||
String _codecInfo(BuildContext context, Emisora e) {
|
||||
final parts = <String>[];
|
||||
if (e.codec != null) parts.add(e.codec!.toUpperCase());
|
||||
if (e.bitrate != null && e.bitrate! > 0) parts.add('${e.bitrate} kbps');
|
||||
return parts.isEmpty
|
||||
? 'Calidad no informada'
|
||||
: 'Calidad original: ${parts.join(' · ')}';
|
||||
? AppLocalizations.of(context).qualityUnknown
|
||||
: AppLocalizations.of(context).qualityOriginal(parts.join(' ? '));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,6 +361,7 @@ class _GrabacionWidget extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final grabacion = estado.estadoGrabacion;
|
||||
final activa = grabacion.activa;
|
||||
@@ -381,7 +385,7 @@ class _GrabacionWidget extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
activa ? 'Grabando radio' : 'Grabación directa',
|
||||
activa ? l10n.recordingActiveTitle : l10n.recordingDirectTitle,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
@@ -389,7 +393,7 @@ class _GrabacionWidget extends StatelessWidget {
|
||||
Text(
|
||||
activa
|
||||
? '${_formatearDuracion(grabacion.transcurrido)} · ${_formatearBytes(grabacion.bytes)}'
|
||||
: 'Guarda el stream original, sin recomprimir.',
|
||||
: l10n.recordingsOriginalStreamHint,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
@@ -405,7 +409,7 @@ class _GrabacionWidget extends StatelessWidget {
|
||||
children: [
|
||||
FilledButton.tonalIcon(
|
||||
icon: Icon(activa ? Icons.stop_rounded : Icons.mic_rounded),
|
||||
label: Text(activa ? 'Parar' : 'Grabar'),
|
||||
label: Text(activa ? l10n.stopAction : l10n.recordAction),
|
||||
onPressed:
|
||||
activa
|
||||
? estado.detenerGrabacion
|
||||
@@ -413,13 +417,13 @@ class _GrabacionWidget extends StatelessWidget {
|
||||
),
|
||||
if (!activa)
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Abrir carpeta',
|
||||
tooltip: l10n.recordingsOpenFolder,
|
||||
icon: const Icon(Icons.folder_open_rounded),
|
||||
onPressed: () => _abrirCarpetaGrabaciones(context),
|
||||
),
|
||||
if (!activa && hayUltimaGrabacion)
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Abrir última grabación',
|
||||
tooltip: l10n.recordingsOpenLatest,
|
||||
icon: const Icon(Icons.audio_file_rounded),
|
||||
onPressed: () => _abrirUltimaGrabacion(context),
|
||||
),
|
||||
@@ -436,7 +440,7 @@ class _GrabacionWidget extends StatelessWidget {
|
||||
if (!context.mounted) return;
|
||||
if (!abierto) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('No se pudo abrir la última grabación')),
|
||||
SnackBar(content: Text(AppLocalizations.of(context).recordingsOpenLatestError)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -447,8 +451,8 @@ class _GrabacionWidget extends StatelessWidget {
|
||||
if (!context.mounted) return;
|
||||
if (!abierto) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('No se pudo abrir la carpeta de grabaciones'),
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context).recordingsOpenFolderPlainError),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -466,11 +470,11 @@ class _GrabacionWidget extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Grabar radio',
|
||||
AppLocalizations.of(ctx).recordRadioTitle,
|
||||
style: Theme.of(ctx).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text('Elige cuánto tiempo querés grabar.'),
|
||||
Text(AppLocalizations.of(ctx).recordRadioSubtitle),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
@@ -481,7 +485,7 @@ class _GrabacionWidget extends StatelessWidget {
|
||||
Icons.all_inclusive_rounded,
|
||||
size: 18,
|
||||
),
|
||||
label: const Text('Indefinida'),
|
||||
label: Text(AppLocalizations.of(ctx).indefiniteOption),
|
||||
onPressed: () {
|
||||
estado.iniciarGrabacion();
|
||||
Navigator.pop(ctx);
|
||||
@@ -497,7 +501,7 @@ class _GrabacionWidget extends StatelessWidget {
|
||||
),
|
||||
ActionChip(
|
||||
avatar: const Icon(Icons.tune_rounded, size: 18),
|
||||
label: const Text('Personalizada'),
|
||||
label: Text(AppLocalizations.of(ctx).customOption),
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
_mostrarDuracionPersonalizada(context);
|
||||
@@ -521,7 +525,7 @@ class _GrabacionWidget extends StatelessWidget {
|
||||
context: context,
|
||||
builder:
|
||||
(ctx) => AlertDialog(
|
||||
title: const Text('Duración de grabación'),
|
||||
title: Text(AppLocalizations.of(ctx).recordDurationTitle),
|
||||
content: Form(
|
||||
key: formKey,
|
||||
child: Row(
|
||||
@@ -529,18 +533,18 @@ class _GrabacionWidget extends StatelessWidget {
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: minutosCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Minutos'),
|
||||
decoration: InputDecoration(labelText: AppLocalizations.of(ctx).minutesLabel),
|
||||
keyboardType: TextInputType.number,
|
||||
validator: _validarNumero,
|
||||
validator: (value) => _validarNumero(ctx, value),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: segundosCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Segundos'),
|
||||
decoration: InputDecoration(labelText: AppLocalizations.of(ctx).secondsLabel),
|
||||
keyboardType: TextInputType.number,
|
||||
validator: _validarNumero,
|
||||
validator: (value) => _validarNumero(ctx, value),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -549,7 +553,7 @@ class _GrabacionWidget extends StatelessWidget {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Cancelar'),
|
||||
child: Text(AppLocalizations.of(ctx).cancelAction),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
@@ -564,7 +568,7 @@ class _GrabacionWidget extends StatelessWidget {
|
||||
estado.iniciarGrabacion(duracion: duracion);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('Grabar'),
|
||||
child: Text(AppLocalizations.of(ctx).recordAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -574,10 +578,10 @@ class _GrabacionWidget extends StatelessWidget {
|
||||
segundosCtrl.dispose();
|
||||
}
|
||||
|
||||
String? _validarNumero(String? value) {
|
||||
String? _validarNumero(BuildContext context, String? value) {
|
||||
if (value == null || value.trim().isEmpty) return null;
|
||||
final n = int.tryParse(value.trim());
|
||||
if (n == null || n < 0) return 'Número inválido';
|
||||
if (n == null || n < 0) return AppLocalizations.of(context).invalidNumber;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -617,6 +621,7 @@ class _Controles extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return StreamBuilder<EstadoReproduccion>(
|
||||
@@ -638,7 +643,7 @@ class _Controles extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'No se puede reproducir esta radio',
|
||||
l10n.playerPlaybackErrorTitle,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
@@ -647,7 +652,7 @@ class _Controles extends StatelessWidget {
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.tonalIcon(
|
||||
icon: const Icon(Icons.refresh_rounded, size: 18),
|
||||
label: const Text('Reintentar'),
|
||||
label: Text(l10n.retryAction),
|
||||
onPressed: () => estado.reproducir(emisora),
|
||||
),
|
||||
],
|
||||
@@ -662,7 +667,7 @@ class _Controles extends StatelessWidget {
|
||||
children: [
|
||||
Semantics(
|
||||
button: true,
|
||||
label: 'Detener reproduccion',
|
||||
label: l10n.stopPlaybackTooltip,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.stop_rounded),
|
||||
iconSize: 34,
|
||||
@@ -671,7 +676,7 @@ class _Controles extends StatelessWidget {
|
||||
minHeight: 56,
|
||||
),
|
||||
color: Colors.white.withValues(alpha: 0.78),
|
||||
tooltip: 'Detener',
|
||||
tooltip: l10n.stopAction,
|
||||
onPressed: cargando ? null : estado.detenerReproduccion,
|
||||
),
|
||||
),
|
||||
@@ -712,8 +717,8 @@ class _Controles extends StatelessWidget {
|
||||
button: true,
|
||||
label:
|
||||
reproduciendo
|
||||
? 'Pausar reproduccion'
|
||||
: 'Iniciar reproduccion',
|
||||
? l10n.pausePlaybackTooltip
|
||||
: l10n.startPlaybackTooltip,
|
||||
child: Center(
|
||||
child:
|
||||
cargando
|
||||
@@ -739,7 +744,7 @@ class _Controles extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Semantics(
|
||||
label: reproduciendo ? 'En vivo' : 'No esta reproduciendo',
|
||||
label: reproduciendo ? l10n.liveNow : l10n.notPlaying,
|
||||
child: Icon(
|
||||
Icons.fiber_manual_record_rounded,
|
||||
size: 32,
|
||||
@@ -768,7 +773,7 @@ class _TimerWidget extends StatelessWidget {
|
||||
if (!estado.timer.activo) {
|
||||
return TextButton.icon(
|
||||
icon: const Icon(Icons.bedtime_outlined, size: 18),
|
||||
label: const Text('Timer de sueno'),
|
||||
label: Text(AppLocalizations.of(context).sleepTimer),
|
||||
onPressed: () => _mostrarTimerDialog(context),
|
||||
);
|
||||
}
|
||||
@@ -798,7 +803,7 @@ class _TimerWidget extends StatelessWidget {
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
),
|
||||
child: const Text('Cancelar'),
|
||||
child: Text(AppLocalizations.of(ctx).cancelAction),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -818,7 +823,7 @@ class _TimerWidget extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Timer de sueno',
|
||||
AppLocalizations.of(ctx).sleepTimer,
|
||||
style: Theme.of(ctx).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Reference in New Issue
Block a user