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.
356 lines
12 KiB
Dart
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',
|
|
);
|
|
}
|
|
}
|