feat(alarm): add musical alarm foundation
Build & Deploy Pluriwave / Análisis de código (push) Successful in 14s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 2m45s

This commit is contained in:
2026-05-21 23:46:52 +02:00
parent 8c2cba093c
commit fb808ebb60
30 changed files with 1437 additions and 43 deletions
+217
View File
@@ -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;
}