The prototype (t4) draws no global app bar anywhere: every root paints a plain ~56px title row inside its own content instead (Alarmas line 325, Ajustes line 511, Explorar line 641). app.dart wrapped every tab in PluriWaveScaffold(appBar: AppBar(title: Text(appTitle), ...)), adding 56dp of chrome and a "PluriWave" title the prototype never shows. Add PluriRootHeader, a shared 56px title-row widget reused by all 5 roots. Extract app.dart's old _mostrarTimerDialog (only reachable from the removed AppBar action) into a free function, showPluriSleepTimerSheet, so every root's header can open the same sheet directly and the sleep-timer feature stays reachable from every tab with no behaviour change. S1, Tier 1 visual-fidelity pass (audit id 2521).
1259 lines
44 KiB
Dart
1259 lines
44 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../estado/estado_alarmas.dart';
|
|
import '../estado/estado_radio.dart';
|
|
import '../l10n/display_names.dart';
|
|
import '../l10n/formato_fechas.dart';
|
|
import '../l10n/app_localizations_ext.dart';
|
|
import '../l10n/gen/app_localizations.dart';
|
|
import '../modelos/alarma_musical.dart';
|
|
import '../modelos/emisora.dart';
|
|
import '../servicios/servicio_programacion_alarmas.dart';
|
|
import '../tema/pluriwave_theme.dart';
|
|
import '../tema/pluriwave_tokens.dart';
|
|
import '../widgets/editor_hora_inline.dart';
|
|
import '../widgets/pluri_glass_surface.dart';
|
|
import '../widgets/pluri_icon.dart';
|
|
import '../widgets/pluri_layout.dart';
|
|
import '../widgets/pluri_premium_widgets.dart';
|
|
import '../widgets/pluri_push_scaffold.dart';
|
|
import '../widgets/pluri_root_header.dart';
|
|
import '../widgets/pluri_sleep_timer_sheet.dart';
|
|
import 'pantalla_vacaciones.dart';
|
|
|
|
class PantallaAlarmas extends StatelessWidget {
|
|
const PantallaAlarmas({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final estado = context.watch<EstadoAlarmas>();
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
return RefreshIndicator(
|
|
onRefresh: estado.refrescarProgramacion,
|
|
child: ListView(
|
|
padding: PluriLayout.pageListPadding,
|
|
children: [
|
|
// S1 (Tier 1 visual fidelity): the prototype has no global
|
|
// AppBar — this root now draws its own 56px title row instead of
|
|
// relying on app.dart's removed shared chrome (which is also
|
|
// where the sleep-timer action used to live).
|
|
PluriRootHeader(
|
|
title: l10n.alarmScreenTitle,
|
|
onSleepTimer: () => showPluriSleepTimerSheet(context),
|
|
),
|
|
PluriScreenHeader(
|
|
title: l10n.alarmScreenTitle,
|
|
subtitle: l10n.alarmScreenSubtitle,
|
|
glyph: PluriIconGlyph.alarm,
|
|
primaryActionLabel: l10n.createAlarmAction,
|
|
onPrimaryAction: () => _abrirEditor(context),
|
|
trailing: PluriStatusPill(
|
|
icon: Icons.alarm_on_rounded,
|
|
label: l10n.alarmsCount(estado.alarmas.length),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: PluriLayout.pageContentPadding,
|
|
child: Column(
|
|
children: [
|
|
_PanelProximaAlarma(estado: estado),
|
|
const SizedBox(height: 12),
|
|
if (estado.alarmas.isEmpty)
|
|
const _EmptyAlarmas()
|
|
else
|
|
for (final alarma in estado.alarmas) ...[
|
|
_TarjetaAlarma(alarma: alarma),
|
|
const SizedBox(height: 12),
|
|
],
|
|
_PanelVacaciones(estado: estado),
|
|
const SizedBox(height: 12),
|
|
_AccesoDiagnostico(estado: estado),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _abrirEditor(
|
|
BuildContext context, {
|
|
AlarmaMusical? alarma,
|
|
}) async {
|
|
await showModalBottomSheet<void>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
useSafeArea: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (_) => _EditorAlarmaSheet(alarma: alarma),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PanelProximaAlarma extends StatelessWidget {
|
|
const _PanelProximaAlarma({required this.estado});
|
|
|
|
final EstadoAlarmas estado;
|
|
|
|
@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 proximaProgramable = proxima?.proximaProgramable;
|
|
|
|
return PluriGlassSurface(
|
|
glowColor: context.pluriTokens.warmCoral.withValues(alpha: 0.28),
|
|
child: Row(
|
|
children: [
|
|
_AssetIcon(
|
|
'assets/icons/alarmas/alarm_music.png',
|
|
size: 72,
|
|
semanticLabel: l10n.alarmIconLabel,
|
|
),
|
|
const SizedBox(width: 14),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
proxima == null
|
|
? activasSinProxima > 0
|
|
? l10n.activeAlarmsWithoutNextTitle
|
|
: l10n.noActiveAlarms
|
|
: l10n.nextAlarmTitle,
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w900,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
proxima == null
|
|
? activasSinProxima > 0
|
|
? l10n.activeAlarmsWithoutNextSubtitle(
|
|
activasSinProxima,
|
|
)
|
|
: l10n.createAlarmHint
|
|
: '${_nombreVisibleAlarma(l10n, proxima)} · ${_fechaHora(l10n, proximaProgramable!)}',
|
|
),
|
|
if (proxima != null) ...[
|
|
const SizedBox(height: 10),
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: OutlinedButton.icon(
|
|
key: const ValueKey('hero-skip-next'),
|
|
onPressed: () => _saltarDesdeHero(context, proxima),
|
|
icon: const Icon(Icons.skip_next_rounded, size: 18),
|
|
label: Text(l10n.alarmHeroSkipAction),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Hero banner inline skip (native-alarms delta, WU8): skips the featured
|
|
/// (soonest-firing) alarm via the SAME `saltarProxima` path the old
|
|
/// always-visible per-card skip button used to call — only the trigger
|
|
/// location moved.
|
|
Future<void> _saltarDesdeHero(
|
|
BuildContext context,
|
|
AlarmaMusical proxima,
|
|
) async {
|
|
final l10n = AppLocalizations.of(context);
|
|
final estado = context.read<EstadoAlarmas>();
|
|
await estado.saltarProxima(proxima.id);
|
|
if (!context.mounted) return;
|
|
final actualizada = context.read<EstadoAlarmas>().alarmas.firstWhere(
|
|
(item) => item.id == proxima.id,
|
|
orElse: () => proxima,
|
|
);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
actualizada.proximaProgramable == null
|
|
? l10n.alarmSkippedNoNextSnackbar
|
|
: l10n.alarmSkippedReturnsSnackbar(
|
|
_fechaHora(l10n, actualizada.proximaProgramable!),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Simplified alarm card (native-alarms delta, WU8): giant time + station +
|
|
/// switch, no always-visible action row. Edit/skip/delete are NEVER lost —
|
|
/// they move behind gestures: tap opens the editor, swipe deletes (with
|
|
/// confirmation), skip lives on the hero banner (`_PanelProximaAlarma`)
|
|
/// instead of a per-card button.
|
|
class _TarjetaAlarma extends StatelessWidget {
|
|
const _TarjetaAlarma({required this.alarma});
|
|
|
|
final AlarmaMusical alarma;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final estado = context.watch<EstadoAlarmas>();
|
|
final tokens = context.pluriTokens;
|
|
final estacion =
|
|
alarma.emisora == null
|
|
? l10n.noStationUseInternalSound
|
|
: localizedStationName(l10n, alarma.emisora!.nombre);
|
|
|
|
return Dismissible(
|
|
key: ValueKey('tarjeta-alarma-${alarma.id}'),
|
|
direction: DismissDirection.horizontal,
|
|
background: const _FondoSwipeEliminarAlarma(
|
|
alignment: Alignment.centerLeft,
|
|
),
|
|
secondaryBackground: const _FondoSwipeEliminarAlarma(
|
|
alignment: Alignment.centerRight,
|
|
),
|
|
confirmDismiss: (_) => _confirmarEliminarAlarma(context, l10n),
|
|
onDismissed: (_) => estado.eliminarAlarma(alarma.id),
|
|
child: PluriGlassSurface(
|
|
glowColor: tokens.electricMagenta.withValues(alpha: 0.22),
|
|
padding: EdgeInsets.zero,
|
|
child: Material(
|
|
type: MaterialType.transparency,
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(tokens.radiusMd),
|
|
onTap: () => _abrirEditor(context, alarma: alarma),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
_hora(alarma),
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.displaySmall?.copyWith(
|
|
fontWeight: FontWeight.w900,
|
|
letterSpacing: -1.5,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(estacion, overflow: TextOverflow.ellipsis),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Switch.adaptive(
|
|
value: alarma.activa,
|
|
onChanged: (value) => estado.cambiarActiva(alarma, value),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _abrirEditor(BuildContext context, {required AlarmaMusical alarma}) {
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
useSafeArea: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (_) => _EditorAlarmaSheet(alarma: alarma),
|
|
);
|
|
}
|
|
|
|
Future<bool> _confirmarEliminarAlarma(
|
|
BuildContext context,
|
|
AppLocalizations l10n,
|
|
) async {
|
|
final confirmado = await showDialog<bool>(
|
|
context: context,
|
|
builder:
|
|
(ctx) => AlertDialog(
|
|
title: Text(l10n.alarmDeleteConfirmTitle),
|
|
content: Text(l10n.alarmDeleteConfirmMessage),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
child: Text(l10n.cancelAction),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(ctx, true),
|
|
child: Text(l10n.deleteAction),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
return confirmado ?? false;
|
|
}
|
|
}
|
|
|
|
/// Swipe-to-delete reveal, shown on both sides so either swipe direction
|
|
/// works regardless of locale text direction.
|
|
class _FondoSwipeEliminarAlarma extends StatelessWidget {
|
|
const _FondoSwipeEliminarAlarma({required this.alignment});
|
|
|
|
final Alignment alignment;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final tokens = context.pluriTokens;
|
|
return Container(
|
|
alignment: alignment,
|
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.error,
|
|
borderRadius: BorderRadius.circular(tokens.radiusMd),
|
|
),
|
|
child: Icon(
|
|
Icons.delete_outline_rounded,
|
|
color: Theme.of(context).colorScheme.onError,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _EditorAlarmaSheet extends StatefulWidget {
|
|
const _EditorAlarmaSheet({this.alarma});
|
|
|
|
final AlarmaMusical? alarma;
|
|
|
|
@override
|
|
State<_EditorAlarmaSheet> createState() => _EditorAlarmaSheetState();
|
|
}
|
|
|
|
class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
|
TextEditingController? _nombreController;
|
|
late TimeOfDay _hora;
|
|
late DateTime _fecha;
|
|
late TipoProgramacionAlarma _tipo;
|
|
late Set<int> _diasSemana;
|
|
late double _volumen;
|
|
late int _fadeInSegundos;
|
|
late int _snoozeMinutos;
|
|
late bool _sonarEnVacaciones;
|
|
late SonidoInternoAlarma _sonidoInterno;
|
|
Emisora? _emisora;
|
|
Emisora? _emisoraFallback;
|
|
bool _favoritosSolicitados = false;
|
|
final ServicioProgramacionAlarmas _programacion =
|
|
ServicioProgramacionAlarmas();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final alarma = widget.alarma;
|
|
final ahora = DateTime.now().add(const Duration(minutes: 5));
|
|
_hora = TimeOfDay(
|
|
hour: alarma?.hora ?? ahora.hour,
|
|
minute: alarma?.minuto ?? ahora.minute,
|
|
);
|
|
_fecha = alarma?.fechaUnica ?? ahora;
|
|
_tipo = alarma?.tipoProgramacion ?? TipoProgramacionAlarma.unica;
|
|
_diasSemana = {...alarma?.diasSemana ?? const <int>[]};
|
|
_volumen = alarma?.volumen ?? 0.85;
|
|
_fadeInSegundos = (alarma?.fadeInSegundos ?? 0).clamp(0, 60).toInt();
|
|
_sonarEnVacaciones = alarma?.sonarEnVacaciones ?? true;
|
|
_sonidoInterno = alarma?.sonidoInterno ?? SonidoInternoAlarma.amanecer;
|
|
_snoozeMinutos = alarma?.snoozeMinutos ?? 5;
|
|
_emisora = alarma?.emisora ?? context.read<EstadoRadio>().emisoraPreferida;
|
|
_emisoraFallback = alarma?.emisoraFallback;
|
|
}
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
// Localizations cannot be read from initState (debug assert); the name
|
|
// controller is created lazily here on the first dependency pass.
|
|
if (_nombreController == null) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final alarma = widget.alarma;
|
|
_nombreController = TextEditingController(
|
|
text:
|
|
alarma == null
|
|
? l10n.defaultAlarmName
|
|
: _nombreVisibleAlarma(l10n, alarma),
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_nombreController?.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final radio = context.watch<EstadoRadio>();
|
|
final bottom = MediaQuery.of(context).viewInsets.bottom;
|
|
if (!_favoritosSolicitados) {
|
|
_favoritosSolicitados = true;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) context.read<EstadoRadio>().cargarFavoritos();
|
|
});
|
|
}
|
|
if (_emisora == null &&
|
|
widget.alarma == null &&
|
|
radio.emisoraPreferida != null) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted && _emisora == null) {
|
|
setState(() => _emisora = radio.emisoraPreferida);
|
|
}
|
|
});
|
|
}
|
|
final favoritas = _favoritasConSeleccion(radio.listaFavoritos);
|
|
return Padding(
|
|
padding: EdgeInsets.fromLTRB(12, 12, 12, bottom + 12),
|
|
child: PluriGlassSurface(
|
|
borderRadius: BorderRadius.circular(28),
|
|
padding: const EdgeInsets.all(18),
|
|
child: Material(
|
|
type: MaterialType.transparency,
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
_AssetIcon(
|
|
'assets/icons/alarmas/alarm_music.png',
|
|
size: 58,
|
|
semanticLabel: l10n.alarmIconLabel,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
widget.alarma == null
|
|
? l10n.newAlarmTitle
|
|
: l10n.editAlarmTitle,
|
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.w900,
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.close_rounded),
|
|
onPressed: () => Navigator.pop(context),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 14),
|
|
TextField(
|
|
controller: _nombreController,
|
|
decoration: InputDecoration(labelText: l10n.nameLabel),
|
|
),
|
|
const SizedBox(height: 16),
|
|
// WU10: the native showTimePicker dialog is replaced by a
|
|
// giant inline HH:MM editor (drag/tap to adjust); see
|
|
// `EditorHoraInline`, standalone-tested on its own.
|
|
Center(
|
|
child: EditorHoraInline(
|
|
value: _hora,
|
|
onChanged: (nuevo) => setState(() => _hora = nuevo),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
SegmentedButton<TipoProgramacionAlarma>(
|
|
segments: [
|
|
ButtonSegment(
|
|
value: TipoProgramacionAlarma.unica,
|
|
label: Text(l10n.oneTimeOption),
|
|
),
|
|
ButtonSegment(
|
|
value: TipoProgramacionAlarma.diaria,
|
|
label: Text(l10n.dailyOption),
|
|
),
|
|
ButtonSegment(
|
|
value: TipoProgramacionAlarma.diasSemana,
|
|
label: Text(l10n.weekdaysOption),
|
|
),
|
|
],
|
|
selected: {_tipo},
|
|
onSelectionChanged:
|
|
(value) => setState(() => _tipo = value.first),
|
|
),
|
|
const SizedBox(height: 10),
|
|
// WU10: weekday circles are now ALWAYS visible (previously
|
|
// only inserted into the tree in diasSemana mode) — matching
|
|
// the mockup, which shows them unconditionally under the
|
|
// giant time. They stay disabled (onSelected: null, the
|
|
// standard Material "greyed out" FilterChip state) outside
|
|
// diasSemana mode rather than being wired to silently mutate
|
|
// `_diasSemana` while a different `_tipo` is saved — no
|
|
// scheduling-data-model change, presentation only.
|
|
Wrap(
|
|
spacing: 6,
|
|
children: [
|
|
for (var i = DateTime.monday; i <= DateTime.sunday; i++)
|
|
FilterChip(
|
|
label: Text(_weekdayShort(l10n, i)),
|
|
selected: _diasSemana.contains(i),
|
|
onSelected:
|
|
_tipo == TipoProgramacionAlarma.diasSemana
|
|
? (selected) => setState(() {
|
|
selected
|
|
? _diasSemana.add(i)
|
|
: _diasSemana.remove(i);
|
|
})
|
|
: null,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
_vistaProximaEjecucion(l10n),
|
|
const SizedBox(height: 14),
|
|
_SectionLabel(
|
|
icon: 'assets/icons/alarmas/fallback_sound.png',
|
|
text: l10n.soundAndVolumeSection,
|
|
),
|
|
Slider(
|
|
value: _volumen,
|
|
// S2-R11: floor lowered from 0.25 to 0.0.
|
|
min: 0,
|
|
max: 1,
|
|
divisions: 20,
|
|
label: '${(_volumen * 100).round()}%',
|
|
onChanged: (value) => setState(() => _volumen = value),
|
|
),
|
|
const SizedBox(height: 8),
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: Text(
|
|
l10n.alarmFadeInTitle,
|
|
style: context.pluriType.cardTitle,
|
|
),
|
|
subtitle: Text(
|
|
_fadeInSegundos == 0
|
|
? l10n.alarmFadeInOff
|
|
: l10n.alarmFadeInSummary(_fadeInSegundos),
|
|
),
|
|
),
|
|
Slider(
|
|
value: _fadeInSegundos.toDouble(),
|
|
min: 0,
|
|
max: 60,
|
|
divisions: 60,
|
|
label: '${_fadeInSegundos}s',
|
|
onChanged:
|
|
(value) =>
|
|
setState(() => _fadeInSegundos = value.round()),
|
|
),
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: Text(l10n.alarmSnoozeDurationTitle),
|
|
subtitle: Text(l10n.alarmSnoozeOptionLabel(_snoozeMinutos)),
|
|
),
|
|
SegmentedButton<int>(
|
|
segments: [
|
|
for (final minutos in _opcionesSnooze())
|
|
ButtonSegment(
|
|
value: minutos,
|
|
label: Text(l10n.alarmSnoozeOptionLabel(minutos)),
|
|
),
|
|
],
|
|
selected: {_snoozeMinutos},
|
|
onSelectionChanged:
|
|
(value) => setState(() => _snoozeMinutos = value.first),
|
|
),
|
|
const SizedBox(height: 8),
|
|
// S2-R9: searchable bottom-sheet picker instead of a dropdown,
|
|
// for the primary station. The backup (fallback) picker moves
|
|
// into the Advanced section below (WU10) — the primary choice
|
|
// stays a top-level field, only its secondary/backup sibling
|
|
// is now one tap further away.
|
|
_CampoSelectorEmisora(
|
|
key: const ValueKey('alarm-station-field'),
|
|
label: l10n.favoriteStationLabel,
|
|
icon: Icons.radio_rounded,
|
|
value:
|
|
_emisora == null
|
|
? l10n.noStationUseInternalSound
|
|
: localizedStationName(l10n, _emisora!.nombre),
|
|
onTap:
|
|
() => _elegirEmisora(
|
|
favoritas,
|
|
seleccionar:
|
|
(emisora) => setState(() => _emisora = emisora),
|
|
),
|
|
),
|
|
if (favoritas.isEmpty) ...[
|
|
const SizedBox(height: 6),
|
|
Text(l10n.saveFavoritesAlarmHint),
|
|
],
|
|
if (radio.emisoraActual != null) ...[
|
|
const SizedBox(height: 8),
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: FilledButton.tonalIcon(
|
|
onPressed:
|
|
() => setState(() => _emisora = radio.emisoraActual),
|
|
icon: const Icon(Icons.add_task_rounded),
|
|
label: Text(l10n.useCurrentStationAction),
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 8),
|
|
SwitchListTile.adaptive(
|
|
contentPadding: EdgeInsets.zero,
|
|
value: _sonarEnVacaciones,
|
|
onChanged:
|
|
(value) => setState(() => _sonarEnVacaciones = value),
|
|
secondary: _AssetIcon(
|
|
'assets/icons/alarmas/vacation_wave.png',
|
|
size: 42,
|
|
semanticLabel: l10n.vacationIconLabel,
|
|
),
|
|
title: Text(l10n.playDuringVacations),
|
|
subtitle: Text(l10n.playDuringVacationsHint),
|
|
),
|
|
const SizedBox(height: 8),
|
|
// WU10 (native-alarms delta — Alarm Editor Preserves Date,
|
|
// Fallback Station, and Sound Fields): the mockup's editor
|
|
// shows only the giant time + weekday circles, but the
|
|
// one-time date field, the fallback-station picker, and the
|
|
// sound dropdown are NOT dropped — they move here, one tap
|
|
// away, instead of being always inline.
|
|
ExpansionTile(
|
|
key: const ValueKey('alarm-advanced-section'),
|
|
tilePadding: EdgeInsets.zero,
|
|
title: Text(
|
|
l10n.alarmAdvancedSectionTitle,
|
|
style: context.pluriType.cardTitle,
|
|
),
|
|
children: [
|
|
_PickerButton(
|
|
icon: Icons.event_rounded,
|
|
label: l10n.dateField,
|
|
value: _fechaCorta(l10n, _fecha),
|
|
onTap:
|
|
_tipo == TipoProgramacionAlarma.unica
|
|
? _elegirFecha
|
|
: null,
|
|
),
|
|
const SizedBox(height: 8),
|
|
_CampoSelectorEmisora(
|
|
key: const ValueKey('alarm-fallback-station-field'),
|
|
label: l10n.alarmFallbackStationLabel,
|
|
icon: Icons.settings_backup_restore_rounded,
|
|
value:
|
|
_emisoraFallback == null
|
|
? l10n.noStationUseInternalSound
|
|
: localizedStationName(
|
|
l10n,
|
|
_emisoraFallback!.nombre,
|
|
),
|
|
onTap:
|
|
() => _elegirEmisora(
|
|
favoritas,
|
|
seleccionar:
|
|
(emisora) =>
|
|
setState(() => _emisoraFallback = emisora),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
DropdownButtonFormField<SonidoInternoAlarma>(
|
|
initialValue: _sonidoInterno,
|
|
decoration: InputDecoration(
|
|
labelText: l10n.internalSafeSoundLabel,
|
|
),
|
|
items: [
|
|
DropdownMenuItem(
|
|
value: SonidoInternoAlarma.amanecer,
|
|
child: Text(l10n.soundWarmSunrise),
|
|
),
|
|
DropdownMenuItem(
|
|
value: SonidoInternoAlarma.campanaSuave,
|
|
child: Text(l10n.soundSoftBell),
|
|
),
|
|
DropdownMenuItem(
|
|
value: SonidoInternoAlarma.pulsoDigital,
|
|
child: Text(l10n.soundDigitalPulse),
|
|
),
|
|
],
|
|
onChanged:
|
|
(value) => setState(
|
|
() => _sonidoInterno = value ?? _sonidoInterno,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
FilledButton.icon(
|
|
onPressed: _guardar,
|
|
icon: const Icon(Icons.check_rounded),
|
|
label: Text(l10n.saveAlarmAction),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
List<int> _opcionesSnooze() {
|
|
final opciones = <int>{3, 5, 10};
|
|
if (_snoozeMinutos > 0) opciones.add(_snoozeMinutos);
|
|
return opciones.toList()..sort();
|
|
}
|
|
|
|
/// Read-only next-trigger preview (S2-R8): computed from the in-progress
|
|
/// draft so the user can verify when the alarm will fire before saving.
|
|
/// Recomputed on every setState, so it tracks time/recurrence edits live.
|
|
Widget _vistaProximaEjecucion(AppLocalizations l10n) {
|
|
final estado = context.read<EstadoAlarmas>();
|
|
final borrador = AlarmaMusical(
|
|
id: widget.alarma?.id ?? '_borrador_editor',
|
|
nombre: 'preview',
|
|
hora: _hora.hour,
|
|
minuto: _hora.minute,
|
|
tipoProgramacion: _tipo,
|
|
diasSemana:
|
|
_tipo == TipoProgramacionAlarma.diasSemana
|
|
? (_diasSemana.toList()..sort())
|
|
: const [],
|
|
fechaUnica: _tipo == TipoProgramacionAlarma.unica ? _fecha : null,
|
|
sonarEnVacaciones: _sonarEnVacaciones,
|
|
);
|
|
final proxima = _programacion.calcularProxima(
|
|
alarma: borrador,
|
|
desde: DateTime.now(),
|
|
vacaciones: estado.vacaciones,
|
|
excepciones: estado.excepciones,
|
|
);
|
|
return _NoticeLine(
|
|
key: const ValueKey('next-trigger-preview'),
|
|
icon: Icons.event_available_rounded,
|
|
text:
|
|
proxima == null
|
|
? l10n.alarmNoNextExecution
|
|
: l10n.alarmNextExecution(_fechaHora(l10n, proxima)),
|
|
);
|
|
}
|
|
|
|
Future<void> _elegirEmisora(
|
|
List<Emisora> emisoras, {
|
|
required ValueChanged<Emisora?> seleccionar,
|
|
}) async {
|
|
final resultado = await showModalBottomSheet<_SeleccionEmisora>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
useSafeArea: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (_) => _SelectorEmisoraSheet(emisoras: emisoras),
|
|
);
|
|
if (resultado == null) return;
|
|
seleccionar(resultado.emisora);
|
|
}
|
|
|
|
Future<void> _elegirFecha() async {
|
|
final ahora = DateTime.now();
|
|
final nueva = await showDatePicker(
|
|
context: context,
|
|
initialDate: _fecha.isBefore(ahora) ? ahora : _fecha,
|
|
firstDate: DateTime(ahora.year, ahora.month, ahora.day),
|
|
lastDate: ahora.add(const Duration(days: 730)),
|
|
);
|
|
if (nueva != null) setState(() => _fecha = nueva);
|
|
}
|
|
|
|
Future<void> _guardar() async {
|
|
if (_tipo == TipoProgramacionAlarma.diasSemana && _diasSemana.isEmpty) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(AppLocalizations.of(context).chooseOneWeekdayError),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
final estado = context.read<EstadoAlarmas>();
|
|
final existente = widget.alarma;
|
|
final nombre = _nombreController?.text.trim() ?? '';
|
|
final alarma = (existente ??
|
|
estado.servicio.crearAlarma(
|
|
nombre: nombre,
|
|
hora: _hora.hour,
|
|
minuto: _hora.minute,
|
|
tipoProgramacion: _tipo,
|
|
diasSemana: _diasSemana.toList()..sort(),
|
|
))
|
|
.copyWith(
|
|
nombre:
|
|
nombre.isEmpty
|
|
? AppLocalizations.of(context).defaultAlarmName
|
|
: nombre,
|
|
hora: _hora.hour,
|
|
minuto: _hora.minute,
|
|
tipoProgramacion: _tipo,
|
|
diasSemana:
|
|
_tipo == TipoProgramacionAlarma.diasSemana
|
|
? (_diasSemana.toList()..sort())
|
|
: const [],
|
|
fechaUnica: _tipo == TipoProgramacionAlarma.unica ? _fecha : null,
|
|
limpiarFechaUnica: _tipo != TipoProgramacionAlarma.unica,
|
|
emisora: _emisora,
|
|
limpiarEmisora: _emisora == null,
|
|
emisoraFallback: _emisoraFallback,
|
|
limpiarEmisoraFallback: _emisoraFallback == null,
|
|
sonarEnVacaciones: _sonarEnVacaciones,
|
|
snoozeMinutos: _snoozeMinutos,
|
|
volumen: _volumen,
|
|
fadeInSegundos: _fadeInSegundos.clamp(0, 60).toInt(),
|
|
sonidoInterno: _sonidoInterno,
|
|
activa: true,
|
|
);
|
|
await estado.guardarAlarma(alarma);
|
|
if (mounted) Navigator.pop(context);
|
|
}
|
|
|
|
List<Emisora> _favoritasConSeleccion(List<Emisora> favoritas) {
|
|
final mapa = <String, Emisora>{};
|
|
for (final emisora in favoritas) {
|
|
mapa[emisora.uuid] = emisora;
|
|
}
|
|
final seleccionada = _emisora;
|
|
if (seleccionada != null) {
|
|
mapa[seleccionada.uuid] = seleccionada;
|
|
}
|
|
final respaldo = _emisoraFallback;
|
|
if (respaldo != null) {
|
|
mapa[respaldo.uuid] = respaldo;
|
|
}
|
|
return mapa.values.toList();
|
|
}
|
|
}
|
|
|
|
/// Result wrapper so the picker can distinguish "cancelled" (null result)
|
|
/// from "no station chosen" (emisora == null).
|
|
class _SeleccionEmisora {
|
|
const _SeleccionEmisora(this.emisora);
|
|
|
|
final Emisora? emisora;
|
|
}
|
|
|
|
class _CampoSelectorEmisora extends StatelessWidget {
|
|
const _CampoSelectorEmisora({
|
|
super.key,
|
|
required this.label,
|
|
required this.icon,
|
|
required this.value,
|
|
required this.onTap,
|
|
});
|
|
|
|
final String label;
|
|
final IconData icon;
|
|
final String value;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return InkWell(
|
|
borderRadius: BorderRadius.circular(12),
|
|
onTap: onTap,
|
|
child: InputDecorator(
|
|
decoration: InputDecoration(
|
|
labelText: label,
|
|
prefixIcon: Icon(icon),
|
|
suffixIcon: const Icon(Icons.arrow_drop_down_rounded),
|
|
),
|
|
child: Text(value, overflow: TextOverflow.ellipsis),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Searchable station picker (S2-R9): bottom sheet with a [SearchBar] over
|
|
/// the user's favorites, matching the main station-picker interaction.
|
|
class _SelectorEmisoraSheet extends StatefulWidget {
|
|
const _SelectorEmisoraSheet({required this.emisoras});
|
|
|
|
final List<Emisora> emisoras;
|
|
|
|
@override
|
|
State<_SelectorEmisoraSheet> createState() => _SelectorEmisoraSheetState();
|
|
}
|
|
|
|
class _SelectorEmisoraSheetState extends State<_SelectorEmisoraSheet> {
|
|
String _filtro = '';
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final bottom = MediaQuery.of(context).viewInsets.bottom;
|
|
final query = _filtro.trim().toLowerCase();
|
|
final filtradas =
|
|
widget.emisoras.where((emisora) {
|
|
if (query.isEmpty) return true;
|
|
return localizedStationName(
|
|
l10n,
|
|
emisora.nombre,
|
|
).toLowerCase().contains(query) ||
|
|
emisora.nombre.toLowerCase().contains(query);
|
|
}).toList();
|
|
return Padding(
|
|
padding: EdgeInsets.fromLTRB(12, 12, 12, bottom + 12),
|
|
child: PluriGlassSurface(
|
|
borderRadius: BorderRadius.circular(28),
|
|
padding: const EdgeInsets.all(18),
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
maxHeight: MediaQuery.of(context).size.height * 0.7,
|
|
),
|
|
child: Material(
|
|
type: MaterialType.transparency,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
SearchBar(
|
|
hintText: l10n.alarmStationPickerSearchHint,
|
|
leading: const Icon(Icons.search_rounded),
|
|
onChanged: (value) => setState(() => _filtro = value),
|
|
),
|
|
const SizedBox(height: 10),
|
|
Flexible(
|
|
child: ListView(
|
|
shrinkWrap: true,
|
|
children: [
|
|
ListTile(
|
|
leading: const Icon(Icons.music_off_rounded),
|
|
title: Text(l10n.noStationUseInternalSound),
|
|
onTap:
|
|
() => Navigator.pop(
|
|
context,
|
|
const _SeleccionEmisora(null),
|
|
),
|
|
),
|
|
for (final emisora in filtradas)
|
|
ListTile(
|
|
leading: const Icon(Icons.radio_rounded),
|
|
title: Text(
|
|
localizedStationName(l10n, emisora.nombre),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
onTap:
|
|
() => Navigator.pop(
|
|
context,
|
|
_SeleccionEmisora(emisora),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AccesoDiagnostico extends StatelessWidget {
|
|
const _AccesoDiagnostico({required this.estado});
|
|
|
|
final EstadoAlarmas estado;
|
|
|
|
@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',
|
|
size: 28,
|
|
),
|
|
label: Text(
|
|
diag == null
|
|
? l10n.androidReliabilityTitle
|
|
: l10n.androidReliabilityStatus(
|
|
exactStatus,
|
|
notificationStatus,
|
|
screenStatus,
|
|
),
|
|
),
|
|
onPressed: () async {
|
|
if (diag != null && !diag.puedeProgramarExactas) {
|
|
await estado.android.solicitarPermisoAlarmasExactas();
|
|
}
|
|
if (diag != null && !diag.notificacionesPermitidas) {
|
|
await estado.android.solicitarPermisoNotificaciones();
|
|
}
|
|
if (diag != null && !diag.puedeUsarPantallaCompleta) {
|
|
await estado.android.solicitarPermisoPantallaCompleta();
|
|
}
|
|
await estado.cargarDiagnostico();
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Vacation summary row (alarm-vacation-ranges delta, WU8): replaces the old
|
|
/// always-inline range list with a tap target showing range count + next-
|
|
/// range countdown, pushing the Vacaciones manager screen. WU9 owns the
|
|
/// destination screen's real content (`EstadoAlarmas` query additions per
|
|
/// design ADR-6); this row's tap target is a temporary placeholder until then
|
|
/// (see `_PantallaVacacionesTemporal` below).
|
|
class _PanelVacaciones extends StatelessWidget {
|
|
const _PanelVacaciones({required this.estado});
|
|
|
|
final EstadoAlarmas estado;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final tokens = context.pluriTokens;
|
|
final resumen = _resumenVacaciones(l10n, estado.vacaciones);
|
|
return PluriGlassSurface(
|
|
glowColor: PluriWaveTokens.skyBlue.withValues(alpha: 0.22),
|
|
padding: EdgeInsets.zero,
|
|
child: Material(
|
|
type: MaterialType.transparency,
|
|
child: InkWell(
|
|
key: const ValueKey('vacaciones-resumen'),
|
|
borderRadius: BorderRadius.circular(tokens.radiusMd),
|
|
onTap: () => _abrirVacaciones(context),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
children: [
|
|
_AssetIcon(
|
|
'assets/icons/alarmas/vacation_wave.png',
|
|
size: 48,
|
|
semanticLabel: l10n.vacationIconLabel,
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.vacationRangesTitle,
|
|
style: Theme.of(context).textTheme.titleMedium
|
|
?.copyWith(fontWeight: FontWeight.w900),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(resumen),
|
|
],
|
|
),
|
|
),
|
|
const Icon(Icons.chevron_right_rounded),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _abrirVacaciones(BuildContext context) {
|
|
PluriPushScaffold.push(context, (_) => const PantallaVacaciones());
|
|
}
|
|
|
|
/// Range count + next-range countdown, computed directly over the existing
|
|
/// `estado.vacaciones` (no new `EstadoAlarmas` query method — those are
|
|
/// design ADR-6's pure additions, owned by WU9).
|
|
String _resumenVacaciones(
|
|
AppLocalizations l10n,
|
|
List<RangoVacaciones> vacaciones,
|
|
) {
|
|
if (vacaciones.isEmpty) return l10n.noVacationRangesLoaded;
|
|
final ahora = DateTime.now();
|
|
final hoy = DateTime(ahora.year, ahora.month, ahora.day);
|
|
final conteo = l10n.vacationRangesCount(vacaciones.length);
|
|
RangoVacaciones? activo;
|
|
for (final rango in vacaciones) {
|
|
if (rango.contiene(ahora)) {
|
|
activo = rango;
|
|
break;
|
|
}
|
|
}
|
|
if (activo != null) {
|
|
final dias = activo.finDia.difference(hoy).inDays;
|
|
return '$conteo · ${l10n.vacationSummaryActiveCountdown(dias)}';
|
|
}
|
|
final futuros =
|
|
vacaciones.where((rango) => rango.inicioDia.isAfter(hoy)).toList()
|
|
..sort((a, b) => a.inicioDia.compareTo(b.inicioDia));
|
|
if (futuros.isNotEmpty) {
|
|
final dias = futuros.first.inicioDia.difference(hoy).inDays;
|
|
return '$conteo · ${l10n.vacationSummaryUpcomingCountdown(dias)}';
|
|
}
|
|
return conteo;
|
|
}
|
|
}
|
|
|
|
class _AssetIcon extends StatelessWidget {
|
|
const _AssetIcon(this.asset, {this.size = 44, this.semanticLabel});
|
|
|
|
final String asset;
|
|
final double size;
|
|
|
|
/// S5-R2: meaningful images carry a label; without one the image is
|
|
/// treated as decorative and excluded from the semantics tree.
|
|
final String? semanticLabel;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Image.asset(
|
|
asset,
|
|
width: size,
|
|
height: size,
|
|
fit: BoxFit.contain,
|
|
semanticLabel: semanticLabel,
|
|
excludeFromSemantics: semanticLabel == null,
|
|
errorBuilder:
|
|
(_, __, ___) => Icon(Icons.music_note_rounded, size: size * 0.65),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PickerButton extends StatelessWidget {
|
|
const _PickerButton({
|
|
required this.icon,
|
|
required this.label,
|
|
required this.value,
|
|
required this.onTap,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String label;
|
|
final String value;
|
|
final VoidCallback? onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return OutlinedButton.icon(
|
|
onPressed: onTap,
|
|
icon: Icon(icon),
|
|
label: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(label, style: Theme.of(context).textTheme.labelSmall),
|
|
Text(value),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SectionLabel extends StatelessWidget {
|
|
const _SectionLabel({required this.icon, required this.text});
|
|
|
|
final String icon;
|
|
final String text;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(
|
|
children: [
|
|
_AssetIcon(icon, size: 34),
|
|
const SizedBox(width: 8),
|
|
// WU10: swapped the raw TextTheme lookup for the named type-scale
|
|
// token (cosmetic only — same weight class, now shared with every
|
|
// other card/section title in the redesign).
|
|
Text(text, style: context.pluriType.cardTitle),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _NoticeLine extends StatelessWidget {
|
|
const _NoticeLine({super.key, required this.icon, required this.text});
|
|
|
|
final IconData icon;
|
|
final String text;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(icon, size: 18),
|
|
const SizedBox(width: 8),
|
|
Expanded(child: Text(text)),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _EmptyAlarmas extends StatelessWidget {
|
|
const _EmptyAlarmas();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
return PluriGlassSurface(
|
|
child: Column(
|
|
children: [
|
|
_AssetIcon(
|
|
'assets/icons/alarmas/alarm_music.png',
|
|
size: 92,
|
|
semanticLabel: l10n.alarmIconLabel,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(l10n.noAlarmsYetTitle),
|
|
const SizedBox(height: 4),
|
|
Text(l10n.noAlarmsYetSubtitle),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
String _nombreVisibleAlarma(AppLocalizations l10n, AlarmaMusical alarma) {
|
|
return localizedAlarmName(l10n, alarma.nombre);
|
|
}
|
|
|
|
String _hora(AlarmaMusical alarma) =>
|
|
'${alarma.hora.toString().padLeft(2, '0')}:${alarma.minuto.toString().padLeft(2, '0')}';
|
|
|
|
String _fechaHora(AppLocalizations l10n, DateTime fecha) =>
|
|
l10n.dateTimeSentence(fecha);
|
|
|
|
String _weekdayShort(AppLocalizations l10n, int day) => switch (day) {
|
|
DateTime.monday => l10n.weekdayShortMonday,
|
|
DateTime.tuesday => l10n.weekdayShortTuesday,
|
|
DateTime.wednesday => l10n.weekdayShortWednesday,
|
|
DateTime.thursday => l10n.weekdayShortThursday,
|
|
DateTime.friday => l10n.weekdayShortFriday,
|
|
DateTime.saturday => l10n.weekdayShortSaturday,
|
|
DateTime.sunday => l10n.weekdayShortSunday,
|
|
_ => '?',
|
|
};
|
|
|
|
// S5-R4: short dates follow the active locale (en-US = M/D/Y, ja = Y/M/D).
|
|
String _fechaCorta(AppLocalizations l10n, DateTime fecha) =>
|
|
fechaCortaLocalizada(l10n.localeName, fecha);
|