Files
pluriwave/lib/modelos/alarma_musical.dart
T
FreeTLab 88bd251eba fix(alarmas): scope schedule-skip exceptions to skipNext only
ExcepcionAlarma._esValida matched ANY exception tipo against an
occurrence, treating it as a user skip. Only the 'skipNext' tipo
existed until now, but the next commits reuse the same model to
record scheduling-reliability failures per alarm (so the alarms list
can surface them via ultimaExcepcionPara) -- without this guard, a
recorded failure would be silently treated as if the user asked to
skip that occurrence, corrupting scheduling. Adds tipo constants to
ExcepcionAlarma for the upcoming failure kinds.
2026-07-31 20:59:02 +02:00

356 lines
12 KiB
Dart

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.fechaUnica,
this.emisora,
this.emisoraFallback,
this.activa = true,
this.sonarEnVacaciones = true,
this.snoozeMinutos = 5,
this.volumen = 0.85,
this.fadeInSegundos = 0,
this.sonidoInterno = SonidoInternoAlarma.amanecer,
this.proximaEjecucion,
this.snoozeHasta,
this.snoozeOrigen,
this.ultimaEjecucionGestionada,
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 DateTime? fechaUnica;
final Emisora? emisora;
final Emisora? emisoraFallback;
final bool sonarEnVacaciones;
final int snoozeMinutos;
final double volumen;
final int fadeInSegundos;
final SonidoInternoAlarma sonidoInterno;
final DateTime? proximaEjecucion;
final DateTime? snoozeHasta;
final DateTime? snoozeOrigen;
final DateTime? ultimaEjecucionGestionada;
final DateTime? creadaEn;
final DateTime? actualizadaEn;
AlarmaMusical copyWith({
String? id,
String? nombre,
bool? activa,
int? hora,
int? minuto,
TipoProgramacionAlarma? tipoProgramacion,
List<int>? diasSemana,
DateTime? fechaUnica,
bool limpiarFechaUnica = false,
Emisora? emisora,
bool limpiarEmisora = false,
Emisora? emisoraFallback,
bool limpiarEmisoraFallback = false,
bool? sonarEnVacaciones,
int? snoozeMinutos,
double? volumen,
int? fadeInSegundos,
SonidoInternoAlarma? sonidoInterno,
DateTime? proximaEjecucion,
bool limpiarProximaEjecucion = false,
DateTime? snoozeHasta,
DateTime? snoozeOrigen,
bool limpiarSnooze = false,
DateTime? ultimaEjecucionGestionada,
bool limpiarUltimaEjecucionGestionada = false,
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,
fechaUnica: limpiarFechaUnica ? null : fechaUnica ?? this.fechaUnica,
emisora: limpiarEmisora ? emisora : emisora ?? this.emisora,
emisoraFallback:
limpiarEmisoraFallback
? emisoraFallback
: emisoraFallback ?? this.emisoraFallback,
sonarEnVacaciones: sonarEnVacaciones ?? this.sonarEnVacaciones,
snoozeMinutos: snoozeMinutos ?? this.snoozeMinutos,
volumen: volumen ?? this.volumen,
fadeInSegundos: fadeInSegundos ?? this.fadeInSegundos,
sonidoInterno: sonidoInterno ?? this.sonidoInterno,
proximaEjecucion:
limpiarProximaEjecucion
? proximaEjecucion
: proximaEjecucion ?? this.proximaEjecucion,
snoozeHasta:
limpiarSnooze ? snoozeHasta : snoozeHasta ?? this.snoozeHasta,
snoozeOrigen:
limpiarSnooze ? snoozeOrigen : snoozeOrigen ?? this.snoozeOrigen,
ultimaEjecucionGestionada:
limpiarUltimaEjecucionGestionada
? ultimaEjecucionGestionada
: ultimaEjecucionGestionada ?? this.ultimaEjecucionGestionada,
creadaEn: creadaEn ?? this.creadaEn,
actualizadaEn: actualizadaEn ?? this.actualizadaEn,
);
}
DateTime? get proximaProgramable => snoozeHasta ?? proximaEjecucion;
Map<String, dynamic> toJson() => {
'id': id,
'nombre': nombre,
'activa': activa,
'hora': hora,
'minuto': minuto,
'tipoProgramacion': tipoProgramacion.name,
'diasSemana': diasSemana,
'fechaUnica': fechaUnica?.toIso8601String(),
'emisora': emisora?.toMap(),
'emisoraFallback': emisoraFallback?.toMap(),
'sonarEnVacaciones': sonarEnVacaciones,
'snoozeMinutos': snoozeMinutos,
'volumen': volumen,
'fadeInSegundos': fadeInSegundos,
'sonidoInterno': sonidoInterno.name,
// INSTANT fields serialize as UTC (offset-carrying "Z" ISO): a local
// toIso8601String() has no offset, so re-parsing it after the device
// changes timezone reinterprets the same wall fields as a DIFFERENT
// instant (a snooze set in Madrid would shift hours after landing in
// New York). fechaUnica stays local-ISO on purpose: it is a wall-clock
// DATE (only y/m/d are ever read), which must follow the user.
'proximaEjecucion': proximaEjecucion?.toUtc().toIso8601String(),
'snoozeHasta': snoozeHasta?.toUtc().toIso8601String(),
'snoozeOrigen': snoozeOrigen?.toUtc().toIso8601String(),
'ultimaEjecucionGestionada':
ultimaEjecucionGestionada?.toUtc().toIso8601String(),
'creadaEn': creadaEn?.toUtc().toIso8601String(),
'actualizadaEn': actualizadaEn?.toUtc().toIso8601String(),
};
// persistence-resilience (D2): `id` stays a REQUIRED, un-defaulted cast
// on purpose -- a missing/wrong-type id must throw, not fall back to a
// fabricated value. Callers that read persisted collections (e.g.
// ServicioAlarmas._parsear via persistencia_tolerante.dart) wrap each
// fromJson call in a per-entry try: a thrown entry is skipped and
// logged, never replacing this required field with a sentinel/fabricated
// id ("skip-never-fabricate"). This boundary also tolerates any future
// required-field break the same way, not just id.
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(),
fechaUnica: _dateFromJson(json['fechaUnica']),
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,
fadeInSegundos:
(json['fadeInSegundos'] as int? ?? 0).clamp(0, 60).toInt(),
sonidoInterno: _enumFromName(
SonidoInternoAlarma.values,
json['sonidoInterno'] as String?,
SonidoInternoAlarma.amanecer,
),
proximaEjecucion: _dateFromJson(json['proximaEjecucion']),
snoozeHasta: _dateFromJson(json['snoozeHasta']),
snoozeOrigen: _dateFromJson(json['snoozeOrigen']),
ultimaEjecucionGestionada: _dateFromJson(
json['ultimaEjecucionGestionada'],
),
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));
}
// Normalizes to LOCAL on read: new payloads carry "Z" (UTC instants,
// toLocal converts), legacy offset-less payloads parse as local already
// (toLocal is then the identity) — both shapes land as the same local
// DateTime the scheduling math expects, so no data migration is needed.
static DateTime? _dateFromJson(Object? raw) =>
raw is String ? DateTime.tryParse(raw)?.toLocal() : 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;
DateTime get inicioDia => DateTime(inicio.year, inicio.month, inicio.day);
DateTime get finDia => DateTime(fin.year, fin.month, fin.day);
bool contiene(DateTime fecha) {
final dia = DateTime(fecha.year, fecha.month, fecha.day);
final desde = inicioDia;
final hasta = finDia;
return activo && !dia.isBefore(desde) && !dia.isAfter(hasta);
}
RangoVacaciones normalizado() {
if (!finDia.isBefore(inicioDia)) return this;
return RangoVacaciones(
id: id,
nombre: nombre,
inicio: finDia,
fin: inicioDia,
activo: activo,
);
}
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,
);
}
}
/// Per-alarm vacation pause impact (design ADR-6, WU9). Produced by
/// `EstadoAlarmas.impactoDeRango`, never persisted, never built from a
/// second date-math implementation — see that method's own doc comment for
/// the exact predicate it mirrors.
class ImpactoVacaciones {
const ImpactoVacaciones({required this.pausadas, required this.noAfectadas});
/// `activa && !sonarEnVacaciones`.
final List<AlarmaMusical> pausadas;
/// `activa && sonarEnVacaciones`.
final List<AlarmaMusical> noAfectadas;
}
class ExcepcionAlarma {
const ExcepcionAlarma({
required this.alarmaId,
required this.ejecucion,
required this.tipo,
});
final String alarmaId;
final DateTime ejecucion;
final String tipo;
/// User-requested skip of the next occurrence (the only [tipo] this model
/// originally supported). `ServicioProgramacionAlarmas._esValida` only
/// treats THIS tipo as an actual schedule skip -- every tipo below records
/// a scheduling-reliability failure and must never affect which occurrence
/// fires next.
static const tipoSaltoSiguiente = 'skipNext';
/// The main alarm registration with the OS failed (`android.programar`
/// threw). Recorded per-alarm so the alarms list can mark the exact card
/// affected instead of only a transient, alarm-agnostic app-wide message.
static const tipoFalloProgramacion = 'schedulingFailed';
/// The main alarm registered successfully but its 30-minute pre-notice
/// reminder did not (native `SecurityException` scheduling the pre-notice
/// alone) -- distinguished from [tipoFalloProgramacion] because the alarm
/// itself will still ring; only the early warning is missing.
static const tipoFalloPreaviso = 'preNoticeFailed';
/// The OS refused to start the foreground ringing service when the alarm
/// fired (e.g. a background-restricted app), so the alarm never actually
/// rang even though it was armed.
static const tipoFalloServicioSonido = 'foregroundServiceFailed';
/// A per-alarm reschedule after boot/unlock failed while sibling alarms
/// succeeded, leaving this one specific alarm unscheduled.
static const tipoFalloReprogramacionArranque = 'rescheduleAfterBootFailed';
/// Every tipo above that represents a reliability FAILURE rather than a
/// deliberate user action -- used by the UI to decide whether to mark a
/// card, and by [ServicioAlarmas] to know which prior record to replace.
static const tiposFallo = {
tipoFalloProgramacion,
tipoFalloPreaviso,
tipoFalloServicioSonido,
tipoFalloReprogramacionArranque,
};
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',
);
}
}