import 'dart:async'; import 'dart:ui' show Locale; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import '../l10n/display_names.dart'; import '../l10n/gen/app_localizations.dart'; import '../modelos/alarma_musical.dart'; class EventoAlarmaAndroid { const EventoAlarmaAndroid({ required this.alarmaId, required this.titulo, required this.accion, this.triggerAtMillis = 0, this.occurrenceAtMillis = 0, this.snoozeMinutes = 5, this.snoozeUntilMillis = 0, }); /// Action reported when the native service snoozed an alarm by itself /// (notification "Posponer" while the app may be backgrounded/killed). static const accionSnoozed = 'snoozed'; final String alarmaId; final String titulo; final String accion; final int triggerAtMillis; final int occurrenceAtMillis; final int snoozeMinutes; final int snoozeUntilMillis; factory EventoAlarmaAndroid.fromMap(Map map) { return EventoAlarmaAndroid( alarmaId: map['alarmId'] as String? ?? '', titulo: map['alarmTitle'] as String? ?? 'PluriWave', accion: map['alarmAction'] as String? ?? '', triggerAtMillis: (map['triggerAtMillis'] as num?)?.toInt() ?? 0, occurrenceAtMillis: (map['occurrenceAtMillis'] as num?)?.toInt() ?? 0, snoozeMinutes: (map['snoozeMinutes'] as num?)?.toInt() ?? 5, snoozeUntilMillis: (map['snoozeUntilMillis'] as num?)?.toInt() ?? 0, ); } } /// Active native snooze persisted by `AlarmScheduler` (Kotlin). Used on cold /// start so Flutter (single source of truth) can import snoozes performed /// while the engine was dead. class EstadoSnoozeNativo { const EstadoSnoozeNativo({ required this.alarmaId, required this.snoozeHasta, required this.snoozeOrigen, }); final String alarmaId; final DateTime snoozeHasta; final DateTime snoozeOrigen; factory EstadoSnoozeNativo.fromMap(Map map) { return EstadoSnoozeNativo( alarmaId: map['alarmId'] as String? ?? '', snoozeHasta: DateTime.fromMillisecondsSinceEpoch( (map['snoozeUntilMillis'] as num?)?.toInt() ?? 0, ), snoozeOrigen: DateTime.fromMillisecondsSinceEpoch( (map['snoozeOriginMillis'] as num?)?.toInt() ?? 0, ), ); } } class DiagnosticoAlarmasAndroid { const DiagnosticoAlarmasAndroid({ required this.puedeProgramarExactas, required this.notificacionesPermitidas, required this.puedeUsarPantallaCompleta, required this.ignoraOptimizacionBateria, required this.alarmasNativasPendientes, required this.fabricante, required this.versionSdk, }); final bool puedeProgramarExactas; final bool notificacionesPermitidas; final bool puedeUsarPantallaCompleta; final bool ignoraOptimizacionBateria; final int alarmasNativasPendientes; final String fabricante; final int versionSdk; factory DiagnosticoAlarmasAndroid.fromMap(Map map) { return DiagnosticoAlarmasAndroid( puedeProgramarExactas: map['canScheduleExactAlarms'] as bool? ?? true, notificacionesPermitidas: map['notificationsEnabled'] as bool? ?? true, puedeUsarPantallaCompleta: map['canUseFullScreenIntent'] as bool? ?? true, ignoraOptimizacionBateria: map['isIgnoringBatteryOptimizations'] as bool? ?? true, alarmasNativasPendientes: map['nativePendingAlarmsCount'] as int? ?? 0, fabricante: map['manufacturer'] as String? ?? 'Android', versionSdk: map['sdkInt'] as int? ?? 0, ); } } class EjecucionAlarmaNativa { const EjecucionAlarmaNativa({ required this.alarmaId, required this.gestionadaEn, }); final String alarmaId; final DateTime gestionadaEn; factory EjecucionAlarmaNativa.fromMap(Map map) { return EjecucionAlarmaNativa( alarmaId: map['alarmId'] as String? ?? '', gestionadaEn: DateTime.fromMillisecondsSinceEpoch( (map['handledAtMillis'] as num?)?.toInt() ?? 0, ), ); } } abstract class PuertoAlarmasAndroid { Stream get eventosAlarma; /// Provides the UI localizations used to localize the alarm/station names /// sent to the native scheduler. void configurarLocalizaciones(AppLocalizations l10n); Future programar(AlarmaMusical alarma); Future cancelar(String alarmaId); Future ocultarNotificacionAlarma(String alarmaId); Future detenerSonidoNativo(String alarmaId); Future solicitarPermisoAlarmasExactas(); Future solicitarPermisoNotificaciones(); Future solicitarPermisoPantallaCompleta(); Future solicitarExencionBateria(); Future confirmarAudioFlutter(String alarmaId); Future diagnostico(); Future obtenerEventoInicial(); Future> obtenerEjecucionesNativasGestionadas(); Future> obtenerEstadoSnoozeNativo(); } class ServicioAlarmasAndroid implements PuertoAlarmasAndroid { ServicioAlarmasAndroid({ MethodChannel channel = const MethodChannel('pluriwave/alarm_scheduler'), }) : _channel = channel { _instalarHandler(); } final MethodChannel _channel; // Instance state (S3-R2): each bridge owns its controller and l10n so // independent instances never share events through globals. final _eventosController = StreamController.broadcast(); AppLocalizations? _l10n; AppLocalizations get _textos { final actual = _l10n; if (actual != null) return actual; return lookupAppLocalizations(const Locale('es')); } @override void configurarLocalizaciones(AppLocalizations l10n) { _l10n = l10n; } @override Stream get eventosAlarma => _eventosController.stream; @override Future programar(AlarmaMusical alarma) async { final proxima = alarma.proximaProgramable; if (proxima == null || !alarma.activa) { debugPrint( '[PluriWave][alarmas] cancelar por inactiva/sin proxima id=${alarma.id} activa=${alarma.activa} proxima=$proxima', ); await cancelar(alarma.id); return; } debugPrint( '[PluriWave][alarmas] programar id=${alarma.id} nombre=${alarma.nombre} proxima=${proxima.toIso8601String()} preaviso=${proxima.subtract(const Duration(minutes: 30)).toIso8601String()}', ); final programada = await _channel.invokeMethod('scheduleAlarm', { 'id': alarma.id, 'title': localizedAlarmName(_textos, alarma.nombre), 'triggerAtMillis': proxima.millisecondsSinceEpoch, 'preNoticeAtMillis': alarma.snoozeHasta == null ? proxima .subtract(const Duration(minutes: 30)) .millisecondsSinceEpoch : 0, 'hour': alarma.hora, 'minute': alarma.minuto, 'scheduleType': alarma.tipoProgramacion.name, 'weekdays': alarma.diasSemana, 'oneShotDateMillis': alarma.fechaUnica?.millisecondsSinceEpoch, 'snoozeUntilMillis': alarma.snoozeHasta?.millisecondsSinceEpoch, 'snoozeOriginMillis': alarma.snoozeOrigen?.millisecondsSinceEpoch, 'snoozeMinutes': alarma.snoozeMinutos, 'lastHandledAtMillis': alarma.ultimaEjecucionGestionada?.millisecondsSinceEpoch, 'soundOnVacation': alarma.sonarEnVacaciones, 'stationName': alarma.emisora == null ? null : localizedStationName(_textos, alarma.emisora!.nombre), 'stationUrl': alarma.emisora?.url, 'fallbackStationName': alarma.emisoraFallback == null ? null : localizedStationName(_textos, alarma.emisoraFallback!.nombre), 'fallbackStationUrl': alarma.emisoraFallback?.url, 'fallbackSound': alarma.sonidoInterno.name, 'volume': alarma.volumen, 'fadeInSegundos': alarma.fadeInSegundos, }); if (programada != true) { throw StateError(_textos.androidExactAlarmScheduleError); } } @override Future cancelar(String alarmaId) => _logAndInvokeVoid('cancelAlarm', {'id': alarmaId}); @override Future ocultarNotificacionAlarma(String alarmaId) => _logAndInvokeVoid('dismissAlarmNotification', {'id': alarmaId}); @override Future detenerSonidoNativo(String alarmaId) => _logAndInvokeVoid('stopNativeAlarmSound', {'id': alarmaId}); @override Future confirmarAudioFlutter(String alarmaId) => _logAndInvokeVoid('confirmFlutterAudio', {'id': alarmaId}); @override Future solicitarPermisoAlarmasExactas() async { final abierto = await _channel.invokeMethod( 'requestExactAlarmPermission', ); return abierto ?? false; } @override Future solicitarPermisoNotificaciones() async { final abierto = await _channel.invokeMethod( 'requestPostNotificationsPermission', ); return abierto ?? false; } @override Future solicitarPermisoPantallaCompleta() async { final abierto = await _channel.invokeMethod( 'requestFullScreenIntentPermission', ); return abierto ?? false; } @override Future solicitarExencionBateria() async { final abierto = await _channel.invokeMethod( 'requestIgnoreBatteryOptimizations', ); return abierto ?? false; } @override Future diagnostico() async { debugPrint('[PluriWave][alarmas] diagnostico android'); final raw = await _channel.invokeMethod>( 'diagnostics', ); final diag = DiagnosticoAlarmasAndroid.fromMap(raw ?? const {}); debugPrint( '[PluriWave][alarmas] diagnostico exactas=${diag.puedeProgramarExactas} notificaciones=${diag.notificacionesPermitidas} sdk=${diag.versionSdk} fabricante=${diag.fabricante}', ); return diag; } @override Future obtenerEventoInicial() async { final raw = await _channel.invokeMethod>( 'getInitialAlarmIntent', ); if (raw == null || raw.isEmpty) return null; final evento = EventoAlarmaAndroid.fromMap(raw); debugPrint( '[PluriWave][alarmas] evento inicial id=${evento.alarmaId} accion=${evento.accion}', ); return evento.alarmaId.isEmpty ? null : evento; } @override Future> obtenerEjecucionesNativasGestionadas() async { final raw = await _channel.invokeMethod>( 'getHandledAlarmOccurrences', ); if (raw == null || raw.isEmpty) return const []; return raw .whereType>() .map(EjecucionAlarmaNativa.fromMap) .where( (evento) => evento.alarmaId.isNotEmpty && evento.gestionadaEn.millisecondsSinceEpoch > 0, ) .toList(); } @override Future> obtenerEstadoSnoozeNativo() async { final raw = await _channel.invokeMethod>( 'getNativeSnoozeState', ); if (raw == null || raw.isEmpty) return const []; return raw .whereType>() .map(EstadoSnoozeNativo.fromMap) .where( (estado) => estado.alarmaId.isNotEmpty && estado.snoozeHasta.millisecondsSinceEpoch > 0, ) .toList(); } Future _logAndInvokeVoid(String method, Map args) { debugPrint('[PluriWave][alarmas] $method $args'); return _channel.invokeMethod(method, args); } // Installed once per instance from the constructor. Creating a second // instance over the SAME channel re-binds the platform handler to the // newest instance (production has exactly one instance per channel). void _instalarHandler() { _channel.setMethodCallHandler((call) async { if (call.method != 'alarmFired') return; final args = call.arguments; if (args is Map) { final evento = EventoAlarmaAndroid.fromMap(args); if (evento.alarmaId.isNotEmpty) { debugPrint( '[PluriWave][alarmas] evento nativo id=${evento.alarmaId} accion=${evento.accion}', ); _eventosController.add(evento); } } }); } }