079e19f0ee
- Integrate audio_session (new servicio_audio_session.dart): incoming calls pause the radio and resume on end, headphone unplug pauses without auto-resume, permanent focus loss never auto-resumes, duck lowers volume - Add play-intent flag to ServicioAudio so interruption handling and future reconnect logic can distinguish user pause from system-driven stops - Eliminate read-modify-write race in ServicioAlarmas with an in-memory cache and single-writer queue across all mutations; recalcularTodas persists only when state actually changed - Convert ServicioAlarmasAndroid static StreamController/handler to injectable instance fields, restoring test isolation - Inject a single cached SharedPreferences from main.dart across services and state (removes 23 inline getInstance() calls) - Move configurarLocalizaciones out of MiniReproductor.build() (was running on every rebuild during playback) - Bound the alarm fire-dedup set (cap 200 entries, 24h pruning) - 12 new tests (89 total green), flutter analyze clean
465 lines
14 KiB
Dart
465 lines
14 KiB
Dart
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();
|
|
|
|
// In-memory cache + single-writer queue (Design 3.5 / S3-R7): every
|
|
// mutation runs serialized through [_enCola] and reads [_cache], killing
|
|
// the read-modify-write race the old cargar()-before-each-mutation had.
|
|
ConfiguracionAlarmas? _cache;
|
|
String? _cacheRaw;
|
|
Future<void> _cola = Future<void>.value();
|
|
|
|
Future<T> _enCola<T>(Future<T> Function() accion) {
|
|
final resultado = _cola.then((_) => accion());
|
|
_cola = resultado.then((_) {}, onError: (_) {});
|
|
return resultado;
|
|
}
|
|
|
|
/// Re-reads from persistent storage (refreshing the cache) so writes done
|
|
/// outside this service — e.g. a settings import that rewrites the raw
|
|
/// key — are always picked up.
|
|
Future<ConfiguracionAlarmas> cargar() => _enCola(() {
|
|
_cache = null;
|
|
_cacheRaw = null;
|
|
return _configActual();
|
|
});
|
|
|
|
Future<ConfiguracionAlarmas> _configActual() async {
|
|
final existente = _cache;
|
|
if (existente != null) return existente;
|
|
final prefs = await _resolverPrefs();
|
|
final raw = prefs.getString(_keyConfig);
|
|
final config = _parsear(raw);
|
|
_cache = config;
|
|
_cacheRaw = raw;
|
|
return config;
|
|
}
|
|
|
|
ConfiguracionAlarmas _parsear(String? raw) {
|
|
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,
|
|
}) => _enCola(() async {
|
|
final config = await _configActual();
|
|
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) => _enCola(() async {
|
|
final config = await _configActual();
|
|
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,
|
|
) => _enCola(() async {
|
|
final config = await _configActual();
|
|
final normalizadas =
|
|
vacaciones.map((v) => v.normalizado()).toList()
|
|
..sort((a, b) => a.inicioDia.compareTo(b.inicioDia));
|
|
final alarmas =
|
|
config.alarmas
|
|
.map((a) => _recalcular(a, normalizadas, config.excepciones))
|
|
.toList();
|
|
final nuevo = ConfiguracionAlarmas(
|
|
alarmas: alarmas,
|
|
vacaciones: normalizadas,
|
|
excepciones: config.excepciones,
|
|
);
|
|
await _guardar(nuevo);
|
|
return nuevo;
|
|
});
|
|
|
|
RangoVacaciones crearRangoVacaciones({
|
|
required DateTime inicio,
|
|
required DateTime fin,
|
|
String? nombre,
|
|
}) {
|
|
final rango = RangoVacaciones(
|
|
id: _uuid.v4(),
|
|
nombre:
|
|
(nombre == null || nombre.trim().isEmpty)
|
|
? 'Vacaciones'
|
|
: nombre.trim(),
|
|
inicio: inicio,
|
|
fin: fin,
|
|
);
|
|
return rango.normalizado();
|
|
}
|
|
|
|
Future<ConfiguracionAlarmas> recalcularTodas() => _enCola(() async {
|
|
final config = await _configActual();
|
|
final alarmas =
|
|
config.alarmas
|
|
.map((a) => _recalcular(a, config.vacaciones, config.excepciones))
|
|
.toList();
|
|
final nuevo = ConfiguracionAlarmas(
|
|
alarmas: alarmas,
|
|
vacaciones: config.vacaciones,
|
|
excepciones: config.excepciones,
|
|
);
|
|
// Dirty-guard (S3-R5): this runs every minute from the refresh timer;
|
|
// skip the SharedPreferences write when nothing actually changed.
|
|
final nuevoRaw = _serializar(nuevo);
|
|
final actualRaw = _cacheRaw ?? _serializar(config);
|
|
if (nuevoRaw == actualRaw) return config;
|
|
await _guardar(nuevo, raw: nuevoRaw);
|
|
return nuevo;
|
|
});
|
|
|
|
Future<ConfiguracionAlarmas> sincronizarEjecucionesNativas(
|
|
Map<String, DateTime> ejecuciones,
|
|
) {
|
|
if (ejecuciones.isEmpty) return cargar();
|
|
return _enCola(() => _sincronizarEjecucionesNativasInterno(ejecuciones));
|
|
}
|
|
|
|
Future<ConfiguracionAlarmas> _sincronizarEjecucionesNativasInterno(
|
|
Map<String, DateTime> ejecuciones,
|
|
) async {
|
|
final config = await _configActual();
|
|
final ahora = _reloj();
|
|
var huboCambios = false;
|
|
final alarmas =
|
|
config.alarmas.map((alarma) {
|
|
final gestionadaEn = ejecuciones[alarma.id];
|
|
if (gestionadaEn == null) return alarma;
|
|
final ultima = alarma.ultimaEjecucionGestionada;
|
|
if (ultima != null && !gestionadaEn.isAfter(ultima)) return alarma;
|
|
|
|
final proxima = alarma.proximaProgramable;
|
|
if (proxima != null &&
|
|
proxima.isAfter(
|
|
gestionadaEn.add(
|
|
ServicioProgramacionAlarmas.toleranciaDisparoInminente,
|
|
),
|
|
)) {
|
|
return alarma;
|
|
}
|
|
|
|
final siguiente = _programacion.calcularSiguienteDespuesDeEjecucion(
|
|
alarma: alarma,
|
|
ejecucion: gestionadaEn,
|
|
vacaciones: config.vacaciones,
|
|
excepciones: config.excepciones,
|
|
);
|
|
huboCambios = true;
|
|
return alarma.copyWith(
|
|
activa:
|
|
alarma.tipoProgramacion == TipoProgramacionAlarma.unica
|
|
? false
|
|
: alarma.activa,
|
|
proximaEjecucion: siguiente,
|
|
limpiarProximaEjecucion: true,
|
|
limpiarSnooze: true,
|
|
ultimaEjecucionGestionada: gestionadaEn,
|
|
actualizadaEn: ahora,
|
|
);
|
|
}).toList();
|
|
|
|
if (!huboCambios) return config;
|
|
final nuevo = ConfiguracionAlarmas(
|
|
alarmas: alarmas,
|
|
vacaciones: config.vacaciones,
|
|
excepciones: config.excepciones,
|
|
);
|
|
await _guardar(nuevo);
|
|
return nuevo;
|
|
}
|
|
|
|
Future<ConfiguracionAlarmas> saltarProxima(String alarmaId) =>
|
|
_enCola(() async {
|
|
final config = await _configActual();
|
|
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;
|
|
});
|
|
|
|
Future<ConfiguracionAlarmas> posponerEjecucion(
|
|
String alarmaId,
|
|
DateTime ejecucion,
|
|
int minutos,
|
|
) async {
|
|
// Unified snooze anchor (Design 2.2): occurrence + minutes, clamped to
|
|
// now + minutes when the target already passed. Matches the native
|
|
// AlarmScheduler.snooze/postponeNext semantics so both layers always
|
|
// land on the same re-fire time.
|
|
final seguros = minutos.clamp(1, 120);
|
|
final objetivo = ejecucion.add(Duration(minutes: seguros));
|
|
final ahora = _reloj();
|
|
final snoozeHasta =
|
|
objetivo.isAfter(ahora)
|
|
? objetivo
|
|
: ahora.add(Duration(minutes: seguros));
|
|
return posponerEjecucionHasta(alarmaId, ejecucion, snoozeHasta);
|
|
}
|
|
|
|
Future<ConfiguracionAlarmas> posponerEjecucionHasta(
|
|
String alarmaId,
|
|
DateTime ejecucion,
|
|
DateTime snoozeHasta,
|
|
) => _enCola(() async {
|
|
final config = await _configActual();
|
|
final ahora = _reloj();
|
|
final alarmas =
|
|
config.alarmas
|
|
.map(
|
|
(a) =>
|
|
a.id == alarmaId
|
|
? a.copyWith(
|
|
snoozeHasta: snoozeHasta,
|
|
snoozeOrigen: ejecucion,
|
|
ultimaEjecucionGestionada: ejecucion,
|
|
actualizadaEn: ahora,
|
|
)
|
|
: a,
|
|
)
|
|
.toList();
|
|
final nuevo = ConfiguracionAlarmas(
|
|
alarmas: alarmas,
|
|
vacaciones: config.vacaciones,
|
|
excepciones: config.excepciones,
|
|
);
|
|
await _guardar(nuevo);
|
|
return nuevo;
|
|
});
|
|
|
|
Future<ConfiguracionAlarmas> completarEjecucion(
|
|
String alarmaId,
|
|
DateTime ejecucion,
|
|
) => _enCola(() async {
|
|
final config = await _configActual();
|
|
final ahora = _reloj();
|
|
final alarmas =
|
|
config.alarmas.map((a) {
|
|
if (a.id != alarmaId) return a;
|
|
final siguiente = _programacion.calcularSiguienteDespuesDeEjecucion(
|
|
alarma: a,
|
|
ejecucion: ejecucion,
|
|
vacaciones: config.vacaciones,
|
|
excepciones: config.excepciones,
|
|
);
|
|
return a.copyWith(
|
|
activa:
|
|
a.tipoProgramacion == TipoProgramacionAlarma.unica
|
|
? false
|
|
: a.activa,
|
|
proximaEjecucion: siguiente,
|
|
limpiarProximaEjecucion: true,
|
|
limpiarSnooze: true,
|
|
ultimaEjecucionGestionada: ejecucion,
|
|
actualizadaEn: ahora,
|
|
);
|
|
}).toList();
|
|
final nuevo = ConfiguracionAlarmas(
|
|
alarmas: alarmas,
|
|
vacaciones: config.vacaciones,
|
|
excepciones: config.excepciones,
|
|
);
|
|
await _guardar(nuevo);
|
|
return nuevo;
|
|
});
|
|
|
|
AlarmaMusical crearAlarma({
|
|
required String nombre,
|
|
required int hora,
|
|
required int minuto,
|
|
required TipoProgramacionAlarma tipoProgramacion,
|
|
required List<int> diasSemana,
|
|
DateTime? fechaUnica,
|
|
Emisora? emisora,
|
|
Emisora? emisoraFallback,
|
|
bool sonarEnVacaciones = true,
|
|
int snoozeMinutos = 5,
|
|
double volumen = 0.85,
|
|
SonidoInternoAlarma sonidoInterno = SonidoInternoAlarma.amanecer,
|
|
}) {
|
|
final ahora = _reloj();
|
|
return AlarmaMusical(
|
|
id: _uuid.v4(),
|
|
nombre: nombre,
|
|
hora: hora,
|
|
minuto: minuto,
|
|
tipoProgramacion: tipoProgramacion,
|
|
diasSemana: diasSemana,
|
|
fechaUnica: fechaUnica,
|
|
emisora: emisora,
|
|
emisoraFallback: emisoraFallback,
|
|
sonarEnVacaciones: sonarEnVacaciones,
|
|
snoozeMinutos: snoozeMinutos,
|
|
volumen: volumen,
|
|
sonidoInterno: sonidoInterno,
|
|
creadaEn: ahora,
|
|
actualizadaEn: ahora,
|
|
);
|
|
}
|
|
|
|
/// Persists [config] and refreshes the in-memory cache (the cache is
|
|
/// "invalidated" by replacing it with the just-written state).
|
|
Future<void> _guardar(ConfiguracionAlarmas config, {String? raw}) async {
|
|
final serializado = raw ?? _serializar(config);
|
|
final prefs = await _resolverPrefs();
|
|
await prefs.setString(_keyConfig, serializado);
|
|
_cache = config;
|
|
_cacheRaw = serializado;
|
|
}
|
|
|
|
String _serializar(ConfiguracionAlarmas config) => 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,
|
|
) {
|
|
final ahora = _reloj();
|
|
// S2-R5: a disabled alarm must not keep a pending snooze; clearing it
|
|
// here guarantees the snoozed occurrence dies with the alarm.
|
|
final snoozeActivo =
|
|
alarma.activa &&
|
|
alarma.snoozeHasta != null &&
|
|
alarma.snoozeHasta!.isAfter(ahora);
|
|
final proxima = _programacion.calcularProxima(
|
|
alarma: alarma,
|
|
desde: ahora,
|
|
vacaciones: vacaciones,
|
|
excepciones: excepciones,
|
|
);
|
|
return alarma.copyWith(
|
|
proximaEjecucion: proxima,
|
|
limpiarProximaEjecucion: true,
|
|
limpiarSnooze: !snoozeActivo,
|
|
);
|
|
}
|
|
|
|
Future<SharedPreferences> _resolverPrefs() async =>
|
|
_prefs ?? SharedPreferences.getInstance();
|
|
}
|