Files
pluriwave/lib/modelos/alarma_musical.dart
T
FreeTLab d8e67a5204
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m46s
fix(alarm): make all date math wall-clock correct across DST and timezone changes
Full time-domain audit (three shipped date bugs prompted it) found one
root cause and two latent travel defects, all now fixed:

Day-stepping used add(Duration(days: 1)), which shifts the absolute
instant by exactly 86400s — documented Dart behavior (sdk#47666), so
crossing a DST transition drifted the wall hour by +-1h permanently
for the rest of the candidate scan (verified: 2026-03-28 07:30
Europe/Madrid + "1 day" = 08:30). The native Calendar engine preserves
wall time, and the single-authority fix made the drifted Dart verdict
win. Candidates now advance by calendar reconstruction (_siguienteDia:
DateTime(y, m, d+1, hora, minuto)), the same wall-clock-preserving
semantics as Calendar.add(DAY_OF_YEAR, 1) plus AOSP DeskClock's
defensive hour/minute re-assertion, keeping both engines in agreement
through any transition.

Instant-valued fields (snoozeHasta/snoozeOrigen/proximaEjecucion/
ultimaEjecucionGestionada/creadaEn/actualizadaEn) serialized as
offset-less local ISO, so re-parsing after a device timezone change
reinterpreted the same wall fields as a different instant. They now
serialize as UTC ("Z"); reads normalize to local, and legacy
offset-less payloads parse identically — no migration. fechaUnica
stays local on purpose: it is a wall-clock date.

One-shot alarms sent fechaUnica's midnight epoch to the native side,
whose boot/travel re-arm derives the calendar day back from it in the
CURRENT zone — a westward shift rolled the date to the previous day.
The channel now anchors the date at local noon, keeping it stable
across real-world zone shifts.

Property tests lock the no-drift guarantee (400 daily / 200 weekday
iterations must all land exactly at hora:minuto — on DST-observing
dev machines this crosses real transitions), plus UTC round-trip,
legacy-payload compatibility, and wall-date preservation tests.
2026-07-12 23:32:32 +02:00

305 lines
10 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,
);
}
}
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',
);
}
}