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:
-259
@@ -217,16 +217,6 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal>
|
|||||||
final indice = navegacion.indice;
|
final indice = navegacion.indice;
|
||||||
|
|
||||||
return PluriWaveScaffold(
|
return PluriWaveScaffold(
|
||||||
appBar: AppBar(
|
|
||||||
title: Text(l10n.appTitle),
|
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.bedtime_outlined),
|
|
||||||
tooltip: l10n.sleepTimer,
|
|
||||||
onPressed: () => _mostrarTimerDialog(context),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: AnimatedSwitcher(
|
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(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import '../widgets/pluri_icon.dart';
|
|||||||
import '../widgets/pluri_layout.dart';
|
import '../widgets/pluri_layout.dart';
|
||||||
import '../widgets/pluri_premium_widgets.dart';
|
import '../widgets/pluri_premium_widgets.dart';
|
||||||
import '../widgets/pluri_push_scaffold.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_backup.dart';
|
||||||
import 'ajustes/pantalla_ajustes_ecualizador.dart';
|
import 'ajustes/pantalla_ajustes_ecualizador.dart';
|
||||||
import 'ajustes/pantalla_ajustes_emisora_preferida.dart';
|
import 'ajustes/pantalla_ajustes_emisora_preferida.dart';
|
||||||
@@ -29,6 +31,14 @@ class PantallaAjustes extends StatelessWidget {
|
|||||||
return ListView(
|
return ListView(
|
||||||
padding: PluriLayout.pageListPadding,
|
padding: PluriLayout.pageListPadding,
|
||||||
children: [
|
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(
|
PluriScreenHeader(
|
||||||
title: l10n.settingsTitle,
|
title: l10n.settingsTitle,
|
||||||
subtitle: l10n.settingsSubtitle,
|
subtitle: l10n.settingsSubtitle,
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import '../widgets/pluri_icon.dart';
|
|||||||
import '../widgets/pluri_layout.dart';
|
import '../widgets/pluri_layout.dart';
|
||||||
import '../widgets/pluri_premium_widgets.dart';
|
import '../widgets/pluri_premium_widgets.dart';
|
||||||
import '../widgets/pluri_push_scaffold.dart';
|
import '../widgets/pluri_push_scaffold.dart';
|
||||||
|
import '../widgets/pluri_root_header.dart';
|
||||||
|
import '../widgets/pluri_sleep_timer_sheet.dart';
|
||||||
import 'pantalla_vacaciones.dart';
|
import 'pantalla_vacaciones.dart';
|
||||||
|
|
||||||
class PantallaAlarmas extends StatelessWidget {
|
class PantallaAlarmas extends StatelessWidget {
|
||||||
@@ -33,6 +35,14 @@ class PantallaAlarmas extends StatelessWidget {
|
|||||||
child: ListView(
|
child: ListView(
|
||||||
padding: PluriLayout.pageListPadding,
|
padding: PluriLayout.pageListPadding,
|
||||||
children: [
|
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(
|
PluriScreenHeader(
|
||||||
title: l10n.alarmScreenTitle,
|
title: l10n.alarmScreenTitle,
|
||||||
subtitle: l10n.alarmScreenSubtitle,
|
subtitle: l10n.alarmScreenSubtitle,
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import '../widgets/pluri_icon.dart';
|
|||||||
import '../widgets/pluri_layout.dart';
|
import '../widgets/pluri_layout.dart';
|
||||||
import '../widgets/pluri_premium_widgets.dart';
|
import '../widgets/pluri_premium_widgets.dart';
|
||||||
import '../widgets/pluri_push_scaffold.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 'package:pluriwave/widgets/tarjeta_emisora.dart';
|
||||||
|
|
||||||
import 'pantalla_paises.dart';
|
import 'pantalla_paises.dart';
|
||||||
@@ -140,6 +142,14 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
|||||||
return ListView(
|
return ListView(
|
||||||
padding: PluriLayout.pageListPadding,
|
padding: PluriLayout.pageListPadding,
|
||||||
children: [
|
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(
|
PluriScreenHeader(
|
||||||
title: l10n.searchScreenTitle,
|
title: l10n.searchScreenTitle,
|
||||||
subtitle: l10n.searchScreenSubtitle,
|
subtitle: l10n.searchScreenSubtitle,
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import '../widgets/pluri_icon.dart';
|
|||||||
import '../widgets/pluri_layout.dart';
|
import '../widgets/pluri_layout.dart';
|
||||||
import '../widgets/pluri_premium_widgets.dart';
|
import '../widgets/pluri_premium_widgets.dart';
|
||||||
import '../widgets/pluri_push_scaffold.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 'package:pluriwave/widgets/tarjeta_emisora.dart';
|
||||||
import 'ajustes/pantalla_ajustes_emisoras_personalizadas.dart';
|
import 'ajustes/pantalla_ajustes_emisoras_personalizadas.dart';
|
||||||
import 'ajustes/pantalla_ajustes_grupos_favoritos.dart';
|
import 'ajustes/pantalla_ajustes_grupos_favoritos.dart';
|
||||||
@@ -113,6 +115,14 @@ class _PantallaFavoritosState extends State<PantallaFavoritos> {
|
|||||||
return ListView(
|
return ListView(
|
||||||
padding: PluriLayout.pageListPadding,
|
padding: PluriLayout.pageListPadding,
|
||||||
children: [
|
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(
|
PluriScreenHeader(
|
||||||
title: l10n.favoritesTitle,
|
title: l10n.favoritesTitle,
|
||||||
subtitle: l10n.favoritesHeaderSubtitle,
|
subtitle: l10n.favoritesHeaderSubtitle,
|
||||||
@@ -180,6 +190,11 @@ class _PantallaFavoritosState extends State<PantallaFavoritos> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
// S1 (Tier 1 visual fidelity): see the empty-state branch above.
|
||||||
|
PluriRootHeader(
|
||||||
|
title: l10n.favoritesTitle,
|
||||||
|
onSleepTimer: () => showPluriSleepTimerSheet(context),
|
||||||
|
),
|
||||||
PluriScreenHeader(
|
PluriScreenHeader(
|
||||||
title: l10n.favoritesTitle,
|
title: l10n.favoritesTitle,
|
||||||
subtitle: l10n.favoritesHeaderSubtitle,
|
subtitle: l10n.favoritesHeaderSubtitle,
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import '../tema/pluriwave_theme.dart';
|
|||||||
import '../widgets/pluri_glass_surface.dart';
|
import '../widgets/pluri_glass_surface.dart';
|
||||||
import '../widgets/pluri_layout.dart';
|
import '../widgets/pluri_layout.dart';
|
||||||
import '../widgets/pluri_premium_widgets.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 '../widgets/visualizador_audio.dart';
|
||||||
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
|
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
|
||||||
|
|
||||||
@@ -39,6 +41,16 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
|||||||
|
|
||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
slivers: [
|
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
|
// WU5 built the hero; WU6 relocated the discovery sections that
|
||||||
// used to follow it (_seccionCercanas, _seccionTendencias,
|
// used to follow it (_seccionCercanas, _seccionTendencias,
|
||||||
// _chipGeneros, _errorBanner, the browse grid) into
|
// _chipGeneros, _errorBanner, the browse grid) into
|
||||||
@@ -142,7 +154,6 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// WU5, design ADR-7: the Escuchar embedded player. `EstadoRadio` is the
|
/// WU5, design ADR-7: the Escuchar embedded player. `EstadoRadio` is the
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<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(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<File> 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<EstadoRadio>.value(value: estado),
|
||||||
|
ListenableProvider<EstadoEcualizador>.value(value: estado.ecualizador),
|
||||||
|
ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion),
|
||||||
|
ListenableProvider<EstadoBusqueda>.value(value: estado.busqueda),
|
||||||
|
if (alarmas != null)
|
||||||
|
ChangeNotifierProvider<EstadoAlarmas>.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<void> 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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user