feat(alarm): add musical alarm foundation
This commit is contained in:
+16
-2
@@ -2,6 +2,8 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'estado/estado_radio.dart';
|
||||
import 'estado/estado_alarmas.dart';
|
||||
import 'pantallas/pantalla_alarmas.dart';
|
||||
import 'pantallas/pantalla_inicio.dart';
|
||||
import 'pantallas/pantalla_buscar.dart';
|
||||
import 'pantallas/pantalla_favoritos.dart';
|
||||
@@ -17,8 +19,11 @@ class PluriWaveApp extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProvider(
|
||||
create: (_) => EstadoRadio(),
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider(create: (_) => EstadoRadio()),
|
||||
ChangeNotifierProvider(create: (_) => EstadoAlarmas()),
|
||||
],
|
||||
child: MaterialApp(
|
||||
title: 'PluriWave',
|
||||
debugShowCheckedModeBanner: false,
|
||||
@@ -47,6 +52,7 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
|
||||
PantallaInicio(),
|
||||
PantallaBuscar(),
|
||||
PantallaFavoritos(),
|
||||
PantallaAlarmas(),
|
||||
PantallaAjustes(),
|
||||
];
|
||||
|
||||
@@ -75,6 +81,14 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
|
||||
),
|
||||
label: 'Favoritos',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: PluriIcon(glyph: PluriIconGlyph.alarm),
|
||||
selectedIcon: PluriIcon(
|
||||
glyph: PluriIconGlyph.alarm,
|
||||
variant: PluriIconVariant.activeGlow,
|
||||
),
|
||||
label: 'Alarmas',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: PluriIcon(glyph: PluriIconGlyph.settings),
|
||||
selectedIcon: PluriIcon(
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../modelos/alarma_musical.dart';
|
||||
import '../servicios/servicio_alarmas.dart';
|
||||
import '../servicios/servicio_alarmas_android.dart';
|
||||
|
||||
class EstadoAlarmas extends ChangeNotifier {
|
||||
EstadoAlarmas({
|
||||
ServicioAlarmas? servicio,
|
||||
ServicioAlarmasAndroid? android,
|
||||
bool iniciarAutomaticamente = true,
|
||||
}) : servicio = servicio ?? ServicioAlarmas(),
|
||||
android = android ?? ServicioAlarmasAndroid() {
|
||||
if (iniciarAutomaticamente) {
|
||||
inicializar();
|
||||
}
|
||||
}
|
||||
|
||||
final ServicioAlarmas servicio;
|
||||
final ServicioAlarmasAndroid android;
|
||||
|
||||
List<AlarmaMusical> _alarmas = [];
|
||||
List<RangoVacaciones> _vacaciones = [];
|
||||
DiagnosticoAlarmasAndroid? _diagnostico;
|
||||
bool _cargando = false;
|
||||
String? _error;
|
||||
|
||||
List<AlarmaMusical> get alarmas => List.unmodifiable(_alarmas);
|
||||
List<RangoVacaciones> get vacaciones => List.unmodifiable(_vacaciones);
|
||||
DiagnosticoAlarmasAndroid? get diagnostico => _diagnostico;
|
||||
bool get cargando => _cargando;
|
||||
String? get error => _error;
|
||||
|
||||
AlarmaMusical? get proximaAlarma {
|
||||
final candidatas =
|
||||
_alarmas.where((a) => a.activa && a.proximaEjecucion != null).toList()
|
||||
..sort((a, b) => a.proximaEjecucion!.compareTo(b.proximaEjecucion!));
|
||||
return candidatas.isEmpty ? null : candidatas.first;
|
||||
}
|
||||
|
||||
Future<void> inicializar() async {
|
||||
_cargando = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
final config = await servicio.cargar();
|
||||
_aplicar(config);
|
||||
await _sincronizarTodas();
|
||||
await cargarDiagnostico();
|
||||
} catch (e) {
|
||||
_error = 'No se pudieron cargar las alarmas: $e';
|
||||
} finally {
|
||||
_cargando = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> guardarAlarma(AlarmaMusical alarma) async {
|
||||
final config = await servicio.guardarAlarma(alarma);
|
||||
_aplicar(config);
|
||||
await android.programar(_alarmas.firstWhere((a) => a.id == alarma.id));
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> eliminarAlarma(String id) async {
|
||||
final config = await servicio.eliminarAlarma(id);
|
||||
_aplicar(config);
|
||||
await android.cancelar(id);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> cambiarActiva(AlarmaMusical alarma, bool activa) async {
|
||||
await guardarAlarma(alarma.copyWith(activa: activa));
|
||||
}
|
||||
|
||||
Future<void> saltarProxima(String alarmaId) async {
|
||||
final config = await servicio.saltarProxima(alarmaId);
|
||||
_aplicar(config);
|
||||
AlarmaMusical? alarma;
|
||||
for (final item in _alarmas) {
|
||||
if (item.id == alarmaId) {
|
||||
alarma = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (alarma != null) {
|
||||
await android.programar(alarma);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> guardarVacaciones(List<RangoVacaciones> vacaciones) async {
|
||||
final config = await servicio.guardarVacaciones(vacaciones);
|
||||
_aplicar(config);
|
||||
await _sincronizarTodas();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> cargarDiagnostico() async {
|
||||
try {
|
||||
_diagnostico = await android.diagnostico();
|
||||
} catch (_) {
|
||||
_diagnostico = null;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _sincronizarTodas() async {
|
||||
for (final alarma in _alarmas) {
|
||||
await android.programar(alarma);
|
||||
}
|
||||
}
|
||||
|
||||
void _aplicar(ConfiguracionAlarmas config) {
|
||||
_alarmas = config.alarmas;
|
||||
_vacaciones = config.vacaciones;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import 'emisora.dart';
|
||||
|
||||
enum TipoProgramacionAlarma { unica, diaria, diasSemana }
|
||||
|
||||
enum SonidoInternoAlarma { amanecer, campanaSuave, pulsoDigital }
|
||||
|
||||
class AlarmaMusical {
|
||||
const AlarmaMusical({
|
||||
required this.id,
|
||||
required this.nombre,
|
||||
required this.hora,
|
||||
required this.minuto,
|
||||
required this.tipoProgramacion,
|
||||
required this.diasSemana,
|
||||
this.emisora,
|
||||
this.emisoraFallback,
|
||||
this.activa = true,
|
||||
this.sonarEnVacaciones = true,
|
||||
this.snoozeMinutos = 5,
|
||||
this.volumen = 0.85,
|
||||
this.sonidoInterno = SonidoInternoAlarma.amanecer,
|
||||
this.proximaEjecucion,
|
||||
this.creadaEn,
|
||||
this.actualizadaEn,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String nombre;
|
||||
final bool activa;
|
||||
final int hora;
|
||||
final int minuto;
|
||||
final TipoProgramacionAlarma tipoProgramacion;
|
||||
final List<int> diasSemana;
|
||||
final Emisora? emisora;
|
||||
final Emisora? emisoraFallback;
|
||||
final bool sonarEnVacaciones;
|
||||
final int snoozeMinutos;
|
||||
final double volumen;
|
||||
final SonidoInternoAlarma sonidoInterno;
|
||||
final DateTime? proximaEjecucion;
|
||||
final DateTime? creadaEn;
|
||||
final DateTime? actualizadaEn;
|
||||
|
||||
AlarmaMusical copyWith({
|
||||
String? id,
|
||||
String? nombre,
|
||||
bool? activa,
|
||||
int? hora,
|
||||
int? minuto,
|
||||
TipoProgramacionAlarma? tipoProgramacion,
|
||||
List<int>? diasSemana,
|
||||
Emisora? emisora,
|
||||
Emisora? emisoraFallback,
|
||||
bool? sonarEnVacaciones,
|
||||
int? snoozeMinutos,
|
||||
double? volumen,
|
||||
SonidoInternoAlarma? sonidoInterno,
|
||||
DateTime? proximaEjecucion,
|
||||
DateTime? creadaEn,
|
||||
DateTime? actualizadaEn,
|
||||
}) {
|
||||
return AlarmaMusical(
|
||||
id: id ?? this.id,
|
||||
nombre: nombre ?? this.nombre,
|
||||
activa: activa ?? this.activa,
|
||||
hora: hora ?? this.hora,
|
||||
minuto: minuto ?? this.minuto,
|
||||
tipoProgramacion: tipoProgramacion ?? this.tipoProgramacion,
|
||||
diasSemana: diasSemana ?? this.diasSemana,
|
||||
emisora: emisora ?? this.emisora,
|
||||
emisoraFallback: emisoraFallback ?? this.emisoraFallback,
|
||||
sonarEnVacaciones: sonarEnVacaciones ?? this.sonarEnVacaciones,
|
||||
snoozeMinutos: snoozeMinutos ?? this.snoozeMinutos,
|
||||
volumen: volumen ?? this.volumen,
|
||||
sonidoInterno: sonidoInterno ?? this.sonidoInterno,
|
||||
proximaEjecucion: proximaEjecucion ?? this.proximaEjecucion,
|
||||
creadaEn: creadaEn ?? this.creadaEn,
|
||||
actualizadaEn: actualizadaEn ?? this.actualizadaEn,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'nombre': nombre,
|
||||
'activa': activa,
|
||||
'hora': hora,
|
||||
'minuto': minuto,
|
||||
'tipoProgramacion': tipoProgramacion.name,
|
||||
'diasSemana': diasSemana,
|
||||
'emisora': emisora?.toMap(),
|
||||
'emisoraFallback': emisoraFallback?.toMap(),
|
||||
'sonarEnVacaciones': sonarEnVacaciones,
|
||||
'snoozeMinutos': snoozeMinutos,
|
||||
'volumen': volumen,
|
||||
'sonidoInterno': sonidoInterno.name,
|
||||
'proximaEjecucion': proximaEjecucion?.toIso8601String(),
|
||||
'creadaEn': creadaEn?.toIso8601String(),
|
||||
'actualizadaEn': actualizadaEn?.toIso8601String(),
|
||||
};
|
||||
|
||||
factory AlarmaMusical.fromJson(Map<String, dynamic> json) {
|
||||
return AlarmaMusical(
|
||||
id: json['id'] as String,
|
||||
nombre: json['nombre'] as String? ?? 'Alarma musical',
|
||||
activa: json['activa'] as bool? ?? true,
|
||||
hora: json['hora'] as int? ?? 7,
|
||||
minuto: json['minuto'] as int? ?? 0,
|
||||
tipoProgramacion: _enumFromName(
|
||||
TipoProgramacionAlarma.values,
|
||||
json['tipoProgramacion'] as String?,
|
||||
TipoProgramacionAlarma.unica,
|
||||
),
|
||||
diasSemana:
|
||||
(json['diasSemana'] as List? ?? const [])
|
||||
.whereType<int>()
|
||||
.where((d) => d >= DateTime.monday && d <= DateTime.sunday)
|
||||
.toList(),
|
||||
emisora: _emisoraFromJson(json['emisora']),
|
||||
emisoraFallback: _emisoraFromJson(json['emisoraFallback']),
|
||||
sonarEnVacaciones: json['sonarEnVacaciones'] as bool? ?? true,
|
||||
snoozeMinutos: json['snoozeMinutos'] as int? ?? 5,
|
||||
volumen: (json['volumen'] as num?)?.toDouble() ?? 0.85,
|
||||
sonidoInterno: _enumFromName(
|
||||
SonidoInternoAlarma.values,
|
||||
json['sonidoInterno'] as String?,
|
||||
SonidoInternoAlarma.amanecer,
|
||||
),
|
||||
proximaEjecucion: _dateFromJson(json['proximaEjecucion']),
|
||||
creadaEn: _dateFromJson(json['creadaEn']),
|
||||
actualizadaEn: _dateFromJson(json['actualizadaEn']),
|
||||
);
|
||||
}
|
||||
|
||||
static Emisora? _emisoraFromJson(Object? raw) {
|
||||
if (raw is! Map) return null;
|
||||
return Emisora.fromMap(Map<String, dynamic>.from(raw));
|
||||
}
|
||||
|
||||
static DateTime? _dateFromJson(Object? raw) =>
|
||||
raw is String ? DateTime.tryParse(raw) : null;
|
||||
|
||||
static T _enumFromName<T extends Enum>(
|
||||
List<T> values,
|
||||
String? name,
|
||||
T fallback,
|
||||
) {
|
||||
for (final value in values) {
|
||||
if (value.name == name) return value;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
class RangoVacaciones {
|
||||
const RangoVacaciones({
|
||||
required this.id,
|
||||
required this.nombre,
|
||||
required this.inicio,
|
||||
required this.fin,
|
||||
this.activo = true,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String nombre;
|
||||
final DateTime inicio;
|
||||
final DateTime fin;
|
||||
final bool activo;
|
||||
|
||||
bool contiene(DateTime fecha) {
|
||||
final dia = DateTime(fecha.year, fecha.month, fecha.day);
|
||||
final desde = DateTime(inicio.year, inicio.month, inicio.day);
|
||||
final hasta = DateTime(fin.year, fin.month, fin.day);
|
||||
return activo && !dia.isBefore(desde) && !dia.isAfter(hasta);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'nombre': nombre,
|
||||
'inicio': inicio.toIso8601String(),
|
||||
'fin': fin.toIso8601String(),
|
||||
'activo': activo,
|
||||
};
|
||||
|
||||
factory RangoVacaciones.fromJson(Map<String, dynamic> json) {
|
||||
return RangoVacaciones(
|
||||
id: json['id'] as String,
|
||||
nombre: json['nombre'] as String? ?? 'Vacaciones',
|
||||
inicio: DateTime.parse(json['inicio'] as String),
|
||||
fin: DateTime.parse(json['fin'] as String),
|
||||
activo: json['activo'] as bool? ?? true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ExcepcionAlarma {
|
||||
const ExcepcionAlarma({
|
||||
required this.alarmaId,
|
||||
required this.ejecucion,
|
||||
required this.tipo,
|
||||
});
|
||||
|
||||
final String alarmaId;
|
||||
final DateTime ejecucion;
|
||||
final String tipo;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'alarmaId': alarmaId,
|
||||
'ejecucion': ejecucion.toIso8601String(),
|
||||
'tipo': tipo,
|
||||
};
|
||||
|
||||
factory ExcepcionAlarma.fromJson(Map<String, dynamic> json) {
|
||||
return ExcepcionAlarma(
|
||||
alarmaId: json['alarmaId'] as String,
|
||||
ejecucion: DateTime.parse(json['ejecucion'] as String),
|
||||
tipo: json['tipo'] as String? ?? 'skipNext',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../estado/estado_alarmas.dart';
|
||||
import '../modelos/alarma_musical.dart';
|
||||
import '../widgets/pluri_glass_surface.dart';
|
||||
import '../widgets/pluri_icon.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 ListView(
|
||||
padding: const EdgeInsets.fromLTRB(0, 0, 0, 124),
|
||||
children: [
|
||||
PluriScreenHeader(
|
||||
title: 'Alarmas musicales',
|
||||
subtitle:
|
||||
'Despertador con radio, vacaciones, aviso previo y fallbacks seguros.',
|
||||
glyph: PluriIconGlyph.alarm,
|
||||
primaryActionLabel: 'Nueva alarma',
|
||||
onPrimaryAction: () => _crearDemo(context),
|
||||
trailing: PluriStatusPill(
|
||||
icon: Icons.alarm_on_rounded,
|
||||
label: '${estado.alarmas.length} alarmas',
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
_DiagnosticoAlarmas(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: 10),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _crearDemo(BuildContext context) async {
|
||||
final estado = context.read<EstadoAlarmas>();
|
||||
final ahora = TimeOfDay.now();
|
||||
final alarma = estado.servicio.crearAlarma(
|
||||
nombre: 'Despertador musical',
|
||||
hora: ahora.hour,
|
||||
minuto: (ahora.minute + 2) % 60,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: const [],
|
||||
);
|
||||
await estado.guardarAlarma(alarma);
|
||||
}
|
||||
}
|
||||
|
||||
class _DiagnosticoAlarmas extends StatelessWidget {
|
||||
const _DiagnosticoAlarmas({required this.estado});
|
||||
|
||||
final EstadoAlarmas estado;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final diag = estado.diagnostico;
|
||||
return PluriGlassSurface(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.health_and_safety_outlined),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Fiabilidad Android',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
tooltip: 'Revisar',
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
onPressed: estado.cargarDiagnostico,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_EstadoPermiso(
|
||||
label: 'Alarmas exactas',
|
||||
ok: diag?.puedeProgramarExactas ?? false,
|
||||
),
|
||||
_EstadoPermiso(
|
||||
label: 'Notificaciones',
|
||||
ok: diag?.notificacionesPermitidas ?? false,
|
||||
),
|
||||
if (diag == null)
|
||||
Text(
|
||||
'Diagnóstico pendiente. En Android se revisan permisos nativos.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EstadoPermiso extends StatelessWidget {
|
||||
const _EstadoPermiso({required this.label, required this.ok});
|
||||
|
||||
final String label;
|
||||
final bool ok;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(
|
||||
ok ? Icons.check_circle_rounded : Icons.warning_amber_rounded,
|
||||
color: ok ? Colors.greenAccent : Colors.orangeAccent,
|
||||
),
|
||||
title: Text(label),
|
||||
subtitle: Text(ok ? 'Correcto' : 'Requiere revisión'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TarjetaAlarma extends StatelessWidget {
|
||||
const _TarjetaAlarma({required this.alarma});
|
||||
|
||||
final AlarmaMusical alarma;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final estado = context.read<EstadoAlarmas>();
|
||||
return PluriGlassSurface(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SwitchListTile.adaptive(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: alarma.activa,
|
||||
onChanged: (value) => estado.cambiarActiva(alarma, value),
|
||||
title: Text(
|
||||
_hora(alarma),
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
subtitle: Text(alarma.nombre),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
Chip(label: Text(_programacion(alarma))),
|
||||
Chip(label: Text('Snooze ${alarma.snoozeMinutos} min')),
|
||||
Chip(
|
||||
label: Text(
|
||||
alarma.sonarEnVacaciones
|
||||
? 'Suena en vacaciones'
|
||||
: 'Respeta vacaciones',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (alarma.proximaEjecucion != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text('Próxima: ${alarma.proximaEjecucion!.toLocal()}'),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.skip_next_rounded),
|
||||
label: const Text('Saltar próxima'),
|
||||
onPressed: () => estado.saltarProxima(alarma.id),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
tooltip: 'Eliminar',
|
||||
icon: const Icon(Icons.delete_outline_rounded),
|
||||
onPressed: () => estado.eliminarAlarma(alarma.id),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
TipoProgramacionAlarma.diaria => 'Diaria',
|
||||
TipoProgramacionAlarma.diasSemana =>
|
||||
'Días: ${alarma.diasSemana.join(', ')}',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyAlarmas extends StatelessWidget {
|
||||
const _EmptyAlarmas();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const PluriGlassSurface(
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.alarm_add_rounded, size: 42),
|
||||
SizedBox(height: 12),
|
||||
Text('Todavía no hay alarmas.'),
|
||||
SizedBox(height: 4),
|
||||
Text('Crea una para empezar a diseñar tu despertar musical.'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../modelos/alarma_musical.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
import 'servicio_programacion_alarmas.dart';
|
||||
|
||||
class ConfiguracionAlarmas {
|
||||
const ConfiguracionAlarmas({
|
||||
required this.alarmas,
|
||||
required this.vacaciones,
|
||||
required this.excepciones,
|
||||
});
|
||||
|
||||
final List<AlarmaMusical> alarmas;
|
||||
final List<RangoVacaciones> vacaciones;
|
||||
final List<ExcepcionAlarma> excepciones;
|
||||
}
|
||||
|
||||
class ServicioAlarmas {
|
||||
ServicioAlarmas({
|
||||
ServicioProgramacionAlarmas? programacion,
|
||||
SharedPreferences? prefs,
|
||||
DateTime Function()? reloj,
|
||||
}) : _programacion = programacion ?? ServicioProgramacionAlarmas(),
|
||||
_prefs = prefs,
|
||||
_reloj = reloj ?? DateTime.now;
|
||||
|
||||
static const _keyConfig = 'alarmas_musicales_v1';
|
||||
final ServicioProgramacionAlarmas _programacion;
|
||||
final SharedPreferences? _prefs;
|
||||
final DateTime Function() _reloj;
|
||||
final _uuid = const Uuid();
|
||||
|
||||
Future<ConfiguracionAlarmas> cargar() async {
|
||||
final prefs = await _resolverPrefs();
|
||||
final raw = prefs.getString(_keyConfig);
|
||||
if (raw == null || raw.trim().isEmpty) {
|
||||
return const ConfiguracionAlarmas(
|
||||
alarmas: [],
|
||||
vacaciones: [],
|
||||
excepciones: [],
|
||||
);
|
||||
}
|
||||
try {
|
||||
final data = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return ConfiguracionAlarmas(
|
||||
alarmas:
|
||||
(data['alarmas'] as List? ?? const [])
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(e) => AlarmaMusical.fromJson(Map<String, dynamic>.from(e)),
|
||||
)
|
||||
.toList(),
|
||||
vacaciones:
|
||||
(data['vacaciones'] as List? ?? const [])
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(e) => RangoVacaciones.fromJson(Map<String, dynamic>.from(e)),
|
||||
)
|
||||
.toList(),
|
||||
excepciones:
|
||||
(data['excepciones'] as List? ?? const [])
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(e) => ExcepcionAlarma.fromJson(Map<String, dynamic>.from(e)),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
} catch (_) {
|
||||
return const ConfiguracionAlarmas(
|
||||
alarmas: [],
|
||||
vacaciones: [],
|
||||
excepciones: [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<ConfiguracionAlarmas> guardarAlarma(
|
||||
AlarmaMusical alarma, {
|
||||
List<RangoVacaciones>? vacaciones,
|
||||
List<ExcepcionAlarma>? excepciones,
|
||||
}) async {
|
||||
final config = await cargar();
|
||||
final ahora = _reloj();
|
||||
final alarmas = List<AlarmaMusical>.from(config.alarmas);
|
||||
final index = alarmas.indexWhere((a) => a.id == alarma.id);
|
||||
final normalizada = _recalcular(
|
||||
alarma.copyWith(creadaEn: alarma.creadaEn ?? ahora, actualizadaEn: ahora),
|
||||
vacaciones ?? config.vacaciones,
|
||||
excepciones ?? config.excepciones,
|
||||
);
|
||||
|
||||
if (index >= 0) {
|
||||
alarmas[index] = normalizada;
|
||||
} else {
|
||||
alarmas.add(normalizada);
|
||||
}
|
||||
final nuevo = ConfiguracionAlarmas(
|
||||
alarmas: alarmas,
|
||||
vacaciones: vacaciones ?? config.vacaciones,
|
||||
excepciones: excepciones ?? config.excepciones,
|
||||
);
|
||||
await _guardar(nuevo);
|
||||
return nuevo;
|
||||
}
|
||||
|
||||
Future<ConfiguracionAlarmas> eliminarAlarma(String id) async {
|
||||
final config = await cargar();
|
||||
final nuevo = ConfiguracionAlarmas(
|
||||
alarmas: config.alarmas.where((a) => a.id != id).toList(),
|
||||
vacaciones: config.vacaciones,
|
||||
excepciones: config.excepciones.where((e) => e.alarmaId != id).toList(),
|
||||
);
|
||||
await _guardar(nuevo);
|
||||
return nuevo;
|
||||
}
|
||||
|
||||
Future<ConfiguracionAlarmas> guardarVacaciones(
|
||||
List<RangoVacaciones> vacaciones,
|
||||
) async {
|
||||
final config = await cargar();
|
||||
final alarmas =
|
||||
config.alarmas
|
||||
.map((a) => _recalcular(a, vacaciones, config.excepciones))
|
||||
.toList();
|
||||
final nuevo = ConfiguracionAlarmas(
|
||||
alarmas: alarmas,
|
||||
vacaciones: vacaciones,
|
||||
excepciones: config.excepciones,
|
||||
);
|
||||
await _guardar(nuevo);
|
||||
return nuevo;
|
||||
}
|
||||
|
||||
Future<ConfiguracionAlarmas> saltarProxima(String alarmaId) async {
|
||||
final config = await cargar();
|
||||
final alarma = config.alarmas.firstWhere((a) => a.id == alarmaId);
|
||||
final proxima = alarma.proximaEjecucion;
|
||||
if (proxima == null) return config;
|
||||
|
||||
final excepciones = [
|
||||
...config.excepciones,
|
||||
ExcepcionAlarma(alarmaId: alarmaId, ejecucion: proxima, tipo: 'skipNext'),
|
||||
];
|
||||
final alarmas =
|
||||
config.alarmas
|
||||
.map(
|
||||
(a) =>
|
||||
a.id == alarmaId
|
||||
? _recalcular(a, config.vacaciones, excepciones)
|
||||
: a,
|
||||
)
|
||||
.toList();
|
||||
final nuevo = ConfiguracionAlarmas(
|
||||
alarmas: alarmas,
|
||||
vacaciones: config.vacaciones,
|
||||
excepciones: excepciones,
|
||||
);
|
||||
await _guardar(nuevo);
|
||||
return nuevo;
|
||||
}
|
||||
|
||||
AlarmaMusical crearAlarma({
|
||||
required String nombre,
|
||||
required int hora,
|
||||
required int minuto,
|
||||
required TipoProgramacionAlarma tipoProgramacion,
|
||||
required List<int> diasSemana,
|
||||
Emisora? emisora,
|
||||
}) {
|
||||
final ahora = _reloj();
|
||||
return AlarmaMusical(
|
||||
id: _uuid.v4(),
|
||||
nombre: nombre,
|
||||
hora: hora,
|
||||
minuto: minuto,
|
||||
tipoProgramacion: tipoProgramacion,
|
||||
diasSemana: diasSemana,
|
||||
emisora: emisora,
|
||||
creadaEn: ahora,
|
||||
actualizadaEn: ahora,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _guardar(ConfiguracionAlarmas config) async {
|
||||
final prefs = await _resolverPrefs();
|
||||
await prefs.setString(
|
||||
_keyConfig,
|
||||
jsonEncode({
|
||||
'alarmas': config.alarmas.map((a) => a.toJson()).toList(),
|
||||
'vacaciones': config.vacaciones.map((v) => v.toJson()).toList(),
|
||||
'excepciones': config.excepciones.map((e) => e.toJson()).toList(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
AlarmaMusical _recalcular(
|
||||
AlarmaMusical alarma,
|
||||
List<RangoVacaciones> vacaciones,
|
||||
List<ExcepcionAlarma> excepciones,
|
||||
) {
|
||||
return alarma.copyWith(
|
||||
proximaEjecucion: _programacion.calcularProxima(
|
||||
alarma: alarma,
|
||||
desde: _reloj(),
|
||||
vacaciones: vacaciones,
|
||||
excepciones: excepciones,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<SharedPreferences> _resolverPrefs() async =>
|
||||
_prefs ?? SharedPreferences.getInstance();
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../modelos/alarma_musical.dart';
|
||||
|
||||
class DiagnosticoAlarmasAndroid {
|
||||
const DiagnosticoAlarmasAndroid({
|
||||
required this.puedeProgramarExactas,
|
||||
required this.notificacionesPermitidas,
|
||||
required this.fabricante,
|
||||
required this.versionSdk,
|
||||
});
|
||||
|
||||
final bool puedeProgramarExactas;
|
||||
final bool notificacionesPermitidas;
|
||||
final String fabricante;
|
||||
final int versionSdk;
|
||||
|
||||
factory DiagnosticoAlarmasAndroid.fromMap(Map<Object?, Object?> map) {
|
||||
return DiagnosticoAlarmasAndroid(
|
||||
puedeProgramarExactas: map['canScheduleExactAlarms'] as bool? ?? true,
|
||||
notificacionesPermitidas: map['notificationsEnabled'] as bool? ?? true,
|
||||
fabricante: map['manufacturer'] as String? ?? 'Android',
|
||||
versionSdk: map['sdkInt'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ServicioAlarmasAndroid {
|
||||
ServicioAlarmasAndroid({
|
||||
MethodChannel channel = const MethodChannel('pluriwave/alarm_scheduler'),
|
||||
}) : _channel = channel;
|
||||
|
||||
final MethodChannel _channel;
|
||||
|
||||
Future<void> programar(AlarmaMusical alarma) async {
|
||||
final proxima = alarma.proximaEjecucion;
|
||||
if (proxima == null || !alarma.activa) {
|
||||
await cancelar(alarma.id);
|
||||
return;
|
||||
}
|
||||
await _channel.invokeMethod<void>('scheduleAlarm', {
|
||||
'id': alarma.id,
|
||||
'title': alarma.nombre,
|
||||
'triggerAtMillis': proxima.millisecondsSinceEpoch,
|
||||
'preNoticeAtMillis':
|
||||
proxima.subtract(const Duration(minutes: 30)).millisecondsSinceEpoch,
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> cancelar(String alarmaId) =>
|
||||
_channel.invokeMethod<void>('cancelAlarm', {'id': alarmaId});
|
||||
|
||||
Future<DiagnosticoAlarmasAndroid> diagnostico() async {
|
||||
final raw = await _channel.invokeMethod<Map<Object?, Object?>>(
|
||||
'diagnostics',
|
||||
);
|
||||
return DiagnosticoAlarmasAndroid.fromMap(raw ?? const {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import '../modelos/alarma_musical.dart';
|
||||
|
||||
class ServicioProgramacionAlarmas {
|
||||
DateTime? calcularProxima({
|
||||
required AlarmaMusical alarma,
|
||||
required DateTime desde,
|
||||
List<RangoVacaciones> vacaciones = const [],
|
||||
List<ExcepcionAlarma> excepciones = const [],
|
||||
}) {
|
||||
if (!alarma.activa) return null;
|
||||
|
||||
final inicio = DateTime(
|
||||
desde.year,
|
||||
desde.month,
|
||||
desde.day,
|
||||
alarma.hora,
|
||||
alarma.minuto,
|
||||
);
|
||||
final primerCandidato =
|
||||
inicio.isAfter(desde) ? inicio : inicio.add(const Duration(days: 1));
|
||||
|
||||
return switch (alarma.tipoProgramacion) {
|
||||
TipoProgramacionAlarma.unica =>
|
||||
_esValida(alarma, primerCandidato, vacaciones, excepciones)
|
||||
? primerCandidato
|
||||
: null,
|
||||
TipoProgramacionAlarma.diaria => _buscarDiaria(
|
||||
alarma,
|
||||
primerCandidato,
|
||||
vacaciones,
|
||||
excepciones,
|
||||
),
|
||||
TipoProgramacionAlarma.diasSemana => _buscarPorDiasSemana(
|
||||
alarma,
|
||||
primerCandidato,
|
||||
vacaciones,
|
||||
excepciones,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
DateTime calcularSnooze(DateTime desde, int minutos) {
|
||||
final seguro = minutos == 3 || minutos == 5 || minutos == 10 ? minutos : 5;
|
||||
return desde.add(Duration(minutes: seguro));
|
||||
}
|
||||
|
||||
bool estaEnVacaciones(DateTime fecha, List<RangoVacaciones> vacaciones) =>
|
||||
vacaciones.any((rango) => rango.contiene(fecha));
|
||||
|
||||
DateTime? _buscarDiaria(
|
||||
AlarmaMusical alarma,
|
||||
DateTime candidato,
|
||||
List<RangoVacaciones> vacaciones,
|
||||
List<ExcepcionAlarma> excepciones,
|
||||
) {
|
||||
var actual = candidato;
|
||||
for (var i = 0; i < 370; i++) {
|
||||
if (_esValida(alarma, actual, vacaciones, excepciones)) return actual;
|
||||
actual = actual.add(const Duration(days: 1));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
DateTime? _buscarPorDiasSemana(
|
||||
AlarmaMusical alarma,
|
||||
DateTime candidato,
|
||||
List<RangoVacaciones> vacaciones,
|
||||
List<ExcepcionAlarma> excepciones,
|
||||
) {
|
||||
if (alarma.diasSemana.isEmpty) return null;
|
||||
var actual = candidato;
|
||||
for (var i = 0; i < 370; i++) {
|
||||
if (alarma.diasSemana.contains(actual.weekday) &&
|
||||
_esValida(alarma, actual, vacaciones, excepciones)) {
|
||||
return actual;
|
||||
}
|
||||
actual = actual.add(const Duration(days: 1));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool _esValida(
|
||||
AlarmaMusical alarma,
|
||||
DateTime candidato,
|
||||
List<RangoVacaciones> vacaciones,
|
||||
List<ExcepcionAlarma> excepciones,
|
||||
) {
|
||||
if (!alarma.sonarEnVacaciones && estaEnVacaciones(candidato, vacaciones)) {
|
||||
return false;
|
||||
}
|
||||
return !excepciones.any(
|
||||
(excepcion) =>
|
||||
excepcion.alarmaId == alarma.id &&
|
||||
_mismaEjecucion(excepcion.ejecucion, candidato),
|
||||
);
|
||||
}
|
||||
|
||||
bool _mismaEjecucion(DateTime a, DateTime b) =>
|
||||
a.year == b.year &&
|
||||
a.month == b.month &&
|
||||
a.day == b.day &&
|
||||
a.hour == b.hour &&
|
||||
a.minute == b.minute;
|
||||
}
|
||||
+49
-41
@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import '../tema/pluriwave_tokens.dart';
|
||||
import '../tema/pluriwave_theme.dart';
|
||||
|
||||
enum PluriIconGlyph { home, search, favorites, player, settings }
|
||||
enum PluriIconGlyph { home, search, favorites, alarm, player, settings }
|
||||
|
||||
enum PluriIconVariant { outline, filled, activeGlow }
|
||||
|
||||
@@ -26,40 +26,40 @@ class PluriIcon extends StatelessWidget {
|
||||
final tokens = context.pluriTokens;
|
||||
final asset = _resolveAsset();
|
||||
final resolvedColor = _resolveColor(context, tokens);
|
||||
final icon = asset == null
|
||||
? Icon(_resolveData(), size: size, color: resolvedColor)
|
||||
: Opacity(
|
||||
opacity: variant == PluriIconVariant.outline ? 0.78 : 1,
|
||||
child: Image.asset(
|
||||
asset,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) => Icon(
|
||||
_resolveData(),
|
||||
size: size,
|
||||
color: resolvedColor,
|
||||
final icon =
|
||||
asset == null
|
||||
? Icon(_resolveData(), size: size, color: resolvedColor)
|
||||
: Opacity(
|
||||
opacity: variant == PluriIconVariant.outline ? 0.78 : 1,
|
||||
child: Image.asset(
|
||||
asset,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder:
|
||||
(_, __, ___) =>
|
||||
Icon(_resolveData(), size: size, color: resolvedColor),
|
||||
),
|
||||
),
|
||||
);
|
||||
final child = variant == PluriIconVariant.activeGlow
|
||||
? Container(
|
||||
width: size + 14,
|
||||
height: size + 14,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: tokens.glowColor,
|
||||
blurRadius: 18,
|
||||
spreadRadius: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: icon,
|
||||
)
|
||||
: icon;
|
||||
);
|
||||
final child =
|
||||
variant == PluriIconVariant.activeGlow
|
||||
? Container(
|
||||
width: size + 14,
|
||||
height: size + 14,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: tokens.glowColor,
|
||||
blurRadius: 18,
|
||||
spreadRadius: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: icon,
|
||||
)
|
||||
: icon;
|
||||
|
||||
return Semantics(
|
||||
label: semanticLabel ?? _fallbackLabel(glyph),
|
||||
@@ -68,13 +68,12 @@ class PluriIcon extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
String? _resolveAsset() {
|
||||
return switch (glyph) {
|
||||
PluriIconGlyph.home => 'assets/icons/pluri_home.png',
|
||||
PluriIconGlyph.search => 'assets/icons/pluri_search.png',
|
||||
PluriIconGlyph.favorites => 'assets/icons/pluri_favorites.png',
|
||||
PluriIconGlyph.alarm => null,
|
||||
PluriIconGlyph.player => 'assets/icons/pluri_player.png',
|
||||
PluriIconGlyph.settings => 'assets/icons/pluri_settings.png',
|
||||
};
|
||||
@@ -82,7 +81,9 @@ class PluriIcon extends StatelessWidget {
|
||||
|
||||
Color _resolveColor(BuildContext context, PluriWaveTokens tokens) {
|
||||
if (variant == PluriIconVariant.activeGlow) return tokens.electricMagenta;
|
||||
if (variant == PluriIconVariant.filled) return Theme.of(context).colorScheme.onSurface;
|
||||
if (variant == PluriIconVariant.filled) {
|
||||
return Theme.of(context).colorScheme.onSurface;
|
||||
}
|
||||
return Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.78);
|
||||
}
|
||||
|
||||
@@ -90,13 +91,19 @@ class PluriIcon extends StatelessWidget {
|
||||
return switch ((glyph, variant)) {
|
||||
(PluriIconGlyph.home, PluriIconVariant.outline) => Icons.home_outlined,
|
||||
(PluriIconGlyph.home, _) => Icons.home_rounded,
|
||||
(PluriIconGlyph.search, PluriIconVariant.outline) => Icons.search_outlined,
|
||||
(PluriIconGlyph.search, PluriIconVariant.outline) =>
|
||||
Icons.search_outlined,
|
||||
(PluriIconGlyph.search, _) => Icons.search_rounded,
|
||||
(PluriIconGlyph.favorites, PluriIconVariant.outline) => Icons.favorite_border_rounded,
|
||||
(PluriIconGlyph.favorites, PluriIconVariant.outline) =>
|
||||
Icons.favorite_border_rounded,
|
||||
(PluriIconGlyph.favorites, _) => Icons.favorite_rounded,
|
||||
(PluriIconGlyph.player, PluriIconVariant.outline) => Icons.play_circle_outline_rounded,
|
||||
(PluriIconGlyph.alarm, PluriIconVariant.outline) => Icons.alarm_outlined,
|
||||
(PluriIconGlyph.alarm, _) => Icons.alarm_rounded,
|
||||
(PluriIconGlyph.player, PluriIconVariant.outline) =>
|
||||
Icons.play_circle_outline_rounded,
|
||||
(PluriIconGlyph.player, _) => Icons.play_circle_rounded,
|
||||
(PluriIconGlyph.settings, PluriIconVariant.outline) => Icons.settings_outlined,
|
||||
(PluriIconGlyph.settings, PluriIconVariant.outline) =>
|
||||
Icons.settings_outlined,
|
||||
(PluriIconGlyph.settings, _) => Icons.settings_rounded,
|
||||
};
|
||||
}
|
||||
@@ -106,6 +113,7 @@ class PluriIcon extends StatelessWidget {
|
||||
PluriIconGlyph.home => 'Inicio',
|
||||
PluriIconGlyph.search => 'Buscar',
|
||||
PluriIconGlyph.favorites => 'Favoritos',
|
||||
PluriIconGlyph.alarm => 'Alarmas',
|
||||
PluriIconGlyph.player => 'Reproductor',
|
||||
PluriIconGlyph.settings => 'Ajustes',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user