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:
+7
-7
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -422,6 +422,18 @@
|
||||
"soundDigitalPulse": "نبضة رقمية",
|
||||
"favoriteStationLabel": "المحطة المفضلة",
|
||||
"noStationUseInternalSound": "بدون محطة: استخدام الصوت الداخلي",
|
||||
"alarmFallbackStationLabel": "محطة احتياطية",
|
||||
"alarmStationPickerSearchHint": "ابحث عن محطة بالاسم",
|
||||
"alarmSnoozeDurationTitle": "مدة الغفوة",
|
||||
"snoozeAction": "غفوة",
|
||||
"alarmSnoozeOptionLabel": "{minutes} د",
|
||||
"@alarmSnoozeOptionLabel": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"saveFavoritesAlarmHint": "احفظ محطات في المفضلة لاستخدامها كمنبه موسيقي.",
|
||||
"useCurrentStationAction": "استخدام المحطة الحالية",
|
||||
"playDuringVacations": "الرنين أثناء الإجازات",
|
||||
|
||||
@@ -422,6 +422,18 @@
|
||||
"soundDigitalPulse": "ডিজিটাল পালস",
|
||||
"favoriteStationLabel": "প্রিয় স্টেশন",
|
||||
"noStationUseInternalSound": "স্টেশন নেই: অভ্যন্তরীণ শব্দ ব্যবহার করুন",
|
||||
"alarmFallbackStationLabel": "ব্যাকআপ স্টেশন",
|
||||
"alarmStationPickerSearchHint": "নাম দিয়ে স্টেশন খুঁজুন",
|
||||
"alarmSnoozeDurationTitle": "স্নুজ সময়কাল",
|
||||
"snoozeAction": "স্নুজ",
|
||||
"alarmSnoozeOptionLabel": "{minutes} মিনিট",
|
||||
"@alarmSnoozeOptionLabel": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"saveFavoritesAlarmHint": "সুরেলা অ্যালার্ম হিসেবে ব্যবহার করতে প্রিয়তে স্টেশন সংরক্ষণ করুন।",
|
||||
"useCurrentStationAction": "বর্তমান স্টেশন ব্যবহার করুন",
|
||||
"playDuringVacations": "ছুটিতে বাজান",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -422,6 +422,18 @@
|
||||
"soundDigitalPulse": "डिजिटल धड़कन",
|
||||
"favoriteStationLabel": "पसंदीदा स्टेशन",
|
||||
"noStationUseInternalSound": "कोई स्टेशन नहीं: आंतरिक ध्वनि इस्तेमाल करें",
|
||||
"alarmFallbackStationLabel": "बैकअप स्टेशन",
|
||||
"alarmStationPickerSearchHint": "नाम से स्टेशन खोजें",
|
||||
"alarmSnoozeDurationTitle": "स्नूज़ अवधि",
|
||||
"snoozeAction": "स्नूज़",
|
||||
"alarmSnoozeOptionLabel": "{minutes} मिनट",
|
||||
"@alarmSnoozeOptionLabel": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"saveFavoritesAlarmHint": "उन्हें संगीतमय अलार्म के रूप में इस्तेमाल करने के लिए स्टेशन पसंदीदा में सहेजें।",
|
||||
"useCurrentStationAction": "वर्तमान स्टेशन इस्तेमाल करें",
|
||||
"playDuringVacations": "छुट्टियों में बजाएँ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -422,6 +422,18 @@
|
||||
"soundDigitalPulse": "デジタルパルス",
|
||||
"favoriteStationLabel": "お気に入り局",
|
||||
"noStationUseInternalSound": "局なし: 内部音を使用",
|
||||
"alarmFallbackStationLabel": "予備の局",
|
||||
"alarmStationPickerSearchHint": "局名で検索",
|
||||
"alarmSnoozeDurationTitle": "スヌーズ時間",
|
||||
"snoozeAction": "スヌーズ",
|
||||
"alarmSnoozeOptionLabel": "{minutes}分",
|
||||
"@alarmSnoozeOptionLabel": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"saveFavoritesAlarmHint": "音楽アラームとして使うには、局をお気に入りに保存してください。",
|
||||
"useCurrentStationAction": "現在の局を使用",
|
||||
"playDuringVacations": "休暇中も鳴らす",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -422,6 +422,18 @@
|
||||
"soundDigitalPulse": "Цифровой импульс",
|
||||
"favoriteStationLabel": "Избранная станция",
|
||||
"noStationUseInternalSound": "Без станции: использовать внутренний звук",
|
||||
"alarmFallbackStationLabel": "Резервная станция",
|
||||
"alarmStationPickerSearchHint": "Поиск станции по названию",
|
||||
"alarmSnoozeDurationTitle": "Интервал повтора",
|
||||
"snoozeAction": "Отложить",
|
||||
"alarmSnoozeOptionLabel": "{minutes} мин",
|
||||
"@alarmSnoozeOptionLabel": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"saveFavoritesAlarmHint": "Сохраните станции в избранное, чтобы использовать их как музыкальный будильник.",
|
||||
"useCurrentStationAction": "Использовать текущую станцию",
|
||||
"playDuringVacations": "Звонить во время отпусков",
|
||||
|
||||
@@ -422,6 +422,18 @@
|
||||
"soundDigitalPulse": "数字脉冲",
|
||||
"favoriteStationLabel": "收藏电台",
|
||||
"noStationUseInternalSound": "无电台:使用内部声音",
|
||||
"alarmFallbackStationLabel": "备用电台",
|
||||
"alarmStationPickerSearchHint": "按名称搜索电台",
|
||||
"alarmSnoozeDurationTitle": "贪睡时长",
|
||||
"snoozeAction": "贪睡",
|
||||
"alarmSnoozeOptionLabel": "{minutes} 分钟",
|
||||
"@alarmSnoozeOptionLabel": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"saveFavoritesAlarmHint": "将电台保存到收藏,即可把它们用作音乐闹钟。",
|
||||
"useCurrentStationAction": "使用当前电台",
|
||||
"playDuringVacations": "假期期间响铃",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 =>
|
||||
'احفظ محطات في المفضلة لاستخدامها كمنبه موسيقي.';
|
||||
|
||||
@@ -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 =>
|
||||
'সুরেলা অ্যালার্ম হিসেবে ব্যবহার করতে প্রিয়তে স্টেশন সংরক্ষণ করুন।';
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -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 =>
|
||||
'उन्हें संगीतमय अलार्म के रूप में इस्तेमाल करने के लिए स्टेशन पसंदीदा में सहेजें।';
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -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 => '音楽アラームとして使うには、局をお気に入りに保存してください。';
|
||||
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -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 =>
|
||||
'Сохраните станции в избранное, чтобы использовать их как музыкальный будильник.';
|
||||
|
||||
@@ -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 => '将电台保存到收藏,即可把它们用作音乐闹钟。';
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user