Files
pluriwave/lib/pantallas/pantalla_alarmas.dart
T
Javier Bautista Fernández de07316d79
Build & Deploy PluriWave / Análisis de código (push) Successful in 37s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m29s
feat(alarmas): agregar fade-in configurable en activacion
2026-06-01 13:20:06 +02:00

1109 lines
36 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../estado/estado_alarmas.dart';
import '../estado/estado_radio.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>();
return RefreshIndicator(
onRefresh: estado.refrescarProgramacion,
child: ListView(
padding: PluriLayout.pageListPadding,
children: [
PluriScreenHeader(
title: 'Despertar musical',
subtitle:
'Alarmas con radio, sonido seguro, vacaciones inteligentes y próxima ejecución siempre visible.',
glyph: PluriIconGlyph.alarm,
primaryActionLabel: 'Crear alarma',
onPrimaryAction: () => _abrirEditor(context),
trailing: PluriStatusPill(
icon: Icons.alarm_on_rounded,
label: '${estado.alarmas.length} alarmas',
),
),
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 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
? 'Alarmas activas sin próxima ejecución'
: 'Sin alarmas activas'
: 'Próxima alarma',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 4),
Text(
proxima == null
? activasSinProxima > 0
? 'Hay $activasSinProxima alarma(s) activas, pero ahora mismo no tienen una fecha futura válida. Revisá fecha, días y vacaciones.'
: 'Creá una alarma y PluriWave calculará la siguiente ejecución automáticamente.'
: '${proxima.nombre} · ${_fechaHora(proximaProgramable!)}',
),
],
),
),
],
),
);
}
}
class _TarjetaAlarma extends StatelessWidget {
const _TarjetaAlarma({required this.alarma});
final AlarmaMusical alarma;
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoAlarmas>();
final excepcion = estado.ultimaExcepcionPara(alarma.id);
final mensajeVacaciones = _mensajeVacaciones(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(alarma),
),
_InfoChip(
icon: Icons.snooze_rounded,
label: '${alarma.snoozeMinutos} min',
),
_InfoChip(
icon: Icons.beach_access_rounded,
label:
alarma.sonarEnVacaciones
? 'Suena en vacaciones'
: 'Pausa en vacaciones',
),
_InfoChip(
icon: Icons.volume_up_rounded,
label: '${(alarma.volumen * 100).round()}%',
),
_InfoChip(
icon: Icons.trending_up_rounded,
label: 'Fade-in ${alarma.fadeInSegundos}s',
),
],
),
const SizedBox(height: 12),
if (alarma.proximaProgramable != null)
_NoticeLine(
icon: Icons.event_available_rounded,
text:
'Siguiente ejecución: ${_fechaHora(alarma.proximaProgramable!)}',
)
else
const _NoticeLine(
icon: Icons.pause_circle_outline_rounded,
text: 'No tiene próxima ejecución activa.',
),
if (excepcion != null) ...[
const SizedBox(height: 6),
_NoticeLine(
icon: Icons.skip_next_rounded,
text:
'Una ejecución fue omitida: ${_fechaHora(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: const Text('Editar'),
onPressed: () => _abrirEditor(context, alarma: alarma),
),
TextButton.icon(
icon: const Icon(Icons.skip_next_rounded),
label: const Text('Omitir siguiente'),
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
? 'Alarma omitida. No queda próxima ejecución.'
: 'Alarma omitida. Volverá el ${_fechaHora(actualizada!.proximaProgramable!)}.',
),
),
);
}
},
),
const Spacer(),
IconButton(
tooltip: 'Eliminar',
icon: const Icon(Icons.delete_outline_rounded),
onPressed: () => estado.eliminarAlarma(alarma.id),
),
],
),
],
),
);
}
String? _mensajeVacaciones(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 'Está pausada por vacaciones (${actual.nombre}) y sin próxima ejecución.';
}
return 'Está pausada por vacaciones (${actual.nombre}) y vuelve el ${_fechaHora(alarma.proximaProgramable!)}.';
}
if (alarma.proximaProgramable != null) {
return 'Con vacaciones activas, volverá a sonar el ${_fechaHora(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 int _snooze;
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 ahora = DateTime.now().add(const Duration(minutes: 5));
_nombreController = TextEditingController(
text: alarma?.nombre ?? 'Despertador musical',
);
_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>[]};
_snooze = alarma?.snoozeMinutos ?? 5;
_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 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 ? 'Nueva alarma' : 'Editar alarma',
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: const InputDecoration(labelText: 'Nombre'),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _PickerButton(
icon: Icons.schedule_rounded,
label: 'Hora',
value: _hora.format(context),
onTap: _elegirHora,
),
),
const SizedBox(width: 10),
Expanded(
child: _PickerButton(
icon: Icons.event_rounded,
label: 'Fecha',
value: _fechaCorta(_fecha),
onTap:
_tipo == TipoProgramacionAlarma.unica
? _elegirFecha
: null,
),
),
],
),
const SizedBox(height: 12),
SegmentedButton<TipoProgramacionAlarma>(
segments: const [
ButtonSegment(
value: TipoProgramacionAlarma.unica,
label: Text('Una vez'),
),
ButtonSegment(
value: TipoProgramacionAlarma.diaria,
label: Text('Diaria'),
),
ButtonSegment(
value: TipoProgramacionAlarma.diasSemana,
label: Text('Días'),
),
],
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(_diaCorto(i)),
selected: _diasSemana.contains(i),
onSelected:
(selected) => setState(() {
selected
? _diasSemana.add(i)
: _diasSemana.remove(i);
}),
),
],
),
],
const SizedBox(height: 14),
_SectionLabel(
icon: 'assets/icons/alarmas/snooze_wave.png',
text: 'Postponer',
),
SegmentedButton<int>(
segments: const [
ButtonSegment(value: 3, label: Text('3 min')),
ButtonSegment(value: 5, label: Text('5 min')),
ButtonSegment(value: 10, label: Text('10 min')),
],
selected: {_snooze},
onSelectionChanged:
(value) => setState(() => _snooze = value.first),
),
const SizedBox(height: 14),
_SectionLabel(
icon: 'assets/icons/alarmas/fallback_sound.png',
text: 'Sonido y volumen',
),
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: const Text('Fade-in de alarma'),
subtitle: Text(
_fadeInSegundos == 0
? '0 s (sin transición)'
: '$_fadeInSegundos s (de 5% al volumen elegido)',
),
),
Slider(
value: _fadeInSegundos.toDouble(),
min: 0,
max: 60,
divisions: 60,
label: '${_fadeInSegundos}s',
onChanged:
(value) =>
setState(() => _fadeInSegundos = value.round()),
),
DropdownButtonFormField<SonidoInternoAlarma>(
initialValue: _sonidoInterno,
decoration: const InputDecoration(
labelText: 'Sonido seguro interno',
),
items: const [
DropdownMenuItem(
value: SonidoInternoAlarma.amanecer,
child: Text('Amanecer cálido'),
),
DropdownMenuItem(
value: SonidoInternoAlarma.campanaSuave,
child: Text('Campana suave'),
),
DropdownMenuItem(
value: SonidoInternoAlarma.pulsoDigital,
child: Text('Pulso digital'),
),
],
onChanged:
(value) => setState(
() => _sonidoInterno = value ?? _sonidoInterno,
),
),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
key: ValueKey(_emisora?.uuid ?? 'sin-emisora'),
initialValue: _emisora?.uuid,
decoration: const InputDecoration(
labelText: 'Emisora favorita',
prefixIcon: Icon(Icons.radio_rounded),
),
items: [
const DropdownMenuItem<String>(
value: '',
child: Text('Sin emisora: usar sonido interno'),
),
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),
const Text(
'Guardá emisoras en Favoritos para usarlas como alarma musical.',
),
],
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: const Text('Usar emisora actual'),
),
),
],
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: const Text('Sonar durante vacaciones'),
subtitle: const Text(
'Si lo apagás, la próxima ejecución saltará al primer día válido.',
),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _guardar,
icon: const Icon(Icons.check_rounded),
label: const Text('Guardar alarma'),
),
],
),
),
),
);
}
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(
const SnackBar(content: Text('Elegí al menos un día de la semana.')),
);
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
? 'Despertador musical'
: _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: _snooze,
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 diag = estado.diagnostico;
return TextButton.icon(
icon: const _AssetIcon(
'assets/icons/alarmas/android_reliability.png',
size: 28,
),
label: Text(
diag == null
? 'Revisar fiabilidad Android'
: 'Fiabilidad: exactas ${diag.puedeProgramarExactas ? 'OK' : 'pendiente'} ? notificaciones ${diag.notificacionesPermitidas ? 'OK' : 'pendiente'} ? pantalla ${diag.puedeUsarPantallaCompleta ? 'OK' : 'pendiente'}',
),
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 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(
'Rangos de vacaciones',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
),
),
),
FilledButton.tonalIcon(
onPressed: () => _abrirAlta(context),
icon: const Icon(Icons.add_rounded),
label: const Text('Agregar'),
),
],
),
const SizedBox(height: 8),
Text(
'Si una alarma tiene "Pausa en vacaciones", se salta automáticamente estos rangos.',
),
const SizedBox(height: 10),
if (vacaciones.isEmpty)
const Text('Sin rangos cargados.')
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: 'Eliminar rango',
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: 'Vacaciones');
}
@override
void dispose() {
_nombreController.dispose();
super.dispose();
}
@override
Widget build(BuildContext 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(
'Nuevo rango de vacaciones',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900),
),
const SizedBox(height: 12),
TextField(
controller: _nombreController,
decoration: const InputDecoration(labelText: 'Nombre'),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _PickerButton(
icon: Icons.play_arrow_rounded,
label: 'Inicio',
value: _fechaCorta(_inicio),
onTap: () => _elegirFecha(esInicio: true),
),
),
const SizedBox(width: 10),
Expanded(
child: _PickerButton(
icon: Icons.stop_rounded,
label: 'Fin',
value: _fechaCorta(_fin),
onTap: () => _elegirFecha(esInicio: false),
),
),
],
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _guardar,
icon: const Icon(Icons.check_rounded),
label: const Text('Guardar rango'),
),
],
),
),
);
}
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) {
return const PluriGlassSurface(
child: Column(
children: [
_AssetIcon('assets/icons/alarmas/alarm_music.png', size: 92),
SizedBox(height: 12),
Text('Todavía no hay alarmas.'),
SizedBox(height: 4),
Text('Creá una para diseñar tu despertar musical.'),
],
),
);
}
}
String _hora(AlarmaMusical alarma) =>
'${alarma.hora.toString().padLeft(2, '0')}:${alarma.minuto.toString().padLeft(2, '0')}';
String _programacion(AlarmaMusical alarma) {
return switch (alarma.tipoProgramacion) {
TipoProgramacionAlarma.unica =>
'Una vez · ${_fechaCorta(alarma.fechaUnica ?? DateTime.now())}',
TipoProgramacionAlarma.diaria => 'Diaria',
TipoProgramacionAlarma.diasSemana =>
'Días: ${alarma.diasSemana.map(_diaCorto).join(', ')}',
};
}
String _fechaHora(DateTime fecha) {
final local = fecha.toLocal();
return '${_diaLargo(local.weekday)} ${local.day} de ${_mes(local.month)} a las '
'${local.hour.toString().padLeft(2, '0')}:${local.minute.toString().padLeft(2, '0')}';
}
String _fechaCorta(DateTime fecha) =>
'${fecha.day.toString().padLeft(2, '0')}/${fecha.month.toString().padLeft(2, '0')}/${fecha.year}';
String _diaCorto(int dia) => switch (dia) {
DateTime.monday => 'Lun',
DateTime.tuesday => 'Mar',
DateTime.wednesday => 'Mié',
DateTime.thursday => 'Jue',
DateTime.friday => 'Vie',
DateTime.saturday => 'Sáb',
DateTime.sunday => 'Dom',
_ => '?',
};
String _diaLargo(int dia) => switch (dia) {
DateTime.monday => 'lunes',
DateTime.tuesday => 'martes',
DateTime.wednesday => 'miércoles',
DateTime.thursday => 'jueves',
DateTime.friday => 'viernes',
DateTime.saturday => 'sábado',
DateTime.sunday => 'domingo',
_ => 'día',
};
String _mes(int mes) => switch (mes) {
1 => 'enero',
2 => 'febrero',
3 => 'marzo',
4 => 'abril',
5 => 'mayo',
6 => 'junio',
7 => 'julio',
8 => 'agosto',
9 => 'septiembre',
10 => 'octubre',
11 => 'noviembre',
12 => 'diciembre',
_ => 'mes',
};