feat(alarms): native reliability fixes and end-to-end snooze

- Use mediaPlayback|systemExempted FGS type with FOREGROUND_SERVICE_SYSTEM_EXEMPTED so alarms fire on Android 14+ (FOREGROUND_SERVICE_ALARM does not exist in the SDK)
- Deduplicate fire notifications: the foreground service FSI notification is the single owner; receiver path removed
- Notification channel v2 with alarm sound URI and USAGE_ALARM attributes, one-time guarded migration from legacy channels
- Pass fallback station through the MethodChannel (NativeAlarmSpec schemaVersion 3) with a three-stage audio chain: primary -> fallback station -> bundled WAV
- Native fade-in volume ramp honoring fadeInSegundos when the app is killed
- Request battery-optimization exemption once, tracked with a persisted asked-once flag
- Fix snooze end-to-end: native ACTION_SNOOZE now reports back to Flutter (snoozed event + cold-start sync), snooze anchor unified to occurrence+minutes on both sides, periodic recalc no longer erases an active snooze
- Add snooze buttons (3/5/10/custom) to the ringing screen with shared audio teardown
- Redesign ringing screen on PluriWaveScaffold with reduced-motion-aware entry animation (new PluriAnimate helper)
- Alarm editor: live next-trigger preview, searchable station pickers (primary and fallback), configurable snooze duration, volume floor down to 0
- New alarm strings localized across all 13 locales
- New unit/widget tests for the snooze flow, alarm bridge payloads, ringing screen and editor (77 tests green)
- SDD artifacts for the app-quality-and-native-alarms change (explore, proposal, spec, design, tasks, apply progress)
This commit is contained in:
2026-06-11 15:33:30 +02:00
parent b5acf97ba4
commit f3e9487215
57 changed files with 4902 additions and 509 deletions
+7 -7
View File
@@ -211,6 +211,11 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
}
Future<void> _abrirAlarmaSonando(EventoAlarmaAndroid evento) async {
if (evento.accion == EventoAlarmaAndroid.accionSnoozed) {
// EstadoAlarmas records native snoozes itself (Decision 2.1); there is
// nothing to open for this event.
return;
}
final estado = context.read<EstadoAlarmas>();
if (estado.alarmas.isEmpty) {
await estado.cargarPersistidasSinRecalcular();
@@ -235,9 +240,7 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(
context,
).skipCurrentAlarmExecution(
AppLocalizations.of(context).skipCurrentAlarmExecution(
localizedAlarmName(AppLocalizations.of(context), alarma.nombre),
),
),
@@ -438,10 +441,7 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
}
}
String _formatearDuracionTimer(
AppLocalizations l10n,
Duration duracion,
) {
String _formatearDuracionTimer(AppLocalizations l10n, Duration duracion) {
final horas = duracion.inHours;
final minutos = duracion.inMinutes.remainder(60);
final segundos = duracion.inSeconds.remainder(60);
+101 -10
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../modelos/alarma_musical.dart';
import '../servicios/servicio_alarmas.dart';
@@ -10,9 +11,17 @@ class EstadoAlarmas extends ChangeNotifier {
EstadoAlarmas({
ServicioAlarmas? servicio,
PuertoAlarmasAndroid? android,
SharedPreferences? prefs,
bool iniciarAutomaticamente = true,
}) : servicio = servicio ?? ServicioAlarmas(),
android = android ?? ServicioAlarmasAndroid() {
android = android ?? ServicioAlarmasAndroid(),
_prefs = prefs {
// Decision 2.1 (snooze sync): the native layer reports its own snoozes
// back through alarmFired/snoozed; record them here so the Flutter
// config stays the single source of truth.
_eventosNativosSub = this.android.eventosAlarma.listen(
_alRecibirEventoNativo,
);
if (iniciarAutomaticamente) {
inicializar();
}
@@ -20,6 +29,8 @@ class EstadoAlarmas extends ChangeNotifier {
final ServicioAlarmas servicio;
final PuertoAlarmasAndroid android;
final SharedPreferences? _prefs;
static const _keyExencionBateriaSolicitada = 'bateria_exencion_solicitada';
List<AlarmaMusical> _alarmas = [];
List<RangoVacaciones> _vacaciones = [];
@@ -27,6 +38,7 @@ class EstadoAlarmas extends ChangeNotifier {
DiagnosticoAlarmasAndroid? _diagnostico;
Timer? _refresco;
Timer? _vigilancia;
StreamSubscription<EventoAlarmaAndroid>? _eventosNativosSub;
final _alarmasVencidasController =
StreamController<AlarmaMusical>.broadcast();
final Set<String> _ejecucionesEmitidas = {};
@@ -248,21 +260,89 @@ class EstadoAlarmas extends ChangeNotifier {
notifyListeners();
}
/// Records a snooze the native layer performed by itself (Decision 2.1).
/// The native scheduler already re-registered setAlarmClock, so this only
/// persists the canonical state — it MUST NOT call android.programar again.
Future<void> _alRecibirEventoNativo(EventoAlarmaAndroid evento) async {
if (evento.accion != EventoAlarmaAndroid.accionSnoozed) return;
if (evento.alarmaId.isEmpty || evento.snoozeUntilMillis <= 0) return;
final hasta = DateTime.fromMillisecondsSinceEpoch(evento.snoozeUntilMillis);
final origen =
evento.occurrenceAtMillis > 0
? DateTime.fromMillisecondsSinceEpoch(evento.occurrenceAtMillis)
: hasta.subtract(Duration(minutes: evento.snoozeMinutes));
debugPrint(
'[PluriWave][alarmas] snooze nativo id=${evento.alarmaId} hasta=${hasta.toIso8601String()}',
);
try {
final config = await servicio.posponerEjecucionHasta(
evento.alarmaId,
origen,
hasta,
);
_aplicar(config);
notifyListeners();
} catch (e) {
debugPrint('[PluriWave][alarmas] snooze nativo ERROR $e');
}
}
Future<void> _sincronizarEjecucionesGestionadasPorAndroid() async {
try {
final ejecuciones = await android.obtenerEjecucionesNativasGestionadas();
if (ejecuciones.isEmpty) return;
final config = await servicio.sincronizarEjecucionesNativas({
for (final ejecucion in ejecuciones)
ejecucion.alarmaId: ejecucion.gestionadaEn,
});
_aplicar(config);
debugPrint(
'[PluriWave][alarmas] sincronizadas ejecuciones nativas count=${ejecuciones.length}',
);
if (ejecuciones.isNotEmpty) {
final config = await servicio.sincronizarEjecucionesNativas({
for (final ejecucion in ejecuciones)
ejecucion.alarmaId: ejecucion.gestionadaEn,
});
_aplicar(config);
debugPrint(
'[PluriWave][alarmas] sincronizadas ejecuciones nativas count=${ejecuciones.length}',
);
}
} catch (e) {
debugPrint('[PluriWave][alarmas] sincronizar nativas ERROR $e');
}
await _importarSnoozesNativosActivos();
}
/// Cold-start half of Decision 2.1: imports snoozes the native scheduler
/// performed while the Flutter engine was dead, before any recalculation
/// could erase them.
Future<void> _importarSnoozesNativosActivos() async {
try {
final snoozes = await android.obtenerEstadoSnoozeNativo();
if (snoozes.isEmpty) return;
final ahora = DateTime.now();
var config = await servicio.cargar();
var huboCambios = false;
for (final snooze in snoozes) {
if (!snooze.snoozeHasta.isAfter(ahora)) continue;
AlarmaMusical? alarma;
for (final candidata in config.alarmas) {
if (candidata.id == snooze.alarmaId) {
alarma = candidata;
break;
}
}
if (alarma == null || !alarma.activa) continue;
if (alarma.snoozeHasta == snooze.snoozeHasta) continue;
config = await servicio.posponerEjecucionHasta(
snooze.alarmaId,
snooze.snoozeOrigen,
snooze.snoozeHasta,
);
huboCambios = true;
}
if (huboCambios) {
_aplicar(config);
debugPrint(
'[PluriWave][alarmas] snoozes nativos importados count=${snoozes.length}',
);
}
} catch (e) {
debugPrint('[PluriWave][alarmas] importar snoozes nativos ERROR $e');
}
}
Future<void> _solicitarPermisosNecesariosParaAlarma() async {
@@ -278,11 +358,21 @@ class EstadoAlarmas extends ChangeNotifier {
if (!diag.puedeUsarPantallaCompleta) {
await android.solicitarPermisoPantallaCompleta();
}
if (!diag.ignoraOptimizacionBateria) {
await _solicitarExencionBateriaUnaVez();
}
} catch (e) {
debugPrint('[PluriWave][alarmas] permisos android ERROR $e');
}
}
Future<void> _solicitarExencionBateriaUnaVez() async {
final prefs = _prefs ?? await SharedPreferences.getInstance();
if (prefs.getBool(_keyExencionBateriaSolicitada) ?? false) return;
await android.solicitarExencionBateria();
await prefs.setBool(_keyExencionBateriaSolicitada, true);
}
Future<void> _sincronizarTodas() async {
debugPrint(
'[PluriWave][alarmas] sincronizar todas count=${_alarmas.length}',
@@ -351,6 +441,7 @@ class EstadoAlarmas extends ChangeNotifier {
void dispose() {
_refresco?.cancel();
_vigilancia?.cancel();
_eventosNativosSub?.cancel();
_alarmasVencidasController.close();
super.dispose();
}
+12
View File
@@ -422,6 +422,18 @@
"soundDigitalPulse": "نبضة رقمية",
"favoriteStationLabel": "المحطة المفضلة",
"noStationUseInternalSound": "بدون محطة: استخدام الصوت الداخلي",
"alarmFallbackStationLabel": "محطة احتياطية",
"alarmStationPickerSearchHint": "ابحث عن محطة بالاسم",
"alarmSnoozeDurationTitle": "مدة الغفوة",
"snoozeAction": "غفوة",
"alarmSnoozeOptionLabel": "{minutes} د",
"@alarmSnoozeOptionLabel": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"saveFavoritesAlarmHint": "احفظ محطات في المفضلة لاستخدامها كمنبه موسيقي.",
"useCurrentStationAction": "استخدام المحطة الحالية",
"playDuringVacations": "الرنين أثناء الإجازات",
+12
View File
@@ -422,6 +422,18 @@
"soundDigitalPulse": "ডিজিটাল পালস",
"favoriteStationLabel": "প্রিয় স্টেশন",
"noStationUseInternalSound": "স্টেশন নেই: অভ্যন্তরীণ শব্দ ব্যবহার করুন",
"alarmFallbackStationLabel": "ব্যাকআপ স্টেশন",
"alarmStationPickerSearchHint": "নাম দিয়ে স্টেশন খুঁজুন",
"alarmSnoozeDurationTitle": "স্নুজ সময়কাল",
"snoozeAction": "স্নুজ",
"alarmSnoozeOptionLabel": "{minutes} মিনিট",
"@alarmSnoozeOptionLabel": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"saveFavoritesAlarmHint": "সুরেলা অ্যালার্ম হিসেবে ব্যবহার করতে প্রিয়তে স্টেশন সংরক্ষণ করুন।",
"useCurrentStationAction": "বর্তমান স্টেশন ব্যবহার করুন",
"playDuringVacations": "ছুটিতে বাজান",
+12
View File
@@ -422,6 +422,18 @@
"soundDigitalPulse": "Digitaler Puls",
"favoriteStationLabel": "Lieblingssender",
"noStationUseInternalSound": "Kein Sender: internen Ton verwenden",
"alarmFallbackStationLabel": "Ersatzsender",
"alarmStationPickerSearchHint": "Sender nach Name suchen",
"alarmSnoozeDurationTitle": "Schlummerdauer",
"snoozeAction": "Schlummern",
"alarmSnoozeOptionLabel": "{minutes} Min.",
"@alarmSnoozeOptionLabel": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"saveFavoritesAlarmHint": "Speichere Sender in Favoriten, um sie als musikalischen Alarm zu verwenden.",
"useCurrentStationAction": "Aktuellen Sender verwenden",
"playDuringVacations": "Während der Ferien läuten",
+12
View File
@@ -422,6 +422,18 @@
"soundDigitalPulse": "Digital pulse",
"favoriteStationLabel": "Favorite station",
"noStationUseInternalSound": "No station: use internal sound",
"alarmFallbackStationLabel": "Backup station",
"alarmStationPickerSearchHint": "Search for a station by name",
"alarmSnoozeDurationTitle": "Snooze duration",
"snoozeAction": "Snooze",
"alarmSnoozeOptionLabel": "{minutes} min",
"@alarmSnoozeOptionLabel": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"saveFavoritesAlarmHint": "Save stations in Favorites to use them as a music alarm.",
"useCurrentStationAction": "Use current station",
"playDuringVacations": "Play during vacations",
+12
View File
@@ -422,6 +422,18 @@
"soundDigitalPulse": "Pulso digital",
"favoriteStationLabel": "Emisora favorita",
"noStationUseInternalSound": "Sin emisora: usar sonido interno",
"alarmFallbackStationLabel": "Emisora de respaldo",
"alarmStationPickerSearchHint": "Buscá una emisora por nombre",
"alarmSnoozeDurationTitle": "Duración de la posposición",
"snoozeAction": "Posponer",
"alarmSnoozeOptionLabel": "{minutes} min",
"@alarmSnoozeOptionLabel": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"saveFavoritesAlarmHint": "Guardá emisoras en Favoritos para usarlas como alarma musical.",
"useCurrentStationAction": "Usar emisora actual",
"playDuringVacations": "Sonar durante vacaciones",
+12
View File
@@ -422,6 +422,18 @@
"soundDigitalPulse": "Impulsion numérique",
"favoriteStationLabel": "Station favorite",
"noStationUseInternalSound": "Aucune station : utiliser le son interne",
"alarmFallbackStationLabel": "Station de secours",
"alarmStationPickerSearchHint": "Rechercher une station par nom",
"alarmSnoozeDurationTitle": "Durée de répétition",
"snoozeAction": "Répéter",
"alarmSnoozeOptionLabel": "{minutes} min",
"@alarmSnoozeOptionLabel": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"saveFavoritesAlarmHint": "Enregistrez des stations dans les Favoris pour les utiliser comme alarme musicale.",
"useCurrentStationAction": "Utiliser la station actuelle",
"playDuringVacations": "Sonner pendant les vacances",
+12
View File
@@ -422,6 +422,18 @@
"soundDigitalPulse": "डिजिटल धड़कन",
"favoriteStationLabel": "पसंदीदा स्टेशन",
"noStationUseInternalSound": "कोई स्टेशन नहीं: आंतरिक ध्वनि इस्तेमाल करें",
"alarmFallbackStationLabel": "बैकअप स्टेशन",
"alarmStationPickerSearchHint": "नाम से स्टेशन खोजें",
"alarmSnoozeDurationTitle": "स्नूज़ अवधि",
"snoozeAction": "स्नूज़",
"alarmSnoozeOptionLabel": "{minutes} मिनट",
"@alarmSnoozeOptionLabel": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"saveFavoritesAlarmHint": "उन्हें संगीतमय अलार्म के रूप में इस्तेमाल करने के लिए स्टेशन पसंदीदा में सहेजें।",
"useCurrentStationAction": "वर्तमान स्टेशन इस्तेमाल करें",
"playDuringVacations": "छुट्टियों में बजाएँ",
+12
View File
@@ -422,6 +422,18 @@
"soundDigitalPulse": "Denyut digital",
"favoriteStationLabel": "Stasiun favorit",
"noStationUseInternalSound": "Tanpa stasiun: gunakan suara internal",
"alarmFallbackStationLabel": "Stasiun cadangan",
"alarmStationPickerSearchHint": "Cari stasiun berdasarkan nama",
"alarmSnoozeDurationTitle": "Durasi tunda",
"snoozeAction": "Tunda",
"alarmSnoozeOptionLabel": "{minutes} mnt",
"@alarmSnoozeOptionLabel": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"saveFavoritesAlarmHint": "Simpan stasiun ke Favorit untuk digunakan sebagai alarm musik.",
"useCurrentStationAction": "Gunakan stasiun saat ini",
"playDuringVacations": "Bunyi saat liburan",
+12
View File
@@ -422,6 +422,18 @@
"soundDigitalPulse": "Impulso digitale",
"favoriteStationLabel": "Emittente preferita",
"noStationUseInternalSound": "Nessuna emittente: usa suono interno",
"alarmFallbackStationLabel": "Emittente di riserva",
"alarmStationPickerSearchHint": "Cerca un'emittente per nome",
"alarmSnoozeDurationTitle": "Durata posticipo",
"snoozeAction": "Posticipa",
"alarmSnoozeOptionLabel": "{minutes} min",
"@alarmSnoozeOptionLabel": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"saveFavoritesAlarmHint": "Salva emittenti nei Preferiti per usarle come sveglia musicale.",
"useCurrentStationAction": "Usa emittente attuale",
"playDuringVacations": "Suona durante le vacanze",
+12
View File
@@ -422,6 +422,18 @@
"soundDigitalPulse": "デジタルパルス",
"favoriteStationLabel": "お気に入り局",
"noStationUseInternalSound": "局なし: 内部音を使用",
"alarmFallbackStationLabel": "予備の局",
"alarmStationPickerSearchHint": "局名で検索",
"alarmSnoozeDurationTitle": "スヌーズ時間",
"snoozeAction": "スヌーズ",
"alarmSnoozeOptionLabel": "{minutes}分",
"@alarmSnoozeOptionLabel": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"saveFavoritesAlarmHint": "音楽アラームとして使うには、局をお気に入りに保存してください。",
"useCurrentStationAction": "現在の局を使用",
"playDuringVacations": "休暇中も鳴らす",
+12
View File
@@ -422,6 +422,18 @@
"soundDigitalPulse": "Pulso digital",
"favoriteStationLabel": "Estação favorita",
"noStationUseInternalSound": "Sem estação: usar som interno",
"alarmFallbackStationLabel": "Estação reserva",
"alarmStationPickerSearchHint": "Buscar estação pelo nome",
"alarmSnoozeDurationTitle": "Duração da soneca",
"snoozeAction": "Soneca",
"alarmSnoozeOptionLabel": "{minutes} min",
"@alarmSnoozeOptionLabel": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"saveFavoritesAlarmHint": "Salve estações nos Favoritos para usá-las como alarme musical.",
"useCurrentStationAction": "Usar estação atual",
"playDuringVacations": "Tocar durante as férias",
+12
View File
@@ -422,6 +422,18 @@
"soundDigitalPulse": "Цифровой импульс",
"favoriteStationLabel": "Избранная станция",
"noStationUseInternalSound": "Без станции: использовать внутренний звук",
"alarmFallbackStationLabel": "Резервная станция",
"alarmStationPickerSearchHint": "Поиск станции по названию",
"alarmSnoozeDurationTitle": "Интервал повтора",
"snoozeAction": "Отложить",
"alarmSnoozeOptionLabel": "{minutes} мин",
"@alarmSnoozeOptionLabel": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"saveFavoritesAlarmHint": "Сохраните станции в избранное, чтобы использовать их как музыкальный будильник.",
"useCurrentStationAction": "Использовать текущую станцию",
"playDuringVacations": "Звонить во время отпусков",
+12
View File
@@ -422,6 +422,18 @@
"soundDigitalPulse": "数字脉冲",
"favoriteStationLabel": "收藏电台",
"noStationUseInternalSound": "无电台:使用内部声音",
"alarmFallbackStationLabel": "备用电台",
"alarmStationPickerSearchHint": "按名称搜索电台",
"alarmSnoozeDurationTitle": "贪睡时长",
"snoozeAction": "贪睡",
"alarmSnoozeOptionLabel": "{minutes} 分钟",
"@alarmSnoozeOptionLabel": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"saveFavoritesAlarmHint": "将电台保存到收藏,即可把它们用作音乐闹钟。",
"useCurrentStationAction": "使用当前电台",
"playDuringVacations": "假期期间响铃",
+30
View File
@@ -1510,6 +1510,36 @@ abstract class AppLocalizations {
/// **'Sin emisora: usar sonido interno'**
String get noStationUseInternalSound;
/// No description provided for @alarmFallbackStationLabel.
///
/// In es, this message translates to:
/// **'Emisora de respaldo'**
String get alarmFallbackStationLabel;
/// No description provided for @alarmStationPickerSearchHint.
///
/// In es, this message translates to:
/// **'Buscá una emisora por nombre'**
String get alarmStationPickerSearchHint;
/// No description provided for @alarmSnoozeDurationTitle.
///
/// In es, this message translates to:
/// **'Duración de la posposición'**
String get alarmSnoozeDurationTitle;
/// No description provided for @snoozeAction.
///
/// In es, this message translates to:
/// **'Posponer'**
String get snoozeAction;
/// No description provided for @alarmSnoozeOptionLabel.
///
/// In es, this message translates to:
/// **'{minutes} min'**
String alarmSnoozeOptionLabel(int minutes);
/// No description provided for @saveFavoritesAlarmHint.
///
/// In es, this message translates to:
+17
View File
@@ -798,6 +798,23 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get noStationUseInternalSound => 'بدون محطة: استخدام الصوت الداخلي';
@override
String get alarmFallbackStationLabel => 'محطة احتياطية';
@override
String get alarmStationPickerSearchHint => 'ابحث عن محطة بالاسم';
@override
String get alarmSnoozeDurationTitle => 'مدة الغفوة';
@override
String get snoozeAction => 'غفوة';
@override
String alarmSnoozeOptionLabel(int minutes) {
return '$minutes د';
}
@override
String get saveFavoritesAlarmHint =>
'احفظ محطات في المفضلة لاستخدامها كمنبه موسيقي.';
+17
View File
@@ -804,6 +804,23 @@ class AppLocalizationsBn extends AppLocalizations {
String get noStationUseInternalSound =>
'স্টেশন নেই: অভ্যন্তরীণ শব্দ ব্যবহার করুন';
@override
String get alarmFallbackStationLabel => 'ব্যাকআপ স্টেশন';
@override
String get alarmStationPickerSearchHint => 'নাম দিয়ে স্টেশন খুঁজুন';
@override
String get alarmSnoozeDurationTitle => 'স্নুজ সময়কাল';
@override
String get snoozeAction => 'স্নুজ';
@override
String alarmSnoozeOptionLabel(int minutes) {
return '$minutes মিনিট';
}
@override
String get saveFavoritesAlarmHint =>
'সুরেলা অ্যালার্ম হিসেবে ব্যবহার করতে প্রিয়তে স্টেশন সংরক্ষণ করুন।';
+17
View File
@@ -806,6 +806,23 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get noStationUseInternalSound => 'Kein Sender: internen Ton verwenden';
@override
String get alarmFallbackStationLabel => 'Ersatzsender';
@override
String get alarmStationPickerSearchHint => 'Sender nach Name suchen';
@override
String get alarmSnoozeDurationTitle => 'Schlummerdauer';
@override
String get snoozeAction => 'Schlummern';
@override
String alarmSnoozeOptionLabel(int minutes) {
return '$minutes Min.';
}
@override
String get saveFavoritesAlarmHint =>
'Speichere Sender in Favoriten, um sie als musikalischen Alarm zu verwenden.';
+17
View File
@@ -801,6 +801,23 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get noStationUseInternalSound => 'No station: use internal sound';
@override
String get alarmFallbackStationLabel => 'Backup station';
@override
String get alarmStationPickerSearchHint => 'Search for a station by name';
@override
String get alarmSnoozeDurationTitle => 'Snooze duration';
@override
String get snoozeAction => 'Snooze';
@override
String alarmSnoozeOptionLabel(int minutes) {
return '$minutes min';
}
@override
String get saveFavoritesAlarmHint =>
'Save stations in Favorites to use them as a music alarm.';
+17
View File
@@ -804,6 +804,23 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get noStationUseInternalSound => 'Sin emisora: usar sonido interno';
@override
String get alarmFallbackStationLabel => 'Emisora de respaldo';
@override
String get alarmStationPickerSearchHint => 'Buscá una emisora por nombre';
@override
String get alarmSnoozeDurationTitle => 'Duración de la posposición';
@override
String get snoozeAction => 'Posponer';
@override
String alarmSnoozeOptionLabel(int minutes) {
return '$minutes min';
}
@override
String get saveFavoritesAlarmHint =>
'Guardá emisoras en Favoritos para usarlas como alarma musical.';
+17
View File
@@ -809,6 +809,23 @@ class AppLocalizationsFr extends AppLocalizations {
String get noStationUseInternalSound =>
'Aucune station : utiliser le son interne';
@override
String get alarmFallbackStationLabel => 'Station de secours';
@override
String get alarmStationPickerSearchHint => 'Rechercher une station par nom';
@override
String get alarmSnoozeDurationTitle => 'Durée de répétition';
@override
String get snoozeAction => 'Répéter';
@override
String alarmSnoozeOptionLabel(int minutes) {
return '$minutes min';
}
@override
String get saveFavoritesAlarmHint =>
'Enregistrez des stations dans les Favoris pour les utiliser comme alarme musicale.';
+17
View File
@@ -801,6 +801,23 @@ class AppLocalizationsHi extends AppLocalizations {
String get noStationUseInternalSound =>
'कोई स्टेशन नहीं: आंतरिक ध्वनि इस्तेमाल करें';
@override
String get alarmFallbackStationLabel => 'बैकअप स्टेशन';
@override
String get alarmStationPickerSearchHint => 'नाम से स्टेशन खोजें';
@override
String get alarmSnoozeDurationTitle => 'स्नूज़ अवधि';
@override
String get snoozeAction => 'स्नूज़';
@override
String alarmSnoozeOptionLabel(int minutes) {
return '$minutes मिनट';
}
@override
String get saveFavoritesAlarmHint =>
'उन्हें संगीतमय अलार्म के रूप में इस्तेमाल करने के लिए स्टेशन पसंदीदा में सहेजें।';
+17
View File
@@ -804,6 +804,23 @@ class AppLocalizationsId extends AppLocalizations {
String get noStationUseInternalSound =>
'Tanpa stasiun: gunakan suara internal';
@override
String get alarmFallbackStationLabel => 'Stasiun cadangan';
@override
String get alarmStationPickerSearchHint => 'Cari stasiun berdasarkan nama';
@override
String get alarmSnoozeDurationTitle => 'Durasi tunda';
@override
String get snoozeAction => 'Tunda';
@override
String alarmSnoozeOptionLabel(int minutes) {
return '$minutes mnt';
}
@override
String get saveFavoritesAlarmHint =>
'Simpan stasiun ke Favorit untuk digunakan sebagai alarm musik.';
+17
View File
@@ -805,6 +805,23 @@ class AppLocalizationsIt extends AppLocalizations {
String get noStationUseInternalSound =>
'Nessuna emittente: usa suono interno';
@override
String get alarmFallbackStationLabel => 'Emittente di riserva';
@override
String get alarmStationPickerSearchHint => 'Cerca un\'emittente per nome';
@override
String get alarmSnoozeDurationTitle => 'Durata posticipo';
@override
String get snoozeAction => 'Posticipa';
@override
String alarmSnoozeOptionLabel(int minutes) {
return '$minutes min';
}
@override
String get saveFavoritesAlarmHint =>
'Salva emittenti nei Preferiti per usarle come sveglia musicale.';
+17
View File
@@ -777,6 +777,23 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get noStationUseInternalSound => '局なし: 内部音を使用';
@override
String get alarmFallbackStationLabel => '予備の局';
@override
String get alarmStationPickerSearchHint => '局名で検索';
@override
String get alarmSnoozeDurationTitle => 'スヌーズ時間';
@override
String get snoozeAction => 'スヌーズ';
@override
String alarmSnoozeOptionLabel(int minutes) {
return '$minutes分';
}
@override
String get saveFavoritesAlarmHint => '音楽アラームとして使うには、局をお気に入りに保存してください。';
+17
View File
@@ -803,6 +803,23 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get noStationUseInternalSound => 'Sem estação: usar som interno';
@override
String get alarmFallbackStationLabel => 'Estação reserva';
@override
String get alarmStationPickerSearchHint => 'Buscar estação pelo nome';
@override
String get alarmSnoozeDurationTitle => 'Duração da soneca';
@override
String get snoozeAction => 'Soneca';
@override
String alarmSnoozeOptionLabel(int minutes) {
return '$minutes min';
}
@override
String get saveFavoritesAlarmHint =>
'Salve estações nos Favoritos para usá-las como alarme musical.';
+17
View File
@@ -805,6 +805,23 @@ class AppLocalizationsRu extends AppLocalizations {
String get noStationUseInternalSound =>
'Без станции: использовать внутренний звук';
@override
String get alarmFallbackStationLabel => 'Резервная станция';
@override
String get alarmStationPickerSearchHint => 'Поиск станции по названию';
@override
String get alarmSnoozeDurationTitle => 'Интервал повтора';
@override
String get snoozeAction => 'Отложить';
@override
String alarmSnoozeOptionLabel(int minutes) {
return '$minutes мин';
}
@override
String get saveFavoritesAlarmHint =>
'Сохраните станции в избранное, чтобы использовать их как музыкальный будильник.';
+17
View File
@@ -773,6 +773,23 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get noStationUseInternalSound => '无电台:使用内部声音';
@override
String get alarmFallbackStationLabel => '备用电台';
@override
String get alarmStationPickerSearchHint => '按名称搜索电台';
@override
String get alarmSnoozeDurationTitle => '贪睡时长';
@override
String get snoozeAction => '贪睡';
@override
String alarmSnoozeOptionLabel(int minutes) {
return '$minutes 分钟';
}
@override
String get saveFavoritesAlarmHint => '将电台保存到收藏,即可把它们用作音乐闹钟。';
+9 -3
View File
@@ -62,7 +62,9 @@ class AlarmaMusical {
DateTime? fechaUnica,
bool limpiarFechaUnica = false,
Emisora? emisora,
bool limpiarEmisora = false,
Emisora? emisoraFallback,
bool limpiarEmisoraFallback = false,
bool? sonarEnVacaciones,
int? snoozeMinutos,
double? volumen,
@@ -87,8 +89,11 @@ class AlarmaMusical {
tipoProgramacion: tipoProgramacion ?? this.tipoProgramacion,
diasSemana: diasSemana ?? this.diasSemana,
fechaUnica: limpiarFechaUnica ? null : fechaUnica ?? this.fechaUnica,
emisora: emisora ?? this.emisora,
emisoraFallback: emisoraFallback ?? this.emisoraFallback,
emisora: limpiarEmisora ? emisora : emisora ?? this.emisora,
emisoraFallback:
limpiarEmisoraFallback
? emisoraFallback
: emisoraFallback ?? this.emisoraFallback,
sonarEnVacaciones: sonarEnVacaciones ?? this.sonarEnVacaciones,
snoozeMinutos: snoozeMinutos ?? this.snoozeMinutos,
volumen: volumen ?? this.volumen,
@@ -98,7 +103,8 @@ class AlarmaMusical {
limpiarProximaEjecucion
? proximaEjecucion
: proximaEjecucion ?? this.proximaEjecucion,
snoozeHasta: limpiarSnooze ? snoozeHasta : snoozeHasta ?? this.snoozeHasta,
snoozeHasta:
limpiarSnooze ? snoozeHasta : snoozeHasta ?? this.snoozeHasta,
snoozeOrigen:
limpiarSnooze ? snoozeOrigen : snoozeOrigen ?? this.snoozeOrigen,
ultimaEjecucionGestionada:
+65 -8
View File
@@ -10,7 +10,10 @@ import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/alarma_musical.dart';
import '../servicios/servicio_audio.dart';
import '../tema/pluri_animate.dart';
import '../tema/pluriwave_theme.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_wave_scaffold.dart';
class PantallaAlarmaSonando extends StatefulWidget {
const PantallaAlarmaSonando({
@@ -129,19 +132,49 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
);
}
/// Shared local-audio teardown for stop and snooze (Design 2.3): the Dart
/// fallback player and fade timer MUST die before the alarm is re-programmed
/// natively, otherwise the local fallback keeps looping after snooze.
Future<void> _liberarAudioLocal() async {
_fallbackTimer?.cancel();
_fadeInTimer?.cancel();
// cancel() detiene la entrega de eventos de forma sincrona; no se espera
// su Future porque puede no resolverse hasta que el stream se cierre.
unawaited(_estadoSub?.cancel());
_estadoSub = null;
await _fallbackPlayer.stop();
}
Future<void> _detener() async {
final radio = context.read<EstadoRadio>();
final alarmas = context.read<EstadoAlarmas>();
final navigator = Navigator.of(context);
_fallbackTimer?.cancel();
_fadeInTimer?.cancel();
await _estadoSub?.cancel();
await _fallbackPlayer.stop();
await _liberarAudioLocal();
await radio.audio.pausar();
await alarmas.finalizarEjecucion(widget.alarma.id);
if (mounted) navigator.pop();
}
/// Flutter-first snooze (S2-R1): tears down local audio, then routes
/// through the canonical EstadoAlarmas.posponerAlarma, which hides the
/// native notification (same stop path as dismiss) and re-programs Android.
Future<void> _posponer(int minutos) async {
final radio = context.read<EstadoRadio>();
final alarmas = context.read<EstadoAlarmas>();
final navigator = Navigator.of(context);
await _liberarAudioLocal();
await radio.audio.pausar();
await alarmas.posponerAlarma(widget.alarma, minutos);
if (mounted) navigator.pop();
}
List<int> _opcionesSnooze() {
final opciones = <int>{3, 5, 10};
final propio = widget.alarma.snoozeMinutos;
if (propio > 0) opciones.add(propio);
return opciones.toList()..sort();
}
@override
void dispose() {
_fallbackTimer?.cancel();
@@ -155,8 +188,12 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
Widget build(BuildContext context) {
final alarma = widget.alarma;
final l10n = AppLocalizations.of(context);
return Scaffold(
backgroundColor: const Color(0xFF061722),
final tokens = context.pluriTokens;
// Cold-GPU note (Design 2.4): PluriGlassSurface uses a BackdropFilter and
// the first frame after a screen-off FSI wake can stutter. The blur sigma
// is capped here, and reduced-motion users skip the entry animation
// entirely via pluriFadeIn.
return PluriWaveScaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(20),
@@ -164,7 +201,8 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
child: PluriGlassSurface(
borderRadius: BorderRadius.circular(32),
padding: const EdgeInsets.all(24),
glowColor: const Color(0xFFFFB86B).withValues(alpha: 0.35),
blurSigma: 10,
glowColor: tokens.warmCoral.withValues(alpha: 0.35),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -197,6 +235,25 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
textAlign: TextAlign.center,
),
const SizedBox(height: 22),
Text(
l10n.snoozeAction,
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: [
for (final minutos in _opcionesSnooze())
OutlinedButton.icon(
onPressed: () => _posponer(minutos),
icon: const Icon(Icons.snooze_rounded),
label: Text(l10n.alarmSnoozeOptionLabel(minutos)),
),
],
),
const SizedBox(height: 14),
FilledButton.icon(
onPressed: _detener,
icon: const Icon(Icons.stop_rounded),
@@ -204,7 +261,7 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
),
],
),
),
).pluriFadeIn(context),
),
),
),
+460 -222
View File
@@ -8,6 +8,7 @@ import '../l10n/app_localizations_ext.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/alarma_musical.dart';
import '../modelos/emisora.dart';
import '../servicios/servicio_programacion_alarmas.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart';
import '../widgets/pluri_layout.dart';
@@ -339,30 +340,27 @@ class _EditorAlarmaSheet extends StatefulWidget {
}
class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
late final TextEditingController _nombreController;
TextEditingController? _nombreController;
late TimeOfDay _hora;
late DateTime _fecha;
late TipoProgramacionAlarma _tipo;
late Set<int> _diasSemana;
late double _volumen;
late int _fadeInSegundos;
late int _snoozeMinutos;
late bool _sonarEnVacaciones;
late SonidoInternoAlarma _sonidoInterno;
Emisora? _emisora;
Emisora? _emisoraFallback;
bool _favoritosSolicitados = false;
final ServicioProgramacionAlarmas _programacion =
ServicioProgramacionAlarmas();
@override
void initState() {
super.initState();
final alarma = widget.alarma;
final l10n = AppLocalizations.of(context);
final ahora = DateTime.now().add(const Duration(minutes: 5));
_nombreController = TextEditingController(
text:
alarma == null
? l10n.defaultAlarmName
: _nombreVisibleAlarma(l10n, alarma),
);
_hora = TimeOfDay(
hour: alarma?.hora ?? ahora.hour,
minute: alarma?.minuto ?? ahora.minute,
@@ -374,12 +372,31 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
_fadeInSegundos = (alarma?.fadeInSegundos ?? 0).clamp(0, 60).toInt();
_sonarEnVacaciones = alarma?.sonarEnVacaciones ?? true;
_sonidoInterno = alarma?.sonidoInterno ?? SonidoInternoAlarma.amanecer;
_snoozeMinutos = alarma?.snoozeMinutos ?? 5;
_emisora = alarma?.emisora ?? context.read<EstadoRadio>().emisoraPreferida;
_emisoraFallback = alarma?.emisoraFallback;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Localizations cannot be read from initState (debug assert); the name
// controller is created lazily here on the first dependency pass.
if (_nombreController == null) {
final l10n = AppLocalizations.of(context);
final alarma = widget.alarma;
_nombreController = TextEditingController(
text:
alarma == null
? l10n.defaultAlarmName
: _nombreVisibleAlarma(l10n, alarma),
);
}
}
@override
void dispose() {
_nombreController.dispose();
_nombreController?.dispose();
super.dispose();
}
@@ -409,232 +426,319 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
child: PluriGlassSurface(
borderRadius: BorderRadius.circular(28),
padding: const EdgeInsets.all(18),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const _AssetIcon(
'assets/icons/alarmas/alarm_music.png',
size: 58,
),
const SizedBox(width: 12),
Expanded(
child: Text(
widget.alarma == null
? l10n.newAlarmTitle
: l10n.editAlarmTitle,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
),
),
),
IconButton(
icon: const Icon(Icons.close_rounded),
onPressed: () => Navigator.pop(context),
),
],
),
const SizedBox(height: 14),
TextField(
controller: _nombreController,
decoration: InputDecoration(labelText: l10n.nameLabel),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _PickerButton(
icon: Icons.schedule_rounded,
label: l10n.timeField,
value: _hora.format(context),
onTap: _elegirHora,
),
),
const SizedBox(width: 10),
Expanded(
child: _PickerButton(
icon: Icons.event_rounded,
label: l10n.dateField,
value: _fechaCorta(_fecha),
onTap:
_tipo == TipoProgramacionAlarma.unica
? _elegirFecha
: null,
),
),
],
),
const SizedBox(height: 12),
SegmentedButton<TipoProgramacionAlarma>(
segments: [
ButtonSegment(
value: TipoProgramacionAlarma.unica,
label: Text(l10n.oneTimeOption),
),
ButtonSegment(
value: TipoProgramacionAlarma.diaria,
label: Text(l10n.dailyOption),
),
ButtonSegment(
value: TipoProgramacionAlarma.diasSemana,
label: Text(l10n.weekdaysOption),
),
],
selected: {_tipo},
onSelectionChanged:
(value) => setState(() => _tipo = value.first),
),
if (_tipo == TipoProgramacionAlarma.diasSemana) ...[
const SizedBox(height: 10),
Wrap(
spacing: 6,
child: Material(
type: MaterialType.transparency,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
for (var i = DateTime.monday; i <= DateTime.sunday; i++)
FilterChip(
label: Text(_weekdayShort(l10n, i)),
selected: _diasSemana.contains(i),
onSelected:
(selected) => setState(() {
selected
? _diasSemana.add(i)
: _diasSemana.remove(i);
}),
const _AssetIcon(
'assets/icons/alarmas/alarm_music.png',
size: 58,
),
const SizedBox(width: 12),
Expanded(
child: Text(
widget.alarma == null
? l10n.newAlarmTitle
: l10n.editAlarmTitle,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
),
),
),
IconButton(
icon: const Icon(Icons.close_rounded),
onPressed: () => Navigator.pop(context),
),
],
),
],
const SizedBox(height: 14),
_SectionLabel(
icon: 'assets/icons/alarmas/fallback_sound.png',
text: l10n.soundAndVolumeSection,
),
Slider(
value: _volumen,
min: 0.25,
max: 1,
divisions: 15,
label: '${(_volumen * 100).round()}%',
onChanged: (value) => setState(() => _volumen = value),
),
const SizedBox(height: 8),
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(l10n.alarmFadeInTitle),
subtitle: Text(
_fadeInSegundos == 0
? l10n.alarmFadeInOff
: l10n.alarmFadeInSummary(_fadeInSegundos),
const SizedBox(height: 14),
TextField(
controller: _nombreController,
decoration: InputDecoration(labelText: l10n.nameLabel),
),
),
Slider(
value: _fadeInSegundos.toDouble(),
min: 0,
max: 60,
divisions: 60,
label: '${_fadeInSegundos}s',
onChanged:
(value) => setState(() => _fadeInSegundos = value.round()),
),
DropdownButtonFormField<SonidoInternoAlarma>(
initialValue: _sonidoInterno,
decoration: InputDecoration(
labelText: l10n.internalSafeSoundLabel,
),
items: [
DropdownMenuItem(
value: SonidoInternoAlarma.amanecer,
child: Text(l10n.soundWarmSunrise),
),
DropdownMenuItem(
value: SonidoInternoAlarma.campanaSuave,
child: Text(l10n.soundSoftBell),
),
DropdownMenuItem(
value: SonidoInternoAlarma.pulsoDigital,
child: Text(l10n.soundDigitalPulse),
),
],
onChanged:
(value) => setState(
() => _sonidoInterno = value ?? _sonidoInterno,
),
),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
key: ValueKey(_emisora?.uuid ?? 'sin-emisora'),
initialValue: _emisora?.uuid,
decoration: InputDecoration(
labelText: l10n.favoriteStationLabel,
prefixIcon: const Icon(Icons.radio_rounded),
),
items: [
DropdownMenuItem<String>(
value: '',
child: Text(l10n.noStationUseInternalSound),
),
for (final emisora in favoritas)
DropdownMenuItem<String>(
value: emisora.uuid,
child: Text(
localizedStationName(l10n, emisora.nombre),
overflow: TextOverflow.ellipsis,
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _PickerButton(
icon: Icons.schedule_rounded,
label: l10n.timeField,
value: _hora.format(context),
onTap: _elegirHora,
),
),
const SizedBox(width: 10),
Expanded(
child: _PickerButton(
icon: Icons.event_rounded,
label: l10n.dateField,
value: _fechaCorta(_fecha),
onTap:
_tipo == TipoProgramacionAlarma.unica
? _elegirFecha
: null,
),
),
],
),
const SizedBox(height: 12),
SegmentedButton<TipoProgramacionAlarma>(
segments: [
ButtonSegment(
value: TipoProgramacionAlarma.unica,
label: Text(l10n.oneTimeOption),
),
ButtonSegment(
value: TipoProgramacionAlarma.diaria,
label: Text(l10n.dailyOption),
),
ButtonSegment(
value: TipoProgramacionAlarma.diasSemana,
label: Text(l10n.weekdaysOption),
),
],
selected: {_tipo},
onSelectionChanged:
(value) => setState(() => _tipo = value.first),
),
if (_tipo == TipoProgramacionAlarma.diasSemana) ...[
const SizedBox(height: 10),
Wrap(
spacing: 6,
children: [
for (var i = DateTime.monday; i <= DateTime.sunday; i++)
FilterChip(
label: Text(_weekdayShort(l10n, i)),
selected: _diasSemana.contains(i),
onSelected:
(selected) => setState(() {
selected
? _diasSemana.add(i)
: _diasSemana.remove(i);
}),
),
],
),
],
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),
Text(l10n.saveFavoritesAlarmHint),
],
if (radio.emisoraActual != null) ...[
const SizedBox(height: 12),
_vistaProximaEjecucion(l10n),
const SizedBox(height: 14),
_SectionLabel(
icon: 'assets/icons/alarmas/fallback_sound.png',
text: l10n.soundAndVolumeSection,
),
Slider(
value: _volumen,
// S2-R11: floor lowered from 0.25 to 0.0.
min: 0,
max: 1,
divisions: 20,
label: '${(_volumen * 100).round()}%',
onChanged: (value) => setState(() => _volumen = value),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: FilledButton.tonalIcon(
onPressed:
() => setState(() => _emisora = radio.emisoraActual),
icon: const Icon(Icons.add_task_rounded),
label: Text(l10n.useCurrentStationAction),
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(l10n.alarmFadeInTitle),
subtitle: Text(
_fadeInSegundos == 0
? l10n.alarmFadeInOff
: l10n.alarmFadeInSummary(_fadeInSegundos),
),
),
],
const SizedBox(height: 8),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
value: _sonarEnVacaciones,
onChanged:
(value) => setState(() => _sonarEnVacaciones = value),
secondary: const _AssetIcon(
'assets/icons/alarmas/vacation_wave.png',
size: 42,
Slider(
value: _fadeInSegundos.toDouble(),
min: 0,
max: 60,
divisions: 60,
label: '${_fadeInSegundos}s',
onChanged:
(value) =>
setState(() => _fadeInSegundos = value.round()),
),
title: Text(l10n.playDuringVacations),
subtitle: Text(l10n.playDuringVacationsHint),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _guardar,
icon: const Icon(Icons.check_rounded),
label: Text(l10n.saveAlarmAction),
),
],
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(l10n.alarmSnoozeDurationTitle),
subtitle: Text(l10n.alarmSnoozeOptionLabel(_snoozeMinutos)),
),
SegmentedButton<int>(
segments: [
for (final minutos in _opcionesSnooze())
ButtonSegment(
value: minutos,
label: Text(l10n.alarmSnoozeOptionLabel(minutos)),
),
],
selected: {_snoozeMinutos},
onSelectionChanged:
(value) => setState(() => _snoozeMinutos = value.first),
),
const SizedBox(height: 8),
DropdownButtonFormField<SonidoInternoAlarma>(
initialValue: _sonidoInterno,
decoration: InputDecoration(
labelText: l10n.internalSafeSoundLabel,
),
items: [
DropdownMenuItem(
value: SonidoInternoAlarma.amanecer,
child: Text(l10n.soundWarmSunrise),
),
DropdownMenuItem(
value: SonidoInternoAlarma.campanaSuave,
child: Text(l10n.soundSoftBell),
),
DropdownMenuItem(
value: SonidoInternoAlarma.pulsoDigital,
child: Text(l10n.soundDigitalPulse),
),
],
onChanged:
(value) => setState(
() => _sonidoInterno = value ?? _sonidoInterno,
),
),
const SizedBox(height: 8),
// S2-R9: searchable bottom-sheet picker instead of a dropdown,
// for both the primary and the backup (fallback) station.
_CampoSelectorEmisora(
key: const ValueKey('alarm-station-field'),
label: l10n.favoriteStationLabel,
icon: Icons.radio_rounded,
value:
_emisora == null
? l10n.noStationUseInternalSound
: localizedStationName(l10n, _emisora!.nombre),
onTap:
() => _elegirEmisora(
favoritas,
seleccionar:
(emisora) => setState(() => _emisora = emisora),
),
),
const SizedBox(height: 8),
_CampoSelectorEmisora(
key: const ValueKey('alarm-fallback-station-field'),
label: l10n.alarmFallbackStationLabel,
icon: Icons.settings_backup_restore_rounded,
value:
_emisoraFallback == null
? l10n.noStationUseInternalSound
: localizedStationName(
l10n,
_emisoraFallback!.nombre,
),
onTap:
() => _elegirEmisora(
favoritas,
seleccionar:
(emisora) =>
setState(() => _emisoraFallback = emisora),
),
),
if (favoritas.isEmpty) ...[
const SizedBox(height: 6),
Text(l10n.saveFavoritesAlarmHint),
],
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: Text(l10n.useCurrentStationAction),
),
),
],
const SizedBox(height: 8),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
value: _sonarEnVacaciones,
onChanged:
(value) => setState(() => _sonarEnVacaciones = value),
secondary: const _AssetIcon(
'assets/icons/alarmas/vacation_wave.png',
size: 42,
),
title: Text(l10n.playDuringVacations),
subtitle: Text(l10n.playDuringVacationsHint),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _guardar,
icon: const Icon(Icons.check_rounded),
label: Text(l10n.saveAlarmAction),
),
],
),
),
),
),
);
}
List<int> _opcionesSnooze() {
final opciones = <int>{3, 5, 10};
if (_snoozeMinutos > 0) opciones.add(_snoozeMinutos);
return opciones.toList()..sort();
}
/// Read-only next-trigger preview (S2-R8): computed from the in-progress
/// draft so the user can verify when the alarm will fire before saving.
/// Recomputed on every setState, so it tracks time/recurrence edits live.
Widget _vistaProximaEjecucion(AppLocalizations l10n) {
final estado = context.read<EstadoAlarmas>();
final borrador = AlarmaMusical(
id: widget.alarma?.id ?? '_borrador_editor',
nombre: 'preview',
hora: _hora.hour,
minuto: _hora.minute,
tipoProgramacion: _tipo,
diasSemana:
_tipo == TipoProgramacionAlarma.diasSemana
? (_diasSemana.toList()..sort())
: const [],
fechaUnica: _tipo == TipoProgramacionAlarma.unica ? _fecha : null,
sonarEnVacaciones: _sonarEnVacaciones,
);
final proxima = _programacion.calcularProxima(
alarma: borrador,
desde: DateTime.now(),
vacaciones: estado.vacaciones,
excepciones: estado.excepciones,
);
return _NoticeLine(
key: const ValueKey('next-trigger-preview'),
icon: Icons.event_available_rounded,
text:
proxima == null
? l10n.alarmNoNextExecution
: l10n.alarmNextExecution(_fechaHora(l10n, proxima)),
);
}
Future<void> _elegirEmisora(
List<Emisora> emisoras, {
required ValueChanged<Emisora?> seleccionar,
}) async {
final resultado = await showModalBottomSheet<_SeleccionEmisora>(
context: context,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: Colors.transparent,
builder: (_) => _SelectorEmisoraSheet(emisoras: emisoras),
);
if (resultado == null) return;
seleccionar(resultado.emisora);
}
Future<void> _elegirHora() async {
final nueva = await showTimePicker(context: context, initialTime: _hora);
if (nueva != null) setState(() => _hora = nueva);
@@ -663,9 +767,10 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
final estado = context.read<EstadoAlarmas>();
final existente = widget.alarma;
final nombre = _nombreController?.text.trim() ?? '';
final alarma = (existente ??
estado.servicio.crearAlarma(
nombre: _nombreController.text.trim(),
nombre: nombre,
hora: _hora.hour,
minuto: _hora.minute,
tipoProgramacion: _tipo,
@@ -673,9 +778,9 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
))
.copyWith(
nombre:
_nombreController.text.trim().isEmpty
nombre.isEmpty
? AppLocalizations.of(context).defaultAlarmName
: _nombreController.text.trim(),
: nombre,
hora: _hora.hour,
minuto: _hora.minute,
tipoProgramacion: _tipo,
@@ -686,8 +791,11 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
fechaUnica: _tipo == TipoProgramacionAlarma.unica ? _fecha : null,
limpiarFechaUnica: _tipo != TipoProgramacionAlarma.unica,
emisora: _emisora,
limpiarEmisora: _emisora == null,
emisoraFallback: _emisoraFallback,
limpiarEmisoraFallback: _emisoraFallback == null,
sonarEnVacaciones: _sonarEnVacaciones,
snoozeMinutos: existente?.snoozeMinutos ?? 5,
snoozeMinutos: _snoozeMinutos,
volumen: _volumen,
fadeInSegundos: _fadeInSegundos.clamp(0, 60).toInt(),
sonidoInterno: _sonidoInterno,
@@ -706,10 +814,140 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
if (seleccionada != null) {
mapa[seleccionada.uuid] = seleccionada;
}
final respaldo = _emisoraFallback;
if (respaldo != null) {
mapa[respaldo.uuid] = respaldo;
}
return mapa.values.toList();
}
}
/// Result wrapper so the picker can distinguish "cancelled" (null result)
/// from "no station chosen" (emisora == null).
class _SeleccionEmisora {
const _SeleccionEmisora(this.emisora);
final Emisora? emisora;
}
class _CampoSelectorEmisora extends StatelessWidget {
const _CampoSelectorEmisora({
super.key,
required this.label,
required this.icon,
required this.value,
required this.onTap,
});
final String label;
final IconData icon;
final String value;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: onTap,
child: InputDecorator(
decoration: InputDecoration(
labelText: label,
prefixIcon: Icon(icon),
suffixIcon: const Icon(Icons.arrow_drop_down_rounded),
),
child: Text(value, overflow: TextOverflow.ellipsis),
),
);
}
}
/// Searchable station picker (S2-R9): bottom sheet with a [SearchBar] over
/// the user's favorites, matching the main station-picker interaction.
class _SelectorEmisoraSheet extends StatefulWidget {
const _SelectorEmisoraSheet({required this.emisoras});
final List<Emisora> emisoras;
@override
State<_SelectorEmisoraSheet> createState() => _SelectorEmisoraSheetState();
}
class _SelectorEmisoraSheetState extends State<_SelectorEmisoraSheet> {
String _filtro = '';
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final bottom = MediaQuery.of(context).viewInsets.bottom;
final query = _filtro.trim().toLowerCase();
final filtradas =
widget.emisoras.where((emisora) {
if (query.isEmpty) return true;
return localizedStationName(
l10n,
emisora.nombre,
).toLowerCase().contains(query) ||
emisora.nombre.toLowerCase().contains(query);
}).toList();
return Padding(
padding: EdgeInsets.fromLTRB(12, 12, 12, bottom + 12),
child: PluriGlassSurface(
borderRadius: BorderRadius.circular(28),
padding: const EdgeInsets.all(18),
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.7,
),
child: Material(
type: MaterialType.transparency,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SearchBar(
hintText: l10n.alarmStationPickerSearchHint,
leading: const Icon(Icons.search_rounded),
onChanged: (value) => setState(() => _filtro = value),
),
const SizedBox(height: 10),
Flexible(
child: ListView(
shrinkWrap: true,
children: [
ListTile(
leading: const Icon(Icons.music_off_rounded),
title: Text(l10n.noStationUseInternalSound),
onTap:
() => Navigator.pop(
context,
const _SeleccionEmisora(null),
),
),
for (final emisora in filtradas)
ListTile(
leading: const Icon(Icons.radio_rounded),
title: Text(
localizedStationName(l10n, emisora.nombre),
overflow: TextOverflow.ellipsis,
),
onTap:
() => Navigator.pop(
context,
_SeleccionEmisora(emisora),
),
),
],
),
),
],
),
),
),
),
);
}
}
class _AccesoDiagnostico extends StatelessWidget {
const _AccesoDiagnostico({required this.estado});
@@ -1036,7 +1274,7 @@ class _InfoChip extends StatelessWidget {
}
class _NoticeLine extends StatelessWidget {
const _NoticeLine({required this.icon, required this.text});
const _NoticeLine({super.key, required this.icon, required this.text});
final IconData icon;
final String text;
+21 -8
View File
@@ -123,9 +123,7 @@ class ServicioAlarmas {
) async {
final config = await cargar();
final normalizadas =
vacaciones
.map((v) => v.normalizado())
.toList()
vacaciones.map((v) => v.normalizado()).toList()
..sort((a, b) => a.inicioDia.compareTo(b.inicioDia));
final alarmas =
config.alarmas
@@ -147,9 +145,10 @@ class ServicioAlarmas {
}) {
final rango = RangoVacaciones(
id: _uuid.v4(),
nombre: (nombre == null || nombre.trim().isEmpty)
? 'Vacaciones'
: nombre.trim(),
nombre:
(nombre == null || nombre.trim().isEmpty)
? 'Vacaciones'
: nombre.trim(),
inicio: inicio,
fin: fin,
);
@@ -259,7 +258,17 @@ class ServicioAlarmas {
DateTime ejecucion,
int minutos,
) async {
final snoozeHasta = _programacion.calcularSnooze(_reloj(), minutos);
// 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);
}
@@ -381,8 +390,12 @@ class ServicioAlarmas {
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.snoozeHasta != null && alarma.snoozeHasta!.isAfter(ahora);
alarma.activa &&
alarma.snoozeHasta != null &&
alarma.snoozeHasta!.isAfter(ahora);
final proxima = _programacion.calcularProxima(
alarma: alarma,
desde: ahora,
+71 -3
View File
@@ -16,14 +16,20 @@ class EventoAlarmaAndroid {
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<Object?, Object?> map) {
return EventoAlarmaAndroid(
@@ -33,6 +39,34 @@ class EventoAlarmaAndroid {
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<Object?, Object?> 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,
),
);
}
}
@@ -60,8 +94,7 @@ class DiagnosticoAlarmasAndroid {
return DiagnosticoAlarmasAndroid(
puedeProgramarExactas: map['canScheduleExactAlarms'] as bool? ?? true,
notificacionesPermitidas: map['notificationsEnabled'] as bool? ?? true,
puedeUsarPantallaCompleta:
map['canUseFullScreenIntent'] as bool? ?? true,
puedeUsarPantallaCompleta: map['canUseFullScreenIntent'] as bool? ?? true,
ignoraOptimizacionBateria:
map['isIgnoringBatteryOptimizations'] as bool? ?? true,
alarmasNativasPendientes: map['nativePendingAlarmsCount'] as int? ?? 0,
@@ -100,10 +133,12 @@ abstract class PuertoAlarmasAndroid {
Future<bool> solicitarPermisoAlarmasExactas();
Future<bool> solicitarPermisoNotificaciones();
Future<bool> solicitarPermisoPantallaCompleta();
Future<bool> solicitarExencionBateria();
Future<void> confirmarAudioFlutter(String alarmaId);
Future<DiagnosticoAlarmasAndroid> diagnostico();
Future<EventoAlarmaAndroid?> obtenerEventoInicial();
Future<List<EjecucionAlarmaNativa>> obtenerEjecucionesNativasGestionadas();
Future<List<EstadoSnoozeNativo>> obtenerEstadoSnoozeNativo();
}
class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
@@ -151,7 +186,9 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
'triggerAtMillis': proxima.millisecondsSinceEpoch,
'preNoticeAtMillis':
alarma.snoozeHasta == null
? proxima.subtract(const Duration(minutes: 30)).millisecondsSinceEpoch
? proxima
.subtract(const Duration(minutes: 30))
.millisecondsSinceEpoch
: 0,
'hour': alarma.hora,
'minute': alarma.minuto,
@@ -169,8 +206,14 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
? 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);
@@ -217,6 +260,14 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
return abierto ?? false;
}
@override
Future<bool> solicitarExencionBateria() async {
final abierto = await _channel.invokeMethod<bool>(
'requestIgnoreBatteryOptimizations',
);
return abierto ?? false;
}
@override
Future<DiagnosticoAlarmasAndroid> diagnostico() async {
debugPrint('[PluriWave][alarmas] diagnostico android');
@@ -261,6 +312,23 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
.toList();
}
@override
Future<List<EstadoSnoozeNativo>> obtenerEstadoSnoozeNativo() async {
final raw = await _channel.invokeMethod<List<Object?>>(
'getNativeSnoozeState',
);
if (raw == null || raw.isEmpty) return const [];
return raw
.whereType<Map<Object?, Object?>>()
.map(EstadoSnoozeNativo.fromMap)
.where(
(estado) =>
estado.alarmaId.isNotEmpty &&
estado.snoozeHasta.millisecondsSinceEpoch > 0,
)
.toList();
}
Future<void> _logAndInvokeVoid(String method, Map<String, Object?> args) {
debugPrint('[PluriWave][alarmas] $method $args');
return _channel.invokeMethod<void>(method, args);
+38
View File
@@ -0,0 +1,38 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_animate/flutter_animate.dart';
/// Reduced-motion-aware entry animations (S5-R3).
///
/// Every entry animation in the app should go through these helpers so the
/// OS "disable animations" accessibility setting is honored from a single
/// call site. When reduced motion is active the child is returned untouched
/// (no [Animate] wrapper at all).
extension PluriAnimate on Widget {
/// Fade-in entry animation.
Widget pluriFadeIn(
BuildContext context, {
Duration duration = const Duration(milliseconds: 350),
Duration delay = Duration.zero,
Curve curve = Curves.easeOutCubic,
}) {
if (_animacionesDeshabilitadas(context)) return this;
return animate(delay: delay).fadeIn(duration: duration, curve: curve);
}
/// Fade + subtle scale entry animation.
Widget pluriScaleIn(
BuildContext context, {
Duration duration = const Duration(milliseconds: 350),
Duration delay = Duration.zero,
Curve curve = Curves.easeOutCubic,
double begin = 0.96,
}) {
if (_animacionesDeshabilitadas(context)) return this;
return animate(delay: delay)
.fadeIn(duration: duration, curve: curve)
.scaleXY(begin: begin, end: 1, duration: duration, curve: curve);
}
bool _animacionesDeshabilitadas(BuildContext context) =>
MediaQuery.maybeDisableAnimationsOf(context) ?? false;
}