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
+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 => '将电台保存到收藏,即可把它们用作音乐闹钟。';