From 78a7415dd8eee3f68facddf54beb6db88924fb73 Mon Sep 17 00:00:00 2001 From: freetlab Date: Wed, 29 Jul 2026 20:17:24 +0200 Subject: [PATCH] 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). --- lib/app.dart | 259 -------------------- lib/pantallas/pantalla_ajustes.dart | 10 + lib/pantallas/pantalla_alarmas.dart | 10 + lib/pantallas/pantalla_buscar.dart | 10 + lib/pantallas/pantalla_favoritos.dart | 15 ++ lib/pantallas/pantalla_inicio.dart | 13 +- lib/widgets/pluri_root_header.dart | 53 ++++ lib/widgets/pluri_sleep_timer_sheet.dart | 253 +++++++++++++++++++ test/app_test.dart | 23 ++ test/pantallas/root_header_wiring_test.dart | 208 ++++++++++++++++ test/widgets/pluri_root_header_test.dart | 58 +++++ 11 files changed, 652 insertions(+), 260 deletions(-) create mode 100644 lib/widgets/pluri_root_header.dart create mode 100644 lib/widgets/pluri_sleep_timer_sheet.dart create mode 100644 test/app_test.dart create mode 100644 test/pantallas/root_header_wiring_test.dart create mode 100644 test/widgets/pluri_root_header_test.dart diff --git a/lib/app.dart b/lib/app.dart index dd36bb1..7547a7d 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -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( - 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( - 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 _pedirDuracionPersonalizada(BuildContext context) { - return showModalBottomSheet( - 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 _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().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(), - ), - ); - } } diff --git a/lib/pantallas/pantalla_ajustes.dart b/lib/pantallas/pantalla_ajustes.dart index 3d22f72..fb0e194 100644 --- a/lib/pantallas/pantalla_ajustes.dart +++ b/lib/pantallas/pantalla_ajustes.dart @@ -5,6 +5,8 @@ import '../widgets/pluri_icon.dart'; import '../widgets/pluri_layout.dart'; import '../widgets/pluri_premium_widgets.dart'; import '../widgets/pluri_push_scaffold.dart'; +import '../widgets/pluri_root_header.dart'; +import '../widgets/pluri_sleep_timer_sheet.dart'; import 'ajustes/pantalla_ajustes_backup.dart'; import 'ajustes/pantalla_ajustes_ecualizador.dart'; import 'ajustes/pantalla_ajustes_emisora_preferida.dart'; @@ -29,6 +31,14 @@ class PantallaAjustes extends StatelessWidget { return ListView( padding: PluriLayout.pageListPadding, children: [ + // S1 (Tier 1 visual fidelity): the prototype has no global AppBar — + // this root now draws its own 56px title row instead of relying on + // app.dart's removed shared chrome (which is also where the + // sleep-timer action used to live). + PluriRootHeader( + title: l10n.settingsTitle, + onSleepTimer: () => showPluriSleepTimerSheet(context), + ), PluriScreenHeader( title: l10n.settingsTitle, subtitle: l10n.settingsSubtitle, diff --git a/lib/pantallas/pantalla_alarmas.dart b/lib/pantallas/pantalla_alarmas.dart index 075aaeb..7a0a63d 100644 --- a/lib/pantallas/pantalla_alarmas.dart +++ b/lib/pantallas/pantalla_alarmas.dart @@ -18,6 +18,8 @@ import '../widgets/pluri_icon.dart'; import '../widgets/pluri_layout.dart'; import '../widgets/pluri_premium_widgets.dart'; import '../widgets/pluri_push_scaffold.dart'; +import '../widgets/pluri_root_header.dart'; +import '../widgets/pluri_sleep_timer_sheet.dart'; import 'pantalla_vacaciones.dart'; class PantallaAlarmas extends StatelessWidget { @@ -33,6 +35,14 @@ class PantallaAlarmas extends StatelessWidget { child: ListView( padding: PluriLayout.pageListPadding, children: [ + // S1 (Tier 1 visual fidelity): the prototype has no global + // AppBar — this root now draws its own 56px title row instead of + // relying on app.dart's removed shared chrome (which is also + // where the sleep-timer action used to live). + PluriRootHeader( + title: l10n.alarmScreenTitle, + onSleepTimer: () => showPluriSleepTimerSheet(context), + ), PluriScreenHeader( title: l10n.alarmScreenTitle, subtitle: l10n.alarmScreenSubtitle, diff --git a/lib/pantallas/pantalla_buscar.dart b/lib/pantallas/pantalla_buscar.dart index 11743d2..fdd6e26 100644 --- a/lib/pantallas/pantalla_buscar.dart +++ b/lib/pantallas/pantalla_buscar.dart @@ -12,6 +12,8 @@ import '../widgets/pluri_icon.dart'; import '../widgets/pluri_layout.dart'; import '../widgets/pluri_premium_widgets.dart'; import '../widgets/pluri_push_scaffold.dart'; +import '../widgets/pluri_root_header.dart'; +import '../widgets/pluri_sleep_timer_sheet.dart'; import 'package:pluriwave/widgets/tarjeta_emisora.dart'; import 'pantalla_paises.dart'; @@ -140,6 +142,14 @@ class _PantallaBuscarState extends State { return ListView( padding: PluriLayout.pageListPadding, children: [ + // S1 (Tier 1 visual fidelity): the prototype has no global AppBar — + // this root now draws its own 56px title row instead of relying on + // app.dart's removed shared chrome (which is also where the + // sleep-timer action used to live). + PluriRootHeader( + title: l10n.searchScreenTitle, + onSleepTimer: () => showPluriSleepTimerSheet(context), + ), PluriScreenHeader( title: l10n.searchScreenTitle, subtitle: l10n.searchScreenSubtitle, diff --git a/lib/pantallas/pantalla_favoritos.dart b/lib/pantallas/pantalla_favoritos.dart index d8c4e89..4709990 100644 --- a/lib/pantallas/pantalla_favoritos.dart +++ b/lib/pantallas/pantalla_favoritos.dart @@ -10,6 +10,8 @@ import '../widgets/pluri_icon.dart'; import '../widgets/pluri_layout.dart'; import '../widgets/pluri_premium_widgets.dart'; import '../widgets/pluri_push_scaffold.dart'; +import '../widgets/pluri_root_header.dart'; +import '../widgets/pluri_sleep_timer_sheet.dart'; import 'package:pluriwave/widgets/tarjeta_emisora.dart'; import 'ajustes/pantalla_ajustes_emisoras_personalizadas.dart'; import 'ajustes/pantalla_ajustes_grupos_favoritos.dart'; @@ -113,6 +115,14 @@ class _PantallaFavoritosState extends State { return ListView( padding: PluriLayout.pageListPadding, children: [ + // S1 (Tier 1 visual fidelity): the prototype has no global + // AppBar — this root now draws its own 56px title row instead of + // relying on app.dart's removed shared chrome (which is also + // where the sleep-timer action used to live). + PluriRootHeader( + title: l10n.favoritesTitle, + onSleepTimer: () => showPluriSleepTimerSheet(context), + ), PluriScreenHeader( title: l10n.favoritesTitle, subtitle: l10n.favoritesHeaderSubtitle, @@ -180,6 +190,11 @@ class _PantallaFavoritosState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // S1 (Tier 1 visual fidelity): see the empty-state branch above. + PluriRootHeader( + title: l10n.favoritesTitle, + onSleepTimer: () => showPluriSleepTimerSheet(context), + ), PluriScreenHeader( title: l10n.favoritesTitle, subtitle: l10n.favoritesHeaderSubtitle, diff --git a/lib/pantallas/pantalla_inicio.dart b/lib/pantallas/pantalla_inicio.dart index 64fa210..cca7819 100644 --- a/lib/pantallas/pantalla_inicio.dart +++ b/lib/pantallas/pantalla_inicio.dart @@ -14,6 +14,8 @@ import '../tema/pluriwave_theme.dart'; import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_layout.dart'; import '../widgets/pluri_premium_widgets.dart'; +import '../widgets/pluri_root_header.dart'; +import '../widgets/pluri_sleep_timer_sheet.dart'; import '../widgets/visualizador_audio.dart'; import 'package:pluriwave/widgets/tarjeta_emisora.dart'; @@ -39,6 +41,16 @@ class _PantallaInicioState extends State { return CustomScrollView( slivers: [ + // S1 (Tier 1 visual fidelity): the prototype has no global AppBar — + // this root now draws its own 56px title row instead of relying on + // app.dart's removed shared chrome (which is also where the + // sleep-timer action used to live). + SliverToBoxAdapter( + child: PluriRootHeader( + title: l10n.navHome, + onSleepTimer: () => showPluriSleepTimerSheet(context), + ), + ), // WU5 built the hero; WU6 relocated the discovery sections that // used to follow it (_seccionCercanas, _seccionTendencias, // _chipGeneros, _errorBanner, the browse grid) into @@ -142,7 +154,6 @@ class _PantallaInicioState extends State { ), ); } - } /// WU5, design ADR-7: the Escuchar embedded player. `EstadoRadio` is the diff --git a/lib/widgets/pluri_root_header.dart b/lib/widgets/pluri_root_header.dart new file mode 100644 index 0000000..89df579 --- /dev/null +++ b/lib/widgets/pluri_root_header.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +import '../l10n/gen/app_localizations.dart'; +import '../tema/pluriwave_theme.dart'; + +/// S1 (Tier 1 visual fidelity): the prototype (`t4`) draws no global +/// `AppBar` — each root paints its own 56px title row inside its own +/// content instead (e.g. Alarmas `height:56px;padding:0 12px 0 20px`, +/// Ajustes `height:56px;padding:0 20px`, Explorar `height:56px`). This +/// widget is that row, reused by all 5 roots so the sleep-timer action +/// that used to live on `app.dart`'s single shared `AppBar` stays +/// reachable from every tab. +class PluriRootHeader extends StatelessWidget { + const PluriRootHeader({ + super.key, + required this.title, + required this.onSleepTimer, + }); + + final String title; + final VoidCallback onSleepTimer; + + static const double height = 56; + + @override + Widget build(BuildContext context) { + final type = context.pluriType; + final l10n = AppLocalizations.of(context); + return SizedBox( + height: height, + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 12, 0), + child: Row( + children: [ + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: type.sectionTitle, + ), + ), + IconButton( + icon: const Icon(Icons.bedtime_outlined), + tooltip: l10n.sleepTimer, + onPressed: onSleepTimer, + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/pluri_sleep_timer_sheet.dart b/lib/widgets/pluri_sleep_timer_sheet.dart new file mode 100644 index 0000000..577a466 --- /dev/null +++ b/lib/widgets/pluri_sleep_timer_sheet.dart @@ -0,0 +1,253 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../estado/estado_radio.dart'; +import '../l10n/gen/app_localizations.dart'; +import 'pluri_layout.dart'; + +/// S1 (Tier 1 visual fidelity): extracted from `app.dart`'s old +/// `_mostrarTimerDialog`, which only the single global `AppBar` could reach. +/// Now that each root draws its own [PluriRootHeader] instead, this is a +/// free function any of them can call directly with their own +/// `BuildContext` — the sleep-timer feature stays reachable from every tab +/// with no behaviour change. +void showPluriSleepTimerSheet(BuildContext context) { + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: + (ctx) => Consumer( + 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( + 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 _pedirDuracionPersonalizada(BuildContext context) { + return showModalBottomSheet( + 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 _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().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(), + ), + ); + } +} diff --git a/test/app_test.dart b/test/app_test.dart new file mode 100644 index 0000000..9e2ae3e --- /dev/null +++ b/test/app_test.dart @@ -0,0 +1,23 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// S1 (Tier 1 visual fidelity): the prototype (`t4`) never draws a global +/// `AppBar` — every root owns its own 56px title row instead (see +/// `PluriRootHeader` and `root_header_wiring_test.dart`). `app.dart` used to +/// wrap every tab in `PluriWaveScaffold(appBar: AppBar(...))`; this is a +/// fast source-level regression guard for that removal, since the widget +/// under it (`_PaginaPrincipal`) is library-private and constructs real +/// platform-backed services, so it cannot be safely widget-tested here. +void main() { + test('app.dart no longer constructs a global Material AppBar', () { + final source = File('lib/app.dart').readAsStringSync(); + expect( + source.contains('AppBar('), + isFalse, + reason: + 'the prototype has no global app bar — each root draws its own ' + 'PluriRootHeader inside its content instead', + ); + }); +} diff --git a/test/pantallas/root_header_wiring_test.dart b/test/pantallas/root_header_wiring_test.dart new file mode 100644 index 0000000..6c13b33 --- /dev/null +++ b/test/pantallas/root_header_wiring_test.dart @@ -0,0 +1,208 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_alarmas.dart'; +import 'package:pluriwave/estado/estado_busqueda.dart'; +import 'package:pluriwave/estado/estado_ecualizador.dart'; +import 'package:pluriwave/estado/estado_grabacion.dart'; +import 'package:pluriwave/estado/estado_radio.dart'; +import 'package:pluriwave/l10n/gen/app_localizations.dart'; +import 'package:pluriwave/pantallas/pantalla_ajustes.dart'; +import 'package:pluriwave/pantallas/pantalla_alarmas.dart'; +import 'package:pluriwave/pantallas/pantalla_buscar.dart'; +import 'package:pluriwave/pantallas/pantalla_favoritos.dart'; +import 'package:pluriwave/pantallas/pantalla_inicio.dart'; +import 'package:pluriwave/servicios/servicio_alarmas.dart'; +import 'package:pluriwave/widgets/pluri_root_header.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../helpers/fakes.dart'; +import '../helpers/fakes_alarmas.dart'; + +/// S1 (Tier 1 visual fidelity): `app.dart`'s `PluriWaveScaffold(appBar: +/// AppBar(...))` is gone — every root now draws its own [PluriRootHeader] +/// instead, matching the prototype (no screen in `t4` shows a global +/// `AppBar`). Each root still ALSO carries its pre-existing +/// `PluriScreenHeader` hero (S2 in this same Tier 1 batch replaces that +/// separately), so title assertions below are scoped to [PluriRootHeader]'s +/// own subtree — the title string itself still appears twice on screen +/// until S2 lands. +/// +/// Pre-existing project constraint (see `pantalla_ajustes_test.dart`): +/// PluriGlassSurface paints a background over ListTile's ink layer, which +/// Flutter flags as a warning-level assertion, not a correctness bug. +void _suppressListTileInkAssertion() { + final original = FlutterError.onError; + FlutterError.onError = (details) { + if (details.exceptionAsString().contains( + 'ListTile background color or ink splashes may be invisible', + )) { + return; + } + original?.call(details); + }; + addTearDown(() => FlutterError.onError = original); +} + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + Future archivoCustomVacio() async => File( + '${Directory.current.path}/test/fixtures/emisoras_custom_vacio.json', + ); + + EstadoRadio crearEstadoRadio() => EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + servicioGrabacion: FakeServicioGrabacionRadioInactiva(), + resolverArchivoCustom: archivoCustomVacio, + iniciarAutomaticamente: false, + ); + + Widget testApp(EstadoRadio estado, Widget body, {EstadoAlarmas? alarmas}) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: estado), + ListenableProvider.value(value: estado.ecualizador), + ListenableProvider.value(value: estado.grabacion), + ListenableProvider.value(value: estado.busqueda), + if (alarmas != null) + ChangeNotifierProvider.value(value: alarmas), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: body), + ), + ); + } + + void setLargeSurface(WidgetTester tester) { + tester.view.physicalSize = const Size(1440, 3200); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + } + + Future pumpStable(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + } + + Finder titleInHeader(String title) => find.descendant( + of: find.byType(PluriRootHeader), + matching: find.text(title), + ); + + testWidgets( + 'Escuchar draws its own PluriRootHeader with the tab title, no AppBar', + (tester) async { + _suppressListTileInkAssertion(); + setLargeSurface(tester); + final estado = crearEstadoRadio(); + addTearDown(estado.dispose); + + await tester.pumpWidget(testApp(estado, const PantallaInicio())); + await pumpStable(tester); + + expect(find.byType(PluriRootHeader), findsOneWidget); + expect(titleInHeader('Listen'), findsOneWidget); + expect(find.byType(AppBar), findsNothing); + }, + ); + + testWidgets( + 'Buscar draws its own PluriRootHeader with the search title, no AppBar', + (tester) async { + _suppressListTileInkAssertion(); + setLargeSurface(tester); + final estado = crearEstadoRadio(); + addTearDown(estado.dispose); + + await tester.pumpWidget(testApp(estado, const PantallaBuscar())); + await pumpStable(tester); + + expect(find.byType(PluriRootHeader), findsOneWidget); + expect(titleInHeader('Search signal'), findsOneWidget); + expect(find.byType(AppBar), findsNothing); + }, + ); + + testWidgets( + 'Favoritos (empty state) draws its own PluriRootHeader, no AppBar', + (tester) async { + _suppressListTileInkAssertion(); + setLargeSurface(tester); + final estado = crearEstadoRadio(); + addTearDown(estado.dispose); + + await tester.pumpWidget(testApp(estado, const PantallaFavoritos())); + await pumpStable(tester); + + expect(find.byType(PluriRootHeader), findsOneWidget); + expect(titleInHeader('Favorites'), findsOneWidget); + expect(find.byType(AppBar), findsNothing); + }, + ); + + testWidgets('Alarmas draws its own PluriRootHeader, no AppBar', ( + tester, + ) async { + _suppressListTileInkAssertion(); + setLargeSurface(tester); + final estado = crearEstadoRadio(); + final alarmas = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 1, 6, 0)), + android: FakePuertoAlarmasAndroid(), + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(alarmas.dispose); + + await tester.pumpWidget( + testApp(estado, const PantallaAlarmas(), alarmas: alarmas), + ); + await pumpStable(tester); + + expect(find.byType(PluriRootHeader), findsOneWidget); + expect(titleInHeader('Music wake-up'), findsOneWidget); + expect(find.byType(AppBar), findsNothing); + }); + + testWidgets( + 'Ajustes draws its own PluriRootHeader, no AppBar, and its bedtime ' + 'action opens the sleep-timer sheet', + (tester) async { + _suppressListTileInkAssertion(); + setLargeSurface(tester); + final estado = crearEstadoRadio(); + addTearDown(estado.dispose); + + await tester.pumpWidget(testApp(estado, const PantallaAjustes())); + await pumpStable(tester); + + expect(find.byType(PluriRootHeader), findsOneWidget); + expect(titleInHeader('Settings'), findsOneWidget); + expect(find.byType(AppBar), findsNothing); + + await tester.tap(find.byIcon(Icons.bedtime_outlined)); + await pumpStable(tester); + + // Not `find.text('Sleep timer')` — Ajustes' own "Sleep timer" + // FilaAjuste row (`l10n.timerSectionTitle`) coincidentally shares the + // exact same string as `l10n.sleepTimer`. The description line is + // unique to the sheet this action opens. + expect( + find.text('Smooth radio shutdown with an exact countdown.'), + findsOneWidget, + ); + }, + ); +} diff --git a/test/widgets/pluri_root_header_test.dart b/test/widgets/pluri_root_header_test.dart new file mode 100644 index 0000000..654ae0a --- /dev/null +++ b/test/widgets/pluri_root_header_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/l10n/gen/app_localizations.dart'; +import 'package:pluriwave/tema/pluriwave_theme.dart'; +import 'package:pluriwave/widgets/pluri_root_header.dart'; + +/// S1 (Tier 1 visual fidelity): the prototype draws no global `AppBar` — +/// each root instead owns a plain 56px title row inside its own content +/// (`t4`, e.g. Alarmas line 325 `height:56px`, Ajustes line 511, Explorar +/// line 641). [PluriRootHeader] is that row, shared by all 5 roots so the +/// sleep-timer action that used to live on `app.dart`'s single global +/// `AppBar` stays reachable from every tab. +void main() { + Widget host(Widget child) { + return MaterialApp( + theme: PluriWaveTheme.dark(), + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: child), + ); + } + + testWidgets('renders the given title and is exactly 56px tall', ( + tester, + ) async { + await tester.pumpWidget( + host(PluriRootHeader(title: 'Settings', onSleepTimer: () {})), + ); + + expect(find.text('Settings'), findsOneWidget); + expect(PluriRootHeader.height, 56); + final size = tester.getSize(find.byType(PluriRootHeader)); + expect(size.height, 56); + }); + + testWidgets('exposes a bedtime action that invokes onSleepTimer when ' + 'tapped', (tester) async { + var tapped = false; + await tester.pumpWidget( + host(PluriRootHeader(title: 'Alarms', onSleepTimer: () => tapped = true)), + ); + + expect(find.byIcon(Icons.bedtime_outlined), findsOneWidget); + await tester.tap(find.byIcon(Icons.bedtime_outlined)); + await tester.pump(); + + expect(tapped, isTrue); + }); + + testWidgets('never builds a Material AppBar', (tester) async { + await tester.pumpWidget( + host(PluriRootHeader(title: 'Search signal', onSleepTimer: () {})), + ); + + expect(find.byType(AppBar), findsNothing); + }); +}