fix(alarmas-editor): REPETIR eyebrow, grouped card, volume row

Audit 8.5 (t4:381): the "REPETIR" eyebrow above the weekday circles --
never rendered before. New ARB key alarmRepeatSectionLabel.

Audit 8.6 (t4:392-401): the station picker, volume, fade-in and
vacation toggle now share ONE bordered card with sangred divider lines
-- were four separately-spaced widgets. Restyled _CampoSelectorEmisora
as a flat nav row (icon/bold label/muted value/chevron, matching
FilaAjuste's own convention) so it fits inside the card instead of
drawing its own outlined InputDecoration chrome -- used by BOTH the
primary and fallback (Advanced) station fields. Snooze duration, "use
current station", and the Advanced section stay OUTSIDE the card,
exactly where they were (audit 8.8's own documented deliberate
extras). A transparent Material sits inside the card's coloured box so
the fade-in ListTile and the vacation SwitchListTile still paint their
ink/background correctly (the same class of constraint already
documented for PluriGlassSurface elsewhere in this codebase).

Audit 8.7 (t4:396): the volume row is now a compact 112px track with a
live percentage label -- was a bare full-width Slider with no visible
value. New ArtB key alarmVolumeLabel.

Item 8.8 (extra name/type-selector/snooze-selector/"use current
station"/Advanced fields) stays exactly as documented: deliberate,
required by the hard constraint that the editor keep its date field,
fallback-station picker and sound dropdown.
This commit is contained in:
2026-07-30 16:42:04 +02:00
parent c7d137c82e
commit dcb415b3f8
29 changed files with 364 additions and 113 deletions
+1
View File
@@ -594,6 +594,7 @@
}
},
"alarmRepeatSectionLabel": "تكرار",
"alarmVolumeLabel": "مستوى الصوت",
"androidReliabilityTitle": "مراجعة موثوقية Android",
"closeAction": "إغلاق",
"customOption": "مخصص",
+1
View File
@@ -594,6 +594,7 @@
}
},
"alarmRepeatSectionLabel": "পুনরাবৃত্তি",
"alarmVolumeLabel": "ভলিউম",
"androidReliabilityTitle": "Android নির্ভরযোগ্যতা দেখুন",
"closeAction": "বন্ধ করুন",
"customOption": "কাস্টম",
+1
View File
@@ -594,6 +594,7 @@
}
},
"alarmRepeatSectionLabel": "WIEDERHOLEN",
"alarmVolumeLabel": "Lautstärke",
"androidReliabilityTitle": "Android-Zuverlässigkeit prüfen",
"closeAction": "Schließen",
"customOption": "Benutzerdefiniert",
+1
View File
@@ -705,6 +705,7 @@
}
},
"alarmRepeatSectionLabel": "REPEAT",
"alarmVolumeLabel": "Volume",
"androidReliabilityTitle": "Review Android reliability",
"closeAction": "Close",
"customOption": "Custom",
+1
View File
@@ -661,6 +661,7 @@
"alarmScheduleWeekdays": "Días: {days}",
"@alarmScheduleWeekdays": {"placeholders": {"days": {}}},
"alarmRepeatSectionLabel": "REPETIR",
"alarmVolumeLabel": "Volumen",
"androidReliabilityTitle": "Revisar fiabilidad Android",
"closeAction": "Cerrar",
"customOption": "Personalizada",
+1
View File
@@ -594,6 +594,7 @@
}
},
"alarmRepeatSectionLabel": "RÉPÉTER",
"alarmVolumeLabel": "Volume",
"androidReliabilityTitle": "Vérifier la fiabilité Android",
"closeAction": "Fermer",
"customOption": "Personnalisée",
+1
View File
@@ -594,6 +594,7 @@
}
},
"alarmRepeatSectionLabel": "दोहराएं",
"alarmVolumeLabel": "आवाज़",
"androidReliabilityTitle": "Android विश्वसनीयता जाँचें",
"closeAction": "बंद करें",
"customOption": "कस्टम",
+1
View File
@@ -594,6 +594,7 @@
}
},
"alarmRepeatSectionLabel": "ULANGI",
"alarmVolumeLabel": "Volume",
"androidReliabilityTitle": "Tinjau keandalan Android",
"closeAction": "Tutup",
"customOption": "Kustom",
+1
View File
@@ -594,6 +594,7 @@
}
},
"alarmRepeatSectionLabel": "RIPETI",
"alarmVolumeLabel": "Volume",
"androidReliabilityTitle": "Controlla affidabilità Android",
"closeAction": "Chiudi",
"customOption": "Personalizzata",
+1
View File
@@ -594,6 +594,7 @@
}
},
"alarmRepeatSectionLabel": "繰り返し",
"alarmVolumeLabel": "音量",
"androidReliabilityTitle": "Androidの信頼性を確認",
"closeAction": "閉じる",
"customOption": "カスタム",
+1
View File
@@ -594,6 +594,7 @@
}
},
"alarmRepeatSectionLabel": "REPETIÇÃO",
"alarmVolumeLabel": "Volume",
"androidReliabilityTitle": "Revisar confiabilidade Android",
"closeAction": "Fechar",
"customOption": "Personalizada",
+1
View File
@@ -594,6 +594,7 @@
}
},
"alarmRepeatSectionLabel": "ПОВТОР",
"alarmVolumeLabel": "Громкость",
"androidReliabilityTitle": "Проверить надёжность Android",
"closeAction": "Закрыть",
"customOption": "Своя",
+1
View File
@@ -594,6 +594,7 @@
}
},
"alarmRepeatSectionLabel": "重复",
"alarmVolumeLabel": "音量",
"androidReliabilityTitle": "检查 Android 可靠性",
"closeAction": "关闭",
"customOption": "自定义",
+6
View File
@@ -2498,6 +2498,12 @@ abstract class AppLocalizations {
/// **'REPETIR'**
String get alarmRepeatSectionLabel;
/// No description provided for @alarmVolumeLabel.
///
/// In es, this message translates to:
/// **'Volumen'**
String get alarmVolumeLabel;
/// No description provided for @androidReliabilityTitle.
///
/// In es, this message translates to:
+3
View File
@@ -1372,6 +1372,9 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get alarmRepeatSectionLabel => 'تكرار';
@override
String get alarmVolumeLabel => 'مستوى الصوت';
@override
String get androidReliabilityTitle => 'مراجعة موثوقية Android';
+3
View File
@@ -1381,6 +1381,9 @@ class AppLocalizationsBn extends AppLocalizations {
@override
String get alarmRepeatSectionLabel => 'পুনরাবৃত্তি';
@override
String get alarmVolumeLabel => 'ভলিউম';
@override
String get androidReliabilityTitle => 'Android নির্ভরযোগ্যতা দেখুন';
+3
View File
@@ -1389,6 +1389,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get alarmRepeatSectionLabel => 'WIEDERHOLEN';
@override
String get alarmVolumeLabel => 'Lautstärke';
@override
String get androidReliabilityTitle => 'Android-Zuverlässigkeit prüfen';
+3
View File
@@ -1373,6 +1373,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get alarmRepeatSectionLabel => 'REPEAT';
@override
String get alarmVolumeLabel => 'Volume';
@override
String get androidReliabilityTitle => 'Review Android reliability';
+3
View File
@@ -1383,6 +1383,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get alarmRepeatSectionLabel => 'REPETIR';
@override
String get alarmVolumeLabel => 'Volumen';
@override
String get androidReliabilityTitle => 'Revisar fiabilidad Android';
+3
View File
@@ -1393,6 +1393,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get alarmRepeatSectionLabel => 'RÉPÉTER';
@override
String get alarmVolumeLabel => 'Volume';
@override
String get androidReliabilityTitle => 'Vérifier la fiabilité Android';
+3
View File
@@ -1376,6 +1376,9 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get alarmRepeatSectionLabel => 'दोहराएं';
@override
String get alarmVolumeLabel => 'आवाज़';
@override
String get androidReliabilityTitle => 'Android विश्वसनीयता जाँचें';
+3
View File
@@ -1384,6 +1384,9 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get alarmRepeatSectionLabel => 'ULANGI';
@override
String get alarmVolumeLabel => 'Volume';
@override
String get androidReliabilityTitle => 'Tinjau keandalan Android';
+3
View File
@@ -1392,6 +1392,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get alarmRepeatSectionLabel => 'RIPETI';
@override
String get alarmVolumeLabel => 'Volume';
@override
String get androidReliabilityTitle => 'Controlla affidabilità Android';
+3
View File
@@ -1333,6 +1333,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get alarmRepeatSectionLabel => '繰り返し';
@override
String get alarmVolumeLabel => '音量';
@override
String get androidReliabilityTitle => 'Androidの信頼性を確認';
+3
View File
@@ -1381,6 +1381,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get alarmRepeatSectionLabel => 'REPETIÇÃO';
@override
String get alarmVolumeLabel => 'Volume';
@override
String get androidReliabilityTitle => 'Revisar confiabilidade Android';
+3
View File
@@ -1385,6 +1385,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get alarmRepeatSectionLabel => 'ПОВТОР';
@override
String get alarmVolumeLabel => 'Громкость';
@override
String get androidReliabilityTitle => 'Проверить надёжность Android';
+3
View File
@@ -1328,6 +1328,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get alarmRepeatSectionLabel => '重复';
@override
String get alarmVolumeLabel => '音量';
@override
String get androidReliabilityTitle => '检查 Android 可靠性';
+246 -113
View File
@@ -647,6 +647,15 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
(value) => setState(() => _tipo = value.first),
),
const SizedBox(height: 10),
// Audit 8.5 (t4 line 381): the "REPETIR" eyebrow above the
// weekday circles -- never rendered anywhere before.
Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 4, 8),
child: Text(
l10n.alarmRepeatSectionLabel,
style: context.pluriType.eyebrowLabel,
),
),
// WU10: weekday circles are now ALWAYS visible (previously
// only inserted into the tree in diasSemana mode) — matching
// the mockup, which shows them unconditionally under the
@@ -688,80 +697,117 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
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),
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(
l10n.alarmFadeInTitle,
style: context.pluriType.cardTitle,
// Audit 8.6 (t4 lines 392-401): the station picker, volume,
// fade-in and vacation toggle now share ONE bordered card
// with sangred divider lines between rows -- matching the
// prototype's own single grouped block -- instead of
// sitting bare on the sheet, each with its own gaps. The
// extra fields the prototype does NOT show (name, type
// selector, snooze selector, "use current station",
// Advanced) stay OUTSIDE this card, exactly where they
// were (documented as deliberate in audit 8.8 / WU10).
DecoratedBox(
key: const ValueKey('alarm-editor-grouped-card'),
decoration: BoxDecoration(
color: context.pluriTokens.listSurface,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: Colors.white.withValues(alpha: 0.08),
),
),
subtitle: Text(
_fadeInSegundos == 0
? l10n.alarmFadeInOff
: l10n.alarmFadeInSummary(_fadeInSegundos),
// A transparent Material sits directly inside the coloured
// box, closer to the fade-in ListTile and the vacation
// SwitchListTile than the DecoratedBox's own opaque fill
// -- both paint their ink/background on the NEAREST
// Material ancestor, and without this the box's colour
// would hide those effects (Flutter's own debug-mode
// check for exactly this).
child: Material(
type: MaterialType.transparency,
child: Column(
children: [
// S2-R9: searchable bottom-sheet picker instead of a
// dropdown, for the primary station. The backup
// (fallback) picker moves into the Advanced section
// below (WU10) — the primary choice stays a
// top-level field, only its secondary/backup sibling
// is now one tap further away.
_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 Divider(height: 1, indent: 66),
// Audit 8.7 (t4 line 396): a compact 112px track with
// a trailing percentage label -- was a bare
// full-width Slider with no visible value.
_FilaVolumen(
volumen: _volumen,
onChanged:
(value) => setState(() => _volumen = value),
),
const Divider(height: 1, indent: 46),
Padding(
padding: const EdgeInsets.fromLTRB(14, 4, 14, 4),
child: ListTile(
contentPadding: EdgeInsets.zero,
title: Text(
l10n.alarmFadeInTitle,
style: context.pluriType.cardTitle,
),
subtitle: Text(
_fadeInSegundos == 0
? l10n.alarmFadeInOff
: l10n.alarmFadeInSummary(_fadeInSegundos),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Slider(
value: _fadeInSegundos.toDouble(),
min: 0,
max: 60,
divisions: 60,
label: '${_fadeInSegundos}s',
onChanged:
(value) => setState(
() => _fadeInSegundos = value.round(),
),
),
),
const Divider(height: 1, indent: 46),
SwitchListTile.adaptive(
value: _sonarEnVacaciones,
onChanged:
(value) =>
setState(() => _sonarEnVacaciones = value),
secondary: _AssetIcon(
'assets/icons/alarmas/vacation_wave.png',
size: 42,
semanticLabel: l10n.vacationIconLabel,
),
title: Text(l10n.playDuringVacations),
subtitle: Text(l10n.playDuringVacationsHint),
),
],
),
),
),
Slider(
value: _fadeInSegundos.toDouble(),
min: 0,
max: 60,
divisions: 60,
label: '${_fadeInSegundos}s',
onChanged:
(value) =>
setState(() => _fadeInSegundos = value.round()),
),
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),
// S2-R9: searchable bottom-sheet picker instead of a dropdown,
// for the primary station. The backup (fallback) picker moves
// into the Advanced section below (WU10) — the primary choice
// stays a top-level field, only its secondary/backup sibling
// is now one tap further away.
_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),
),
),
if (favoritas.isEmpty) ...[
const SizedBox(height: 6),
Text(l10n.saveFavoritesAlarmHint),
@@ -778,19 +824,23 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
),
),
],
const SizedBox(height: 8),
SwitchListTile.adaptive(
const SizedBox(height: 12),
ListTile(
contentPadding: EdgeInsets.zero,
value: _sonarEnVacaciones,
onChanged:
(value) => setState(() => _sonarEnVacaciones = value),
secondary: _AssetIcon(
'assets/icons/alarmas/vacation_wave.png',
size: 42,
semanticLabel: l10n.vacationIconLabel,
),
title: Text(l10n.playDuringVacations),
subtitle: Text(l10n.playDuringVacationsHint),
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),
// WU10 (native-alarms delta — Alarm Editor Preserves Date,
@@ -1033,18 +1083,122 @@ class _CampoSelectorEmisora extends StatelessWidget {
final String value;
final VoidCallback onTap;
/// Audit 8.6 (t4:394): a flat nav row -- icon, bold label, muted value,
/// chevron -- matching the same convention `FilaAjuste` uses everywhere
/// else in Settings, so this fits cleanly inside the grouped card below
/// instead of drawing its own outlined `InputDecoration` chrome.
@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),
final type = context.pluriType;
return Material(
type: MaterialType.transparency,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
child: Row(
children: [
Icon(icon, size: 20),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: type.cardTitle),
Text(
value,
overflow: TextOverflow.ellipsis,
style: type.bodyStrong.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.55),
),
),
],
),
),
Icon(
Icons.chevron_right_rounded,
size: 19,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.4),
),
],
),
),
child: Text(value, overflow: TextOverflow.ellipsis),
),
);
}
}
/// Audit 8.7 (t4 line 396): a compact volume row -- icon, label, a 112px
/// track, and a trailing "80%" -- was a bare full-width `Slider` with no
/// visible current value. Constrains a real, still-draggable `Slider`
/// (not a static bar) to the prototype's 112px track width via a
/// `SliderTheme` + fixed-width `SizedBox`, preserving drag interactivity.
class _FilaVolumen extends StatelessWidget {
const _FilaVolumen({required this.volumen, required this.onChanged});
final double volumen;
final ValueChanged<double> onChanged;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final onSurface = Theme.of(context).colorScheme.onSurface;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4),
child: Row(
children: [
Icon(
Icons.volume_up_rounded,
size: 20,
color: onSurface.withValues(alpha: 0.7),
),
const SizedBox(width: 12),
Expanded(
child: Text(
l10n.alarmVolumeLabel,
style: context.pluriType.cardTitle,
),
),
SizedBox(
width: 112,
height: 24,
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 4,
activeTrackColor: PluriWaveTokens.brand,
inactiveTrackColor: Colors.white.withValues(alpha: 0.14),
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 14),
),
child: Slider(
value: volumen,
// S2-R11: floor lowered from 0.25 to 0.0.
min: 0,
max: 1,
divisions: 20,
label: '${(volumen * 100).round()}%',
onChanged: onChanged,
),
),
),
SizedBox(
width: 34,
child: Text(
'${(volumen * 100).round()}%',
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w800,
color: onSurface.withValues(alpha: 0.7),
),
),
),
],
),
);
}
@@ -1446,27 +1600,6 @@ class _PickerButton extends StatelessWidget {
}
}
class _SectionLabel extends StatelessWidget {
const _SectionLabel({required this.icon, required this.text});
final String icon;
final String text;
@override
Widget build(BuildContext context) {
return Row(
children: [
_AssetIcon(icon, size: 34),
const SizedBox(width: 8),
// WU10: swapped the raw TextTheme lookup for the named type-scale
// token (cosmetic only — same weight class, now shared with every
// other card/section title in the redesign).
Text(text, style: context.pluriType.cardTitle),
],
);
}
}
class _NoticeLine extends StatelessWidget {
const _NoticeLine({super.key, required this.icon, required this.text});
@@ -400,6 +400,66 @@ void main() {
);
},
);
testWidgets('8.5: the "REPETIR" eyebrow sits above the weekday circles', (
tester,
) async {
await _abrirEditor(tester);
expect(find.text(l10n.alarmRepeatSectionLabel), findsOneWidget);
final eyebrowY =
tester.getBottomLeft(find.text(l10n.alarmRepeatSectionLabel)).dy;
final circlesY =
tester
.getTopLeft(find.byKey(const ValueKey('alarm-weekday-circles')))
.dy;
expect(eyebrowY <= circlesY, isTrue);
});
testWidgets(
'8.6: the station field, volume, fade-in and vacation toggle share '
'ONE grouped card',
(tester) async {
await _abrirEditor(tester);
final tarjeta = find.byKey(const ValueKey('alarm-editor-grouped-card'));
expect(tarjeta, findsOneWidget);
expect(
find.descendant(
of: tarjeta,
matching: find.byKey(const ValueKey('alarm-station-field')),
),
findsOneWidget,
);
expect(
find.descendant(of: tarjeta, matching: find.byType(Slider)),
findsNWidgets(2),
reason: 'volume + fade-in sliders both live inside the card',
);
expect(
find.descendant(of: tarjeta, matching: find.byType(SwitchListTile)),
findsOneWidget,
reason: 'the vacation toggle lives inside the card',
);
// Snooze duration is a deliberate EXTRA (audit 8.8), kept OUTSIDE
// the prototype-mandated 4-row card.
expect(
find.descendant(
of: tarjeta,
matching: find.text(l10n.alarmSnoozeDurationTitle),
),
findsNothing,
);
},
);
testWidgets('8.7: the volume row shows a live percentage label next to a '
'compact track', (tester) async {
await _abrirEditor(tester);
expect(find.text(l10n.alarmVolumeLabel), findsOneWidget);
expect(find.text('85%'), findsOneWidget);
});
});
group('WU8 — tarjeta de alarma simplificada', () {