feat(alarms): native reliability fixes and end-to-end snooze
- Use mediaPlayback|systemExempted FGS type with FOREGROUND_SERVICE_SYSTEM_EXEMPTED so alarms fire on Android 14+ (FOREGROUND_SERVICE_ALARM does not exist in the SDK) - Deduplicate fire notifications: the foreground service FSI notification is the single owner; receiver path removed - Notification channel v2 with alarm sound URI and USAGE_ALARM attributes, one-time guarded migration from legacy channels - Pass fallback station through the MethodChannel (NativeAlarmSpec schemaVersion 3) with a three-stage audio chain: primary -> fallback station -> bundled WAV - Native fade-in volume ramp honoring fadeInSegundos when the app is killed - Request battery-optimization exemption once, tracked with a persisted asked-once flag - Fix snooze end-to-end: native ACTION_SNOOZE now reports back to Flutter (snoozed event + cold-start sync), snooze anchor unified to occurrence+minutes on both sides, periodic recalc no longer erases an active snooze - Add snooze buttons (3/5/10/custom) to the ringing screen with shared audio teardown - Redesign ringing screen on PluriWaveScaffold with reduced-motion-aware entry animation (new PluriAnimate helper) - Alarm editor: live next-trigger preview, searchable station pickers (primary and fallback), configurable snooze duration, volume floor down to 0 - New alarm strings localized across all 13 locales - New unit/widget tests for the snooze flow, alarm bridge payloads, ringing screen and editor (77 tests green) - SDD artifacts for the app-quality-and-native-alarms change (explore, proposal, spec, design, tasks, apply progress)
This commit is contained in:
+460
-222
@@ -8,6 +8,7 @@ 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 '../widgets/pluri_glass_surface.dart';
|
||||
import '../widgets/pluri_icon.dart';
|
||||
import '../widgets/pluri_layout.dart';
|
||||
@@ -339,30 +340,27 @@ class _EditorAlarmaSheet extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
late final TextEditingController _nombreController;
|
||||
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 l10n = AppLocalizations.of(context);
|
||||
final ahora = DateTime.now().add(const Duration(minutes: 5));
|
||||
_nombreController = TextEditingController(
|
||||
text:
|
||||
alarma == null
|
||||
? l10n.defaultAlarmName
|
||||
: _nombreVisibleAlarma(l10n, alarma),
|
||||
);
|
||||
_hora = TimeOfDay(
|
||||
hour: alarma?.hora ?? ahora.hour,
|
||||
minute: alarma?.minuto ?? ahora.minute,
|
||||
@@ -374,12 +372,31 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
_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();
|
||||
_nombreController?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -409,232 +426,319 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
child: PluriGlassSurface(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
padding: const EdgeInsets.all(18),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const _AssetIcon(
|
||||
'assets/icons/alarmas/alarm_music.png',
|
||||
size: 58,
|
||||
),
|
||||
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: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _PickerButton(
|
||||
icon: Icons.schedule_rounded,
|
||||
label: l10n.timeField,
|
||||
value: _hora.format(context),
|
||||
onTap: _elegirHora,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _PickerButton(
|
||||
icon: Icons.event_rounded,
|
||||
label: l10n.dateField,
|
||||
value: _fechaCorta(_fecha),
|
||||
onTap:
|
||||
_tipo == TipoProgramacionAlarma.unica
|
||||
? _elegirFecha
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
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),
|
||||
),
|
||||
if (_tipo == TipoProgramacionAlarma.diasSemana) ...[
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
for (var i = DateTime.monday; i <= DateTime.sunday; i++)
|
||||
FilterChip(
|
||||
label: Text(_weekdayShort(l10n, i)),
|
||||
selected: _diasSemana.contains(i),
|
||||
onSelected:
|
||||
(selected) => setState(() {
|
||||
selected
|
||||
? _diasSemana.add(i)
|
||||
: _diasSemana.remove(i);
|
||||
}),
|
||||
const _AssetIcon(
|
||||
'assets/icons/alarmas/alarm_music.png',
|
||||
size: 58,
|
||||
),
|
||||
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),
|
||||
_SectionLabel(
|
||||
icon: 'assets/icons/alarmas/fallback_sound.png',
|
||||
text: l10n.soundAndVolumeSection,
|
||||
),
|
||||
Slider(
|
||||
value: _volumen,
|
||||
min: 0.25,
|
||||
max: 1,
|
||||
divisions: 15,
|
||||
label: '${(_volumen * 100).round()}%',
|
||||
onChanged: (value) => setState(() => _volumen = value),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l10n.alarmFadeInTitle),
|
||||
subtitle: Text(
|
||||
_fadeInSegundos == 0
|
||||
? l10n.alarmFadeInOff
|
||||
: l10n.alarmFadeInSummary(_fadeInSegundos),
|
||||
const SizedBox(height: 14),
|
||||
TextField(
|
||||
controller: _nombreController,
|
||||
decoration: InputDecoration(labelText: l10n.nameLabel),
|
||||
),
|
||||
),
|
||||
Slider(
|
||||
value: _fadeInSegundos.toDouble(),
|
||||
min: 0,
|
||||
max: 60,
|
||||
divisions: 60,
|
||||
label: '${_fadeInSegundos}s',
|
||||
onChanged:
|
||||
(value) => setState(() => _fadeInSegundos = value.round()),
|
||||
),
|
||||
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),
|
||||
DropdownButtonFormField<String>(
|
||||
key: ValueKey(_emisora?.uuid ?? 'sin-emisora'),
|
||||
initialValue: _emisora?.uuid,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.favoriteStationLabel,
|
||||
prefixIcon: const Icon(Icons.radio_rounded),
|
||||
),
|
||||
items: [
|
||||
DropdownMenuItem<String>(
|
||||
value: '',
|
||||
child: Text(l10n.noStationUseInternalSound),
|
||||
),
|
||||
for (final emisora in favoritas)
|
||||
DropdownMenuItem<String>(
|
||||
value: emisora.uuid,
|
||||
child: Text(
|
||||
localizedStationName(l10n, emisora.nombre),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _PickerButton(
|
||||
icon: Icons.schedule_rounded,
|
||||
label: l10n.timeField,
|
||||
value: _hora.format(context),
|
||||
onTap: _elegirHora,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _PickerButton(
|
||||
icon: Icons.event_rounded,
|
||||
label: l10n.dateField,
|
||||
value: _fechaCorta(_fecha),
|
||||
onTap:
|
||||
_tipo == TipoProgramacionAlarma.unica
|
||||
? _elegirFecha
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
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),
|
||||
),
|
||||
if (_tipo == TipoProgramacionAlarma.diasSemana) ...[
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
children: [
|
||||
for (var i = DateTime.monday; i <= DateTime.sunday; i++)
|
||||
FilterChip(
|
||||
label: Text(_weekdayShort(l10n, i)),
|
||||
selected: _diasSemana.contains(i),
|
||||
onSelected:
|
||||
(selected) => setState(() {
|
||||
selected
|
||||
? _diasSemana.add(i)
|
||||
: _diasSemana.remove(i);
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
onChanged:
|
||||
(uuid) => setState(() {
|
||||
if (uuid == null || uuid.isEmpty) {
|
||||
_emisora = null;
|
||||
return;
|
||||
}
|
||||
_emisora = favoritas.firstWhere((e) => e.uuid == uuid);
|
||||
}),
|
||||
),
|
||||
if (favoritas.isEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(l10n.saveFavoritesAlarmHint),
|
||||
],
|
||||
if (radio.emisoraActual != 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),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FilledButton.tonalIcon(
|
||||
onPressed:
|
||||
() => setState(() => _emisora = radio.emisoraActual),
|
||||
icon: const Icon(Icons.add_task_rounded),
|
||||
label: Text(l10n.useCurrentStationAction),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l10n.alarmFadeInTitle),
|
||||
subtitle: Text(
|
||||
_fadeInSegundos == 0
|
||||
? l10n.alarmFadeInOff
|
||||
: l10n.alarmFadeInSummary(_fadeInSegundos),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile.adaptive(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: _sonarEnVacaciones,
|
||||
onChanged:
|
||||
(value) => setState(() => _sonarEnVacaciones = value),
|
||||
secondary: const _AssetIcon(
|
||||
'assets/icons/alarmas/vacation_wave.png',
|
||||
size: 42,
|
||||
Slider(
|
||||
value: _fadeInSegundos.toDouble(),
|
||||
min: 0,
|
||||
max: 60,
|
||||
divisions: 60,
|
||||
label: '${_fadeInSegundos}s',
|
||||
onChanged:
|
||||
(value) =>
|
||||
setState(() => _fadeInSegundos = value.round()),
|
||||
),
|
||||
title: Text(l10n.playDuringVacations),
|
||||
subtitle: Text(l10n.playDuringVacationsHint),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: _guardar,
|
||||
icon: const Icon(Icons.check_rounded),
|
||||
label: Text(l10n.saveAlarmAction),
|
||||
),
|
||||
],
|
||||
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),
|
||||
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),
|
||||
// S2-R9: searchable bottom-sheet picker instead of a dropdown,
|
||||
// for both the primary and the backup (fallback) station.
|
||||
_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),
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
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: const _AssetIcon(
|
||||
'assets/icons/alarmas/vacation_wave.png',
|
||||
size: 42,
|
||||
),
|
||||
title: Text(l10n.playDuringVacations),
|
||||
subtitle: Text(l10n.playDuringVacationsHint),
|
||||
),
|
||||
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> _elegirHora() async {
|
||||
final nueva = await showTimePicker(context: context, initialTime: _hora);
|
||||
if (nueva != null) setState(() => _hora = nueva);
|
||||
@@ -663,9 +767,10 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
|
||||
final estado = context.read<EstadoAlarmas>();
|
||||
final existente = widget.alarma;
|
||||
final nombre = _nombreController?.text.trim() ?? '';
|
||||
final alarma = (existente ??
|
||||
estado.servicio.crearAlarma(
|
||||
nombre: _nombreController.text.trim(),
|
||||
nombre: nombre,
|
||||
hora: _hora.hour,
|
||||
minuto: _hora.minute,
|
||||
tipoProgramacion: _tipo,
|
||||
@@ -673,9 +778,9 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
))
|
||||
.copyWith(
|
||||
nombre:
|
||||
_nombreController.text.trim().isEmpty
|
||||
nombre.isEmpty
|
||||
? AppLocalizations.of(context).defaultAlarmName
|
||||
: _nombreController.text.trim(),
|
||||
: nombre,
|
||||
hora: _hora.hour,
|
||||
minuto: _hora.minute,
|
||||
tipoProgramacion: _tipo,
|
||||
@@ -686,8 +791,11 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
fechaUnica: _tipo == TipoProgramacionAlarma.unica ? _fecha : null,
|
||||
limpiarFechaUnica: _tipo != TipoProgramacionAlarma.unica,
|
||||
emisora: _emisora,
|
||||
limpiarEmisora: _emisora == null,
|
||||
emisoraFallback: _emisoraFallback,
|
||||
limpiarEmisoraFallback: _emisoraFallback == null,
|
||||
sonarEnVacaciones: _sonarEnVacaciones,
|
||||
snoozeMinutos: existente?.snoozeMinutos ?? 5,
|
||||
snoozeMinutos: _snoozeMinutos,
|
||||
volumen: _volumen,
|
||||
fadeInSegundos: _fadeInSegundos.clamp(0, 60).toInt(),
|
||||
sonidoInterno: _sonidoInterno,
|
||||
@@ -706,10 +814,140 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
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});
|
||||
|
||||
@@ -1036,7 +1274,7 @@ class _InfoChip extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _NoticeLine extends StatelessWidget {
|
||||
const _NoticeLine({required this.icon, required this.text});
|
||||
const _NoticeLine({super.key, required this.icon, required this.text});
|
||||
|
||||
final IconData icon;
|
||||
final String text;
|
||||
|
||||
Reference in New Issue
Block a user