1080 lines
35 KiB
Dart
1080 lines
35 KiB
Dart
import 'package:flutter/material.dart';
|
|
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';
|
|
import '../widgets/pluri_icon.dart';
|
|
import '../widgets/pluri_layout.dart';
|
|
import '../widgets/pluri_premium_widgets.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: [
|
|
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: const Color(0xFFFFB86B).withValues(alpha: 0.28),
|
|
child: Row(
|
|
children: [
|
|
const _AssetIcon('assets/icons/alarmas/alarm_music.png', size: 72),
|
|
const SizedBox(width: 14),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
proxima == null
|
|
? activasSinProxima > 0
|
|
? l10n.activeAlarmsWithoutNextTitle
|
|
: l10n.activeAlarmsNoneTitle
|
|
: 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
|
|
: l10n.alarmNextSummary(
|
|
proxima.nombre,
|
|
_fechaHora(l10n, proximaProgramable!),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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 excepcion = estado.ultimaExcepcionPara(alarma.id);
|
|
final mensajeVacaciones = _mensajeVacaciones(l10n, estado.vacaciones);
|
|
return PluriGlassSurface(
|
|
glowColor: const Color(0xFF22D3EE).withValues(alpha: 0.22),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const _AssetIcon(
|
|
'assets/icons/alarmas/alarm_music.png',
|
|
size: 64,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
_hora(alarma),
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.headlineMedium?.copyWith(
|
|
fontWeight: FontWeight.w900,
|
|
letterSpacing: -1,
|
|
),
|
|
),
|
|
Text(alarma.nombre),
|
|
],
|
|
),
|
|
),
|
|
Switch.adaptive(
|
|
value: alarma.activa,
|
|
onChanged: (value) => estado.cambiarActiva(alarma, value),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
_InfoChip(
|
|
icon: Icons.repeat_rounded,
|
|
label: _programacion(l10n, alarma),
|
|
),
|
|
_InfoChip(
|
|
icon: Icons.beach_access_rounded,
|
|
label:
|
|
alarma.sonarEnVacaciones
|
|
? l10n.alarmVacationPlay
|
|
: l10n.alarmVacationPause,
|
|
),
|
|
_InfoChip(
|
|
icon: Icons.volume_up_rounded,
|
|
label: '${(alarma.volumen * 100).round()}%',
|
|
),
|
|
_InfoChip(
|
|
icon: Icons.trending_up_rounded,
|
|
label: l10n.fadeInSeconds(alarma.fadeInSegundos),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
if (alarma.proximaProgramable != null)
|
|
_NoticeLine(
|
|
icon: Icons.event_available_rounded,
|
|
text: l10n.alarmNextExecution(
|
|
_fechaHora(l10n, alarma.proximaProgramable!),
|
|
),
|
|
)
|
|
else
|
|
_NoticeLine(
|
|
icon: Icons.pause_circle_outline_rounded,
|
|
text: l10n.alarmNoNextExecution,
|
|
),
|
|
if (excepcion != null) ...[
|
|
const SizedBox(height: 6),
|
|
_NoticeLine(
|
|
icon: Icons.skip_next_rounded,
|
|
text: l10n.alarmSkippedExecution(
|
|
_fechaHora(l10n, excepcion.ejecucion),
|
|
),
|
|
),
|
|
],
|
|
if (mensajeVacaciones != null) ...[
|
|
const SizedBox(height: 6),
|
|
_NoticeLine(
|
|
icon: Icons.beach_access_rounded,
|
|
text: mensajeVacaciones,
|
|
),
|
|
],
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
TextButton.icon(
|
|
icon: const Icon(Icons.edit_rounded),
|
|
label: Text(l10n.editAction),
|
|
onPressed: () => _abrirEditor(context, alarma: alarma),
|
|
),
|
|
TextButton.icon(
|
|
icon: const Icon(Icons.skip_next_rounded),
|
|
label: Text(l10n.skipNextAction),
|
|
onPressed:
|
|
alarma.proximaProgramable == null
|
|
? null
|
|
: () async {
|
|
await estado.saltarProxima(alarma.id);
|
|
if (context.mounted) {
|
|
final alarmas =
|
|
context.read<EstadoAlarmas>().alarmas;
|
|
AlarmaMusical? actualizada;
|
|
for (final item in alarmas) {
|
|
if (item.id == alarma.id) {
|
|
actualizada = item;
|
|
break;
|
|
}
|
|
}
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
actualizada?.proximaProgramable == null
|
|
? l10n.alarmSkippedNoNextSnackbar
|
|
: l10n.alarmSkippedReturnsSnackbar(
|
|
_fechaHora(
|
|
l10n,
|
|
actualizada!.proximaProgramable!,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
},
|
|
),
|
|
const Spacer(),
|
|
IconButton(
|
|
tooltip: l10n.deleteAction,
|
|
icon: const Icon(Icons.delete_outline_rounded),
|
|
onPressed: () => estado.eliminarAlarma(alarma.id),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String? _mensajeVacaciones(
|
|
AppLocalizations l10n,
|
|
List<RangoVacaciones> vacaciones,
|
|
) {
|
|
if (alarma.sonarEnVacaciones) return null;
|
|
final ahora = DateTime.now();
|
|
RangoVacaciones? actual;
|
|
for (final rango in vacaciones) {
|
|
if (rango.contiene(ahora)) {
|
|
actual = rango;
|
|
break;
|
|
}
|
|
}
|
|
if (actual != null) {
|
|
if (alarma.proximaProgramable == null) {
|
|
return l10n.alarmVacationPausedNoNext(actual.nombre);
|
|
}
|
|
return l10n.alarmVacationPausedReturns(
|
|
actual.nombre,
|
|
_fechaHora(l10n, alarma.proximaProgramable!),
|
|
);
|
|
}
|
|
if (alarma.proximaProgramable != null) {
|
|
return l10n.alarmVacationReturns(
|
|
_fechaHora(l10n, alarma.proximaProgramable!),
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
void _abrirEditor(BuildContext context, {required AlarmaMusical alarma}) {
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
useSafeArea: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (_) => _EditorAlarmaSheet(alarma: alarma),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _EditorAlarmaSheet extends StatefulWidget {
|
|
const _EditorAlarmaSheet({this.alarma});
|
|
|
|
final AlarmaMusical? alarma;
|
|
|
|
@override
|
|
State<_EditorAlarmaSheet> createState() => _EditorAlarmaSheetState();
|
|
}
|
|
|
|
class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
|
late final TextEditingController _nombreController;
|
|
late TimeOfDay _hora;
|
|
late DateTime _fecha;
|
|
late TipoProgramacionAlarma _tipo;
|
|
late Set<int> _diasSemana;
|
|
late double _volumen;
|
|
late int _fadeInSegundos;
|
|
late bool _sonarEnVacaciones;
|
|
late SonidoInternoAlarma _sonidoInterno;
|
|
Emisora? _emisora;
|
|
bool _favoritosSolicitados = false;
|
|
|
|
@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?.nombre ?? l10n.defaultAlarmName,
|
|
);
|
|
_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)) as int;
|
|
_sonarEnVacaciones = alarma?.sonarEnVacaciones ?? true;
|
|
_sonidoInterno = alarma?.sonidoInterno ?? SonidoInternoAlarma.amanecer;
|
|
_emisora = alarma?.emisora ?? context.read<EstadoRadio>().emisoraPreferida;
|
|
}
|
|
|
|
@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: 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.timeLabel,
|
|
value: _hora.format(context),
|
|
onTap: _elegirHora,
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: _PickerButton(
|
|
icon: Icons.event_rounded,
|
|
label: l10n.dateLabel,
|
|
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(l10n.weekdayShort(i)),
|
|
selected: _diasSemana.contains(i),
|
|
onSelected:
|
|
(selected) => setState(() {
|
|
selected
|
|
? _diasSemana.add(i)
|
|
: _diasSemana.remove(i);
|
|
}),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
const SizedBox(height: 14),
|
|
_SectionLabel(
|
|
icon: 'assets/icons/alarmas/fallback_sound.png',
|
|
text: l10n.soundAndVolumeTitle,
|
|
),
|
|
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.alarmFadeInProgress(_fadeInSegundos),
|
|
),
|
|
),
|
|
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.soundInternalSafe,
|
|
),
|
|
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(
|
|
emisora.nombre,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
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: 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),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _elegirHora() async {
|
|
final nueva = await showTimePicker(context: context, initialTime: _hora);
|
|
if (nueva != null) setState(() => _hora = nueva);
|
|
}
|
|
|
|
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 alarma = (existente ??
|
|
estado.servicio.crearAlarma(
|
|
nombre: _nombreController.text.trim(),
|
|
hora: _hora.hour,
|
|
minuto: _hora.minute,
|
|
tipoProgramacion: _tipo,
|
|
diasSemana: _diasSemana.toList()..sort(),
|
|
))
|
|
.copyWith(
|
|
nombre:
|
|
_nombreController.text.trim().isEmpty
|
|
? AppLocalizations.of(context).defaultAlarmName
|
|
: _nombreController.text.trim(),
|
|
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,
|
|
sonarEnVacaciones: _sonarEnVacaciones,
|
|
snoozeMinutos: existente?.snoozeMinutos ?? 5,
|
|
volumen: _volumen,
|
|
fadeInSegundos: _fadeInSegundos.clamp(0, 60),
|
|
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;
|
|
}
|
|
return mapa.values.toList();
|
|
}
|
|
}
|
|
|
|
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();
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PanelVacaciones extends StatelessWidget {
|
|
const _PanelVacaciones({required this.estado});
|
|
|
|
final EstadoAlarmas estado;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final vacaciones = [...estado.vacaciones]
|
|
..sort((a, b) => a.inicioDia.compareTo(b.inicioDia));
|
|
return PluriGlassSurface(
|
|
glowColor: const Color(0xFF60A5FA).withValues(alpha: 0.22),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const _AssetIcon(
|
|
'assets/icons/alarmas/vacation_wave.png',
|
|
size: 48,
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text(
|
|
l10n.vacationRangesTitle,
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w900,
|
|
),
|
|
),
|
|
),
|
|
FilledButton.tonalIcon(
|
|
onPressed: () => _abrirAlta(context),
|
|
icon: const Icon(Icons.add_rounded),
|
|
label: Text(l10n.addAction),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(l10n.vacationRangesHint),
|
|
if (vacaciones.isEmpty)
|
|
Text(l10n.vacationRangesEmpty)
|
|
else
|
|
for (final rango in vacaciones)
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const Icon(Icons.event_busy_rounded),
|
|
title: Text(rango.nombre),
|
|
subtitle: Text(
|
|
'${_fechaCorta(rango.inicioDia)} → ${_fechaCorta(rango.finDia)}',
|
|
),
|
|
trailing: IconButton(
|
|
tooltip: l10n.deleteRangeAction,
|
|
onPressed: () => estado.eliminarRangoVacaciones(rango.id),
|
|
icon: const Icon(Icons.delete_outline_rounded),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _abrirAlta(BuildContext context) async {
|
|
await showModalBottomSheet<void>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
useSafeArea: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (_) => const _EditorVacacionesSheet(),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _EditorVacacionesSheet extends StatefulWidget {
|
|
const _EditorVacacionesSheet();
|
|
|
|
@override
|
|
State<_EditorVacacionesSheet> createState() => _EditorVacacionesSheetState();
|
|
}
|
|
|
|
class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
|
|
late final TextEditingController _nombreController;
|
|
late DateTime _inicio;
|
|
late DateTime _fin;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final hoy = DateTime.now();
|
|
_inicio = DateTime(hoy.year, hoy.month, hoy.day);
|
|
_fin = _inicio.add(const Duration(days: 2));
|
|
_nombreController = TextEditingController(
|
|
text: AppLocalizations.of(context).vacationsDefaultName,
|
|
);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_nombreController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@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),
|
|
child: PluriGlassSurface(
|
|
borderRadius: BorderRadius.circular(28),
|
|
padding: const EdgeInsets.all(18),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.newVacationRangeTitle,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900),
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: _nombreController,
|
|
decoration: InputDecoration(labelText: l10n.nameLabel),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _PickerButton(
|
|
icon: Icons.play_arrow_rounded,
|
|
label: l10n.startLabel,
|
|
value: _fechaCorta(_inicio),
|
|
onTap: () => _elegirFecha(esInicio: true),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: _PickerButton(
|
|
icon: Icons.stop_rounded,
|
|
label: l10n.endLabel,
|
|
value: _fechaCorta(_fin),
|
|
onTap: () => _elegirFecha(esInicio: false),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
FilledButton.icon(
|
|
onPressed: _guardar,
|
|
icon: const Icon(Icons.check_rounded),
|
|
label: Text(l10n.saveRangeAction),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _elegirFecha({required bool esInicio}) async {
|
|
final actual = esInicio ? _inicio : _fin;
|
|
final hoy = DateTime.now();
|
|
final seleccion = await showDatePicker(
|
|
context: context,
|
|
initialDate: actual,
|
|
firstDate: DateTime(hoy.year, hoy.month, hoy.day),
|
|
lastDate: hoy.add(const Duration(days: 1460)),
|
|
);
|
|
if (seleccion == null) return;
|
|
setState(() {
|
|
if (esInicio) {
|
|
_inicio = seleccion;
|
|
if (_fin.isBefore(_inicio)) _fin = _inicio;
|
|
} else {
|
|
_fin = seleccion;
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _guardar() async {
|
|
final estado = context.read<EstadoAlarmas>();
|
|
final rango = estado.servicio.crearRangoVacaciones(
|
|
inicio: _inicio,
|
|
fin: _fin,
|
|
nombre: _nombreController.text.trim(),
|
|
);
|
|
await estado.crearRangoVacaciones(rango);
|
|
if (mounted) Navigator.pop(context);
|
|
}
|
|
}
|
|
|
|
class _AssetIcon extends StatelessWidget {
|
|
const _AssetIcon(this.asset, {this.size = 44});
|
|
|
|
final String asset;
|
|
final double size;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Image.asset(
|
|
asset,
|
|
width: size,
|
|
height: size,
|
|
fit: BoxFit.contain,
|
|
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),
|
|
Text(
|
|
text,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w800),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _InfoChip extends StatelessWidget {
|
|
const _InfoChip({required this.icon, required this.label});
|
|
|
|
final IconData icon;
|
|
final String label;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Chip(avatar: Icon(icon, size: 16), label: Text(label));
|
|
}
|
|
}
|
|
|
|
class _NoticeLine extends StatelessWidget {
|
|
const _NoticeLine({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: [
|
|
const _AssetIcon('assets/icons/alarmas/alarm_music.png', size: 92),
|
|
const SizedBox(height: 12),
|
|
Text(l10n.noAlarmsYetTitle),
|
|
const SizedBox(height: 4),
|
|
Text(l10n.noAlarmsYetSubtitle),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
String _hora(AlarmaMusical alarma) =>
|
|
'${alarma.hora.toString().padLeft(2, '0')}:${alarma.minuto.toString().padLeft(2, '0')}';
|
|
|
|
String _programacion(AppLocalizations l10n, AlarmaMusical alarma) {
|
|
return switch (alarma.tipoProgramacion) {
|
|
TipoProgramacionAlarma.unica =>
|
|
l10n.alarmScheduleOnce(_fechaCorta(alarma.fechaUnica ?? DateTime.now())),
|
|
TipoProgramacionAlarma.diaria => l10n.dailyOption,
|
|
TipoProgramacionAlarma.diasSemana =>
|
|
l10n.alarmScheduleWeekdays(
|
|
alarma.diasSemana.map(l10n.weekdayShort).join(', '),
|
|
),
|
|
};
|
|
}
|
|
|
|
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}';
|