fix(chrome): drop the global AppBar, give each root its own title row

The prototype (t4) draws no global app bar anywhere: every root paints
a plain ~56px title row inside its own content instead (Alarmas line
325, Ajustes line 511, Explorar line 641). app.dart wrapped every tab
in PluriWaveScaffold(appBar: AppBar(title: Text(appTitle), ...)),
adding 56dp of chrome and a "PluriWave" title the prototype never
shows.

Add PluriRootHeader, a shared 56px title-row widget reused by all 5
roots. Extract app.dart's old _mostrarTimerDialog (only reachable from
the removed AppBar action) into a free function,
showPluriSleepTimerSheet, so every root's header can open the same
sheet directly and the sleep-timer feature stays reachable from every
tab with no behaviour change.

S1, Tier 1 visual-fidelity pass (audit id 2521).
This commit is contained in:
2026-07-29 20:17:24 +02:00
parent b8f078bc14
commit 78a7415dd8
11 changed files with 652 additions and 260 deletions
-259
View File
@@ -217,16 +217,6 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal>
final indice = navegacion.indice;
return PluriWaveScaffold(
appBar: AppBar(
title: Text(l10n.appTitle),
actions: [
IconButton(
icon: const Icon(Icons.bedtime_outlined),
tooltip: l10n.sleepTimer,
onPressed: () => _mostrarTimerDialog(context),
),
],
),
body: SafeArea(
top: false,
child: AnimatedSwitcher(
@@ -423,253 +413,4 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal>
}
}
}
void _mostrarTimerDialog(BuildContext context) {
showModalBottomSheet(
context: context,
showDragHandle: true,
builder:
(ctx) => Consumer<EstadoRadio>(
builder:
(ctx, estado, _) => SafeArea(
child: Padding(
padding: PluriLayout.sheetPadding,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(ctx).sleepTimer,
style: Theme.of(ctx).textTheme.titleLarge,
),
const SizedBox(height: PluriLayout.sectionGap),
Text(
AppLocalizations.of(ctx).sleepTimerDescription,
style: Theme.of(ctx).textTheme.bodySmall,
),
const SizedBox(height: PluriLayout.panelGap),
if (estado.timer.activo)
StreamBuilder<Duration>(
stream: estado.timer.tiempoRestanteStream,
builder: (ctx, snap) {
final restante =
snap.data ?? estado.timer.tiempoRestante;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
_formatearDuracionTimer(
AppLocalizations.of(ctx),
restante,
),
style:
Theme.of(ctx).textTheme.headlineMedium,
),
const SizedBox(
height: PluriLayout.compactGap,
),
FilledButton.tonal(
onPressed: () {
estado.cancelarTimer();
Navigator.pop(ctx);
},
child: Text(
AppLocalizations.of(ctx).cancelTimer,
),
),
],
);
},
)
else
Wrap(
spacing: PluriLayout.compactGap,
runSpacing: PluriLayout.compactGap,
children: [
for (final segundos
in estado.timerSuenoPresetsSegundos)
ActionChip(
label: Text(
_formatearDuracionTimer(
AppLocalizations.of(ctx),
Duration(seconds: segundos),
),
),
onPressed: () {
estado.iniciarTimerDuracion(
Duration(seconds: segundos),
);
Navigator.pop(ctx);
},
),
ActionChip(
avatar: const Icon(
Icons.tune_rounded,
size: 18,
),
label: Text(
AppLocalizations.of(ctx).optionOther,
),
onPressed: () async {
final duracion =
await _pedirDuracionPersonalizada(ctx);
if (duracion == null || !ctx.mounted) return;
estado.iniciarTimerDuracion(duracion);
Navigator.pop(ctx);
},
),
],
),
],
),
),
),
),
);
}
Future<Duration?> _pedirDuracionPersonalizada(BuildContext context) {
return showModalBottomSheet<Duration>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (ctx) => const _TimerPersonalizadoSheet(),
);
}
}
String _formatearDuracionTimer(AppLocalizations l10n, Duration duracion) {
final horas = duracion.inHours;
final minutos = duracion.inMinutes.remainder(60);
final segundos = duracion.inSeconds.remainder(60);
if (horas > 0) {
return l10n.durationHoursMinutesSeconds(
horas,
minutos.toString().padLeft(2, '0'),
segundos.toString().padLeft(2, '0'),
);
}
if (minutos > 0) {
return segundos == 0
? l10n.durationMinutesOnly(minutos)
: l10n.durationMinutesSeconds(
minutos,
segundos.toString().padLeft(2, '0'),
);
}
return l10n.durationSecondsOnly(segundos);
}
class _TimerPersonalizadoSheet extends StatefulWidget {
const _TimerPersonalizadoSheet();
@override
State<_TimerPersonalizadoSheet> createState() =>
_TimerPersonalizadoSheetState();
}
class _TimerPersonalizadoSheetState extends State<_TimerPersonalizadoSheet> {
final _horasCtrl = TextEditingController();
final _minutosCtrl = TextEditingController(text: '15');
final _segundosCtrl = TextEditingController();
bool _guardarPreset = true;
@override
void dispose() {
_horasCtrl.dispose();
_minutosCtrl.dispose();
_segundosCtrl.dispose();
super.dispose();
}
int _leer(TextEditingController ctrl) => int.tryParse(ctrl.text.trim()) ?? 0;
Future<void> _confirmar() async {
final duracion = Duration(
hours: _leer(_horasCtrl),
minutes: _leer(_minutosCtrl),
seconds: _leer(_segundosCtrl),
);
if (duracion <= Duration.zero) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).durationGreaterThanZero),
),
);
return;
}
if (_guardarPreset) {
await context.read<EstadoRadio>().agregarTimerSuenoPreset(duracion);
}
if (mounted) Navigator.pop(context, duracion);
}
@override
Widget build(BuildContext context) {
final bottom = MediaQuery.viewInsetsOf(context).bottom;
return SafeArea(
child: Padding(
padding: EdgeInsets.fromLTRB(18, 0, 18, 18 + bottom),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
AppLocalizations.of(context).customDurationTitle,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: PluriLayout.sectionGap),
Row(
children: [
Expanded(
child: _campoTiempo(
_horasCtrl,
AppLocalizations.of(context).hoursLabel,
),
),
const SizedBox(width: PluriLayout.compactGap),
Expanded(
child: _campoTiempo(
_minutosCtrl,
AppLocalizations.of(context).minutesLabel,
),
),
const SizedBox(width: PluriLayout.compactGap),
Expanded(
child: _campoTiempo(
_segundosCtrl,
AppLocalizations.of(context).secondsLabel,
),
),
],
),
const SizedBox(height: PluriLayout.compactGap),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: Text(AppLocalizations.of(context).saveQuickAccess),
value: _guardarPreset,
onChanged: (value) => setState(() => _guardarPreset = value),
),
const SizedBox(height: PluriLayout.sectionGap),
FilledButton.icon(
icon: const Icon(Icons.bedtime_rounded),
label: Text(AppLocalizations.of(context).startTimer),
onPressed: _confirmar,
),
],
),
),
);
}
Widget _campoTiempo(TextEditingController controller, String label) {
return TextField(
controller: controller,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
);
}
}