diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f1de16f..2000b90 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -8,6 +8,7 @@ + diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt index 9fda42c..0bc1d82 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt @@ -72,6 +72,15 @@ class AlarmScheduler(private val context: Context) { NotificationManagerCompat.from(context).cancel( PluriWaveAlarmReceiver.notificationIdForAlarm(id) ) + NotificationManagerCompat.from(context).cancel( + PluriWaveAlarmReceiver.fireNotificationIdForAlarm(id) + ) + } + + fun dismissFireNotification(id: String) { + NotificationManagerCompat.from(context).cancel( + PluriWaveAlarmReceiver.fireNotificationIdForAlarm(id) + ) } fun canScheduleExactAlarms(): Boolean { diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt index a0f71d3..9cbc636 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt @@ -70,6 +70,15 @@ class MainActivity : AudioServiceActivity() { result.success(null) } } + "dismissAlarmNotification" -> { + val id = call.argument("id") + if (id == null) { + result.error("INVALID_ALARM", "Missing alarm id", null) + } else { + alarmScheduler.dismissFireNotification(id) + result.success(null) + } + } "diagnostics" -> { result.success( mapOf( diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt index 3e7d40f..3f6378b 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt @@ -23,6 +23,7 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() { putExtra(EXTRA_ALARM_TITLE, title) putExtra(EXTRA_ALARM_ACTION, ACTION_FIRE) } + showFireNotification(context, alarmId, title, launch) context.startActivity(launch) } ACTION_PRE_NOTICE -> { @@ -41,6 +42,40 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() { } } + private fun showFireNotification( + context: Context, + alarmId: String, + title: String, + launch: Intent + ) { + ensureFireChannel(context) + val fullScreenIntent = PendingIntent.getActivity( + context, + requestCode(alarmId, 10), + launch, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + val notification = NotificationCompat.Builder(context, FIRE_CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_lock_idle_alarm) + .setContentTitle("Alarma PluriWave") + .setContentText(title) + .setCategory(NotificationCompat.CATEGORY_ALARM) + .setPriority(NotificationCompat.PRIORITY_MAX) + .setOngoing(true) + .setAutoCancel(false) + .setContentIntent(fullScreenIntent) + .setFullScreenIntent(fullScreenIntent, true) + .build() + + try { + NotificationManagerCompat.from(context).notify( + fireNotificationIdForAlarm(alarmId), + notification, + ) + } catch (_: SecurityException) { + } + } + private fun showPreNoticeNotification(context: Context, alarmId: String, title: String) { ensureChannel(context) @@ -82,6 +117,23 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() { NotificationManagerCompat.from(context).notify(notificationIdForAlarm(alarmId), notification) } + private fun ensureFireChannel(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val existing = manager.getNotificationChannel(FIRE_CHANNEL_ID) + if (existing != null) return + + val channel = NotificationChannel( + FIRE_CHANNEL_ID, + "Alarmas sonando", + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "Pantalla urgente cuando una alarma musical debe sonar" + enableVibration(true) + } + manager.createNotificationChannel(channel) + } + private fun ensureChannel(context: Context) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager @@ -104,6 +156,7 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() { companion object { const val CHANNEL_ID = "pluriwave_alarm_pre_notice" + const val FIRE_CHANNEL_ID = "pluriwave_alarm_fire" const val ACTION_FIRE = "es.freetimelab.pluriwave.alarm.FIRE" const val ACTION_PRE_NOTICE = "es.freetimelab.pluriwave.alarm.PRE_NOTICE" const val ACTION_SKIP_NEXT = "es.freetimelab.pluriwave.alarm.SKIP_NEXT" @@ -112,5 +165,6 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() { const val EXTRA_ALARM_ACTION = "alarmAction" fun notificationIdForAlarm(alarmId: String): Int = 53 * alarmId.hashCode() + 7 + fun fireNotificationIdForAlarm(alarmId: String): Int = 59 * alarmId.hashCode() + 9 } } diff --git a/lib/app.dart b/lib/app.dart index d27ca58..dc81ae3 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -158,8 +158,9 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> { body: SafeArea(top: false, child: _paginas[_indice]), bottomNavigationBar: SafeArea( top: false, + minimum: const EdgeInsets.only(bottom: 8), child: Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 10), + padding: const EdgeInsets.fromLTRB(12, 0, 12, 0), child: Column( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/estado/estado_alarmas.dart b/lib/estado/estado_alarmas.dart index 9f5dbb4..fc6161f 100644 --- a/lib/estado/estado_alarmas.dart +++ b/lib/estado/estado_alarmas.dart @@ -64,7 +64,12 @@ class EstadoAlarmas extends ChangeNotifier { Future guardarAlarma(AlarmaMusical alarma) async { final config = await servicio.guardarAlarma(alarma); _aplicar(config); - await android.programar(_alarmas.firstWhere((a) => a.id == alarma.id)); + try { + await android.programar(_alarmas.firstWhere((a) => a.id == alarma.id)); + } catch (e) { + _error = + 'Alarma guardada, pero Android no pudo programarla todavía: $e'; + } notifyListeners(); } @@ -111,10 +116,12 @@ class EstadoAlarmas extends ChangeNotifier { Future posponerAlarma(AlarmaMusical alarma, int minutos) async { final proxima = DateTime.now().add(Duration(minutes: minutos)); + await android.ocultarNotificacionAlarma(alarma.id); await android.programar(alarma.copyWith(proximaEjecucion: proxima)); } Future finalizarEjecucion(String alarmaId) async { + await android.ocultarNotificacionAlarma(alarmaId); await refrescarProgramacion(); } diff --git a/lib/estado/estado_radio.dart b/lib/estado/estado_radio.dart index 68cbc2c..85a27aa 100644 --- a/lib/estado/estado_radio.dart +++ b/lib/estado/estado_radio.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; import 'package:geocoding/geocoding.dart'; import 'package:geolocator/geolocator.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../modelos/emisora.dart'; import '../modelos/preset_ecualizador.dart'; @@ -53,6 +54,7 @@ class EstadoRadio extends ChangeNotifier { Future? _initFuture; int _revisionReproduccion = 0; Emisora? _emisoraSeleccionada; + String? _emisoraPreferidaUuid; // Errores de reproducción → SnackBar. final _errorController = StreamController.broadcast(); @@ -84,6 +86,7 @@ class EstadoRadio extends ChangeNotifier { String? _ultimoIdiomaBusqueda; String? _ultimoTagBusqueda; String? _errorCarga; + static const _keyEmisoraPreferida = 'emisora_preferida_uuid_v1'; List get populares => _populares; List get tendencias => _tendencias; @@ -100,6 +103,8 @@ class EstadoRadio extends ChangeNotifier { String? get errorCercanas => _errorCercanas; String? get error => _errorCarga; Emisora? get emisoraActual => _emisoraSeleccionada ?? audio.emisoraActual; + Emisora? get emisoraPreferida => _resolverEmisoraPreferida(); + String? get emisoraPreferidaUuid => emisoraPreferida?.uuid; Stream get estadoStream => audio.estadoStream; PresetEcualizador get presetEcualizador => _presetActual; PresetEcualizador get presetPrincipalEcualizador => _presetPrincipal; @@ -134,6 +139,29 @@ class EstadoRadio extends ChangeNotifier { return mapa.values.toList(); } + List get emisorasDisponiblesPreferencia { + final mapa = {}; + for (final emisora in _listaFavoritos) { + mapa[emisora.uuid] = emisora; + } + for (final emisora in _emisorasCustom) { + mapa.putIfAbsent(emisora.uuid, () => emisora); + } + for (final emisora in _populares) { + mapa.putIfAbsent(emisora.uuid, () => emisora); + } + for (final emisora in _tendencias) { + mapa.putIfAbsent(emisora.uuid, () => emisora); + } + for (final emisora in _resultadosBusqueda) { + mapa.putIfAbsent(emisora.uuid, () => emisora); + } + for (final emisora in _emisorasCercanas) { + mapa.putIfAbsent(emisora.uuid, () => emisora); + } + return mapa.values.toList(); + } + Future inicializar() { _initFuture ??= _init(); return _initFuture!; @@ -142,11 +170,13 @@ class EstadoRadio extends ChangeNotifier { Future _init() async { await grabacion.inicializar(); await _cargarEcualizadorPersistido(); + await _cargarEmisoraPreferida(); await Future.wait([ cargarPopulares(), cargarFavoritos(), _cargarEmisorasCustom(), ]); + await _normalizarEmisoraPreferida(); } /// Escucha el stream de estado del audio y gestiona errores de reproducción. @@ -209,9 +239,61 @@ class EstadoRadio extends ChangeNotifier { Future cargarFavoritos() async { _listaFavoritos = await favoritos.obtenerTodos(); + await _normalizarEmisoraPreferida(); notifyListeners(); } + Future cambiarEmisoraPreferida(Emisora? emisora) async { + _emisoraPreferidaUuid = emisora?.uuid; + final prefs = await SharedPreferences.getInstance(); + if (_emisoraPreferidaUuid == null) { + await prefs.remove(_keyEmisoraPreferida); + } else { + await prefs.setString(_keyEmisoraPreferida, _emisoraPreferidaUuid!); + } + notifyListeners(); + } + + Future reproducirEmisoraPreferida() async { + final preferida = emisoraPreferida; + if (preferida == null) return; + await reproducir(preferida); + } + + Future _cargarEmisoraPreferida() async { + final prefs = await SharedPreferences.getInstance(); + _emisoraPreferidaUuid = prefs.getString(_keyEmisoraPreferida); + } + + Future _normalizarEmisoraPreferida() async { + final preferida = _resolverEmisoraPreferida(); + if (preferida?.uuid == _emisoraPreferidaUuid) return; + _emisoraPreferidaUuid = preferida?.uuid; + final prefs = await SharedPreferences.getInstance(); + if (_emisoraPreferidaUuid == null) { + await prefs.remove(_keyEmisoraPreferida); + } else { + await prefs.setString(_keyEmisoraPreferida, _emisoraPreferidaUuid!); + } + } + + Emisora? _resolverEmisoraPreferida() { + final uuid = _emisoraPreferidaUuid; + if (uuid != null) { + for (final emisora in _listaFavoritos) { + if (emisora.uuid == uuid) return emisora; + } + } + if (_listaFavoritos.isNotEmpty) return _listaFavoritos.first; + if (uuid != null) { + for (final emisora in emisorasDisponiblesPreferencia) { + if (emisora.uuid == uuid) return emisora; + } + } + final disponibles = emisorasDisponiblesPreferencia; + return disponibles.isEmpty ? null : disponibles.first; + } + static const int _tamanoPaginaBusqueda = 30; static const int _maxResultadosBusquedaEnMemoria = 180; diff --git a/lib/pantallas/pantalla_ajustes.dart b/lib/pantallas/pantalla_ajustes.dart index 15526cc..48737ab 100644 --- a/lib/pantallas/pantalla_ajustes.dart +++ b/lib/pantallas/pantalla_ajustes.dart @@ -54,6 +54,8 @@ class _AjustesContent extends StatelessWidget { SizedBox(height: 12), _SeccionGrabaciones(), SizedBox(height: 12), + _SeccionEmisoraPreferida(), + SizedBox(height: 12), _SeccionEmisoras(), SizedBox(height: 12), _SeccionBackup(), @@ -238,6 +240,107 @@ class _SeccionEcualizador extends StatelessWidget { } } +class _SeccionEmisoraPreferida extends StatelessWidget { + const _SeccionEmisoraPreferida(); + + @override + Widget build(BuildContext context) { + final estado = context.watch(); + final favoritas = estado.listaFavoritos; + final preferida = estado.emisoraPreferida; + final opciones = _opciones(estado, preferida); + + return PluriGlassSurface( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.radio_rounded), + const SizedBox(width: 12), + Text( + 'Emisora preferida', + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Se preselecciona al crear alarmas y puede iniciarse como reproducción rápida.', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 12), + if (opciones.isEmpty) + const ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon(Icons.info_outline_rounded), + title: Text('Todavía no hay emisoras disponibles'), + subtitle: Text( + 'Guardá favoritas o cargá emisoras para elegir una preferida.', + ), + ) + else + DropdownButtonFormField( + initialValue: preferida?.uuid, + decoration: InputDecoration( + labelText: + favoritas.isEmpty + ? 'Fallback automático' + : 'Favorita por defecto', + ), + items: [ + for (final emisora in opciones) + DropdownMenuItem( + value: emisora.uuid, + child: Text( + emisora.nombre, + overflow: TextOverflow.ellipsis, + ), + ), + ], + onChanged: (uuid) async { + final seleccion = opciones.firstWhere((e) => e.uuid == uuid); + await context.read().cambiarEmisoraPreferida( + seleccion, + ); + }, + ), + if (preferida != null) ...[ + const SizedBox(height: 8), + Text( + favoritas.any((e) => e.uuid == preferida.uuid) + ? 'Preferida actual: ${preferida.nombre}' + : 'Sin favoritas: usando automáticamente ${preferida.nombre}', + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: FilledButton.tonalIcon( + icon: const Icon(Icons.play_arrow_rounded), + label: const Text('Reproducir preferida'), + onPressed: + () => context.read().reproducirEmisoraPreferida(), + ), + ), + ], + ], + ), + ); + } + + List _opciones(EstadoRadio estado, Emisora? preferida) { + final base = + estado.listaFavoritos.isNotEmpty + ? estado.listaFavoritos + : estado.emisorasDisponiblesPreferencia; + final mapa = {for (final emisora in base) emisora.uuid: emisora}; + if (preferida != null) { + mapa[preferida.uuid] = preferida; + } + return mapa.values.toList(); + } +} + class _SeccionEmisoras extends StatelessWidget { const _SeccionEmisoras(); diff --git a/lib/pantallas/pantalla_alarmas.dart b/lib/pantallas/pantalla_alarmas.dart index 09bddfc..66abd67 100644 --- a/lib/pantallas/pantalla_alarmas.dart +++ b/lib/pantallas/pantalla_alarmas.dart @@ -294,6 +294,7 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { late bool _sonarEnVacaciones; late SonidoInternoAlarma _sonidoInterno; Emisora? _emisora; + bool _favoritosSolicitados = false; @override void initState() { @@ -311,7 +312,7 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { _volumen = alarma?.volumen ?? 0.85; _sonarEnVacaciones = alarma?.sonarEnVacaciones ?? true; _sonidoInterno = alarma?.sonidoInterno ?? SonidoInternoAlarma.amanecer; - _emisora = alarma?.emisora; + _emisora = alarma?.emisora ?? context.read().emisoraPreferida; } @override @@ -324,6 +325,20 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { Widget build(BuildContext context) { final radio = context.watch(); final bottom = MediaQuery.of(context).viewInsets.bottom; + if (!_favoritosSolicitados) { + _favoritosSolicitados = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) context.read().cargarFavoritos(); + }); + } + if (_emisora == null && widget.alarma == null && radio.emisoraPreferida != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _emisora == null) { + setState(() => _emisora = radio.emisoraPreferida); + } + }); + } + final favoritas = _favoritasConSeleccion(radio.listaFavoritos); return Padding( padding: EdgeInsets.fromLTRB(12, 12, 12, bottom + 12), child: PluriGlassSurface( @@ -437,22 +452,52 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { onChanged: (value) => setState(() => _sonidoInterno = value ?? _sonidoInterno), ), const SizedBox(height: 8), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.radio_rounded), - title: Text(_emisora?.nombre ?? 'Sin emisora principal'), - subtitle: Text( - radio.emisoraActual == null - ? 'Se usará el sonido interno si la radio falla.' - : 'Podés usar la emisora que está seleccionada ahora.', - ), - trailing: FilledButton.tonal( - onPressed: radio.emisoraActual == null - ? null - : () => setState(() => _emisora = radio.emisoraActual), - child: const Text('Usar actual'), + DropdownButtonFormField( + key: ValueKey(_emisora?.uuid ?? 'sin-emisora'), + initialValue: _emisora?.uuid, + decoration: const InputDecoration( + labelText: 'Emisora favorita', + prefixIcon: Icon(Icons.radio_rounded), ), + items: [ + const DropdownMenuItem( + value: '', + child: Text('Sin emisora: usar sonido interno'), + ), + for (final emisora in favoritas) + DropdownMenuItem( + value: emisora.uuid, + child: Text( + emisora.nombre, + overflow: TextOverflow.ellipsis, + ), + ), + ], + onChanged: (uuid) => setState(() { + if (uuid == null || uuid.isEmpty) { + _emisora = null; + return; + } + _emisora = favoritas.firstWhere((e) => e.uuid == uuid); + }), ), + if (favoritas.isEmpty) ...[ + const SizedBox(height: 6), + const Text( + 'Guardá emisoras en Favoritos para usarlas como alarma musical.', + ), + ], + if (radio.emisoraActual != null) ...[ + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: FilledButton.tonalIcon( + onPressed: () => setState(() => _emisora = radio.emisoraActual), + icon: const Icon(Icons.add_task_rounded), + label: const Text('Usar emisora actual'), + ), + ), + ], const SizedBox(height: 8), SwitchListTile.adaptive( contentPadding: EdgeInsets.zero, @@ -531,6 +576,18 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { await estado.guardarAlarma(alarma); if (mounted) Navigator.pop(context); } + + List _favoritasConSeleccion(List favoritas) { + final mapa = {}; + for (final emisora in favoritas) { + mapa[emisora.uuid] = emisora; + } + final seleccionada = _emisora; + if (seleccionada != null) { + mapa[seleccionada.uuid] = seleccionada; + } + return mapa.values.toList(); + } } class _AccesoDiagnostico extends StatelessWidget { diff --git a/lib/servicios/servicio_alarmas_android.dart b/lib/servicios/servicio_alarmas_android.dart index 68739e5..33611dc 100644 --- a/lib/servicios/servicio_alarmas_android.dart +++ b/lib/servicios/servicio_alarmas_android.dart @@ -79,6 +79,9 @@ class ServicioAlarmasAndroid { Future cancelar(String alarmaId) => _channel.invokeMethod('cancelAlarm', {'id': alarmaId}); + Future ocultarNotificacionAlarma(String alarmaId) => _channel + .invokeMethod('dismissAlarmNotification', {'id': alarmaId}); + Future diagnostico() async { final raw = await _channel.invokeMethod>( 'diagnostics', diff --git a/lib/widgets/pluri_wave_scaffold.dart b/lib/widgets/pluri_wave_scaffold.dart index a6523be..35dee58 100644 --- a/lib/widgets/pluri_wave_scaffold.dart +++ b/lib/widgets/pluri_wave_scaffold.dart @@ -24,7 +24,7 @@ class PluriWaveScaffold extends StatelessWidget { appBar: appBar, bottomNavigationBar: bottomNavigationBar, floatingActionButton: floatingActionButton, - extendBody: true, + extendBody: false, body: DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient(