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