Author SHA1 Message Date
FreeTLab 97e38becfe fix(chrome): retire PluriScreenHeader, it has no equivalent in the prototype
PluriScreenHeader was a 38-radius glass hero: an aurora banner at 24%
opacity, a black-to-transparent scrim, two radial orbs, a 120px app-mark
watermark and a 56px tri-gradient glyph badge. None of it is in the
prototype (t4) -- every root's title is plain text in its own 56px
row, which PluriRootHeader (S1) already provides. Delete the class
(and its now-orphaned _Orb helper) and its four call sites (Buscar,
Favoritos x2, Alarmas, Ajustes landed with S8).

Two of those call sites carried the hero's only functional bit besides
the title, so PluriRootHeader gains an `actions` slot (rendered before
the shared bedtime button) to keep them reachable:
  - Alarmas' create-alarm button (FilledButton.tonalIcon, unchanged
    shape, just relocated)
  - Buscar's filters entry point (the same tappable PluriStatusPill,
    just relocated)
Every other retired trailing pill (Ajustes' "Secure" status,
Favoritos' collection-count badges, Alarmas' alarm-count badge) was
purely decorative and matches the prototype by simply disappearing.

S2, Tier 1 visual-fidelity pass (audit id 2521).
2026-07-29 21:29:49 +02:00
FreeTLab 7ebe0b77a4 fix(layout): introduce the prototype's 3-tier horizontal padding scale
The prototype runs three horizontal padding tiers (t4): 20px for
section titles/eyebrows (lines 153, 254, 299, 511), 16px for cards
(lines 327, 512, 610), 12px for background-less list rows (lines 174,
226, 301). The build had collapsed all three into a single
PluriLayout.horizontal = 16, used everywhere regardless of context.

Add PluriLayout.titleHorizontal (20) and PluriLayout.rowHorizontal
(12) alongside the existing `horizontal` (16, unchanged -- it already
covers the card tier). horizontal keeps every one of its ~30 existing
call sites unchanged.

Committed ahead of S2 (item 6 in this pass) because that item's
PluriRootHeader edit reuses titleHorizontal/rowHorizontal for the
header's own padding, matching the prototype's own header spec exactly
(e.g. Alarmas padding:0 12px 0 20px) -- a real dependency, not just
numbering.

S5, Tier 1 visual-fidelity pass (audit id 2521).
2026-07-29 21:28:48 +02:00
FreeTLab e0164f68b5 fix(ajustes): show each settings row's current value
The prototype puts a trailing current-value string on nearly every
settings row (t4 lines 512-539, 625 -- "3 guardados", "Alfabetico",
"Espanol", "7 . 84 MB"). FilaAjuste only accepted icon/titulo/onTap,
so every row was value-blind.

Add an optional `valor` slot to FilaAjuste (13px, rgba(242,247,250,.55),
rendered before the chevron). Wire 8 of the 12 built rows to state
already available at the settings root: equalizer on/off, sleep-timer
active, favourite-group count, preferred station name, custom-station
count, sort order, recordings count-and-size (FutureBuilder over
EstadoGrabacion.listarGrabaciones), and the current language (hoisted
pantalla_ajustes_idioma.dart's native-name list to module level so the
root can read it without duplicating it). Salida de audio, Musica
local, Backup and Info's version are left without a value -- each
lacks a low-risk, deterministically-testable data source (see the
apply-progress note for the reason per row).

Reading EstadoRadio for these values through a root `context.watch`
would rebuild the whole settings list -- including the Grabaciones
FutureBuilder's disk read -- on every unrelated audio notification;
this follows the codebase's existing S4-R5 convention of narrow
`context.select` per field instead.

Ajustes' own PluriRootHeader/PluriScreenHeader edit (S2 in this same
pass) landed in this commit too, since both touched the same header
block in pantalla_ajustes.dart at the same time.

S8, Tier 1 visual-fidelity pass (audit id 2521).
2026-07-29 21:26:48 +02:00
FreeTLab f5a211492a fix(typography): bake the prototype colour into eyebrowLabel
The prototype's eyebrows are always rgba(242,247,250,.42) (t4 lines
254, 299, 381, 450, 511, 660). eyebrowLabel set size/weight/letter-
spacing only, no colour, so every call site (settings group headers,
vacation section titles, the ringing screen's snooze label) rendered
at whatever full-opacity default the ambient text theme resolved to.

One factory-level change fixes every current and future consumer of
PluriWaveTypography.eyebrowLabel — no call site needs editing.

S9, Tier 1 visual-fidelity pass (audit id 2521).
2026-07-29 20:34:09 +02:00
FreeTLab d0660a4966 fix(stations): replace the ringed circle thumbnail with a plain square
The prototype's station thumbnail is a plain 44-48px square, radius
11/12, with no ring or glow (t4 lines 84, 175, 227, 302, 614).
TarjetaEmisora's compact row variant instead painted a 58x58 circle
wrapped in a SweepGradient ring (magenta/cyan/coral) and a 22-blur
glow behind a 50x50 ClipRRect(18).

Replace it with a 48x48 ClipRRect(12) square. Update the loading
shimmer placeholder to match (was also a 58x58 circle block).

S6, Tier 1 visual-fidelity pass (audit id 2521).
2026-07-29 20:29:31 +02:00
FreeTLab 5a5f1655a9 fix(surfaces): make list/card surfaces opaque by default, fix card radius
The prototype states an explicit system rule (t4 line 40): "opaque list
surface #102532, glass only in the chrome and in the active card."
PluriGlassSurface backs nearly every card/row in the app, but always
rendered translucent + blurred (glassSurface, blur 18) — the listSurface
token (#102532) existed since WU1 but was only ever used at reduced
alpha, never opaquely.

Add a `glass` flag to PluriGlassSurface, defaulting to false: an opaque
listSurface fill with no BackdropFilter. Chrome (MiniReproductor) and
the active/now-playing card (Escuchar's hero, when a station is
playing) opt into the old translucent look via `glass: true` — every
other of the ~26 call sites needs no change and now renders opaquely.

Also fix the card radius: the prototype's dominant card radius is 18
(t4 lines 512, 613, 715, 133); radiusMd was 22, a systematic +4px drift
across every card that defaults to it. Retune TarjetaEmisora's own
radius ternary so its compact (row) variant uses the dominant row
radius, 14, instead of inheriting the card radius.

S3+S4, Tier 1 visual-fidelity pass (audit id 2521).
2026-07-29 20:23:55 +02:00
FreeTLab 78a7415dd8 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).
2026-07-29 20:17:24 +02:00
FreeTLab b8f078bc14 feat(nav): rebuild the bottom bar as the prototype's balloon bar
The functional redesign never touched pluri_bottom_navigation.dart, so the
bar kept the old glass + magenta/coral language while every other surface
moved on. Rebuilds it to prototype t4/4a: a 52px opaque #0A1B24 pill with a
110x74 balloon raised over the active tab, a teal radial glow behind it, the
active item lifted 15px with its label, and inactive items reduced to a 23px
icon at 46% opacity.

The five tabs and their PluriIconGlyph icons are unchanged, per the standing
decision — only the bar's shape, colour and behaviour move.

altura and PluriLayout.bottomChromeInset are both asserted against the real
laid-out height rather than hardcoded guesses.
2026-07-29 20:00:30 +02:00
FreeTLab c01c518541 fix(favoritos): use the cross-version onReorder API and correct drag index math
Build & Deploy PluriWave / Análisis de código (push) Successful in 24s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m26s
The CI Flutter SDK predates v3.41 and only exposes ReorderableListView's
onReorder; the newer onReorderItem broke the build with three analyzer
errors. onReorder exists in both SDKs, so use it and compensate for its
pre-removal newIndex internally.

Fixing the call site surfaced a real ordering bug: _onReorder located the
target neighbour in the untrimmed global list, while ServicioFavoritos
.reordenar inserts into the list after the station is removed. Dragging a
station downwards past its neighbours therefore landed it one slot too far.
Adds a mid-list downward-drag test, the only case that separates the two
coordinate spaces.
2026-07-29 18:35:08 +02:00
FreeTLab 40c2763061 feat: functional redesign of the whole app (1.2.0)
Build & Deploy PluriWave / Análisis de código (push) Failing after 16s
Build & Deploy PluriWave / Build APK + AAB release (push) Skipped
Adopts the approved Claude Design handoff across every screen while keeping
the existing five-tab navigation and icon set.

- Design tokens, named type scale and a shared push-chrome scaffold
- Settings split from 1897 lines into a 198-line root plus 12 detail screens
- Escuchar gains an embedded player and a favorites grid
- Buscar gains the discovery landing state, filter pills and client-side sort
- New screens: countries browser, vacation manager, recordings library, welcome
- Equalizer restyled at 5 bands with custom presets; full player restructured
- Alarms, ringing screen and connectivity banners restyled
- All 466 message keys translated across 13 locales

Tests: 530 -> 747. Analyzer unchanged from baseline.
2026-07-29 18:18:33 +02:00
35 changed files with 2324 additions and 743 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(),
),
);
}
}
@@ -28,32 +28,60 @@ class PantallaAjustesIdioma extends StatelessWidget {
}
}
/// S8 (Tier 1 visual fidelity): the "system" pseudo-code, the native-name
/// list and the locale<->code mapping used to be private to this file's
/// `_CuerpoIdioma`. Hoisted to module level (unchanged values/logic) so
/// `pantalla_ajustes.dart`'s Idioma row can show the CURRENT language's
/// native name as its trailing value (t4's own example, line 526:
/// "Español") without duplicating this list.
const codigoIdiomaSistema = 'system';
const idiomasDisponibles = [
IdiomaDisponible(Locale('en'), 'English'),
IdiomaDisponible(Locale('es'), 'Español'),
IdiomaDisponible(Locale('zh'), '中文'),
IdiomaDisponible(Locale('hi'), 'हिन्दी'),
IdiomaDisponible(Locale('ar'), 'العربية'),
IdiomaDisponible(Locale('pt'), 'Português'),
IdiomaDisponible(Locale('fr'), 'Français'),
IdiomaDisponible(Locale('ru'), 'Русский'),
IdiomaDisponible(Locale('de'), 'Deutsch'),
IdiomaDisponible(Locale('ja'), '日本語'),
IdiomaDisponible(Locale('id'), 'Bahasa Indonesia'),
IdiomaDisponible(Locale('bn'), 'বাংলা'),
IdiomaDisponible(Locale('it'), 'Italiano'),
];
String codigoLocaleIdioma(Locale locale) {
final countryCode = locale.countryCode;
if (countryCode == null || countryCode.isEmpty) {
return locale.languageCode;
}
return '${locale.languageCode}_$countryCode';
}
/// The current language's own native name (e.g. "Español"), or the
/// localized "system default" label when [locale] is null.
String nombreIdiomaActual(Locale? locale, AppLocalizations l10n) {
if (locale == null) return l10n.languageSystemDefault;
final codigo = codigoLocaleIdioma(locale);
final idioma = idiomasDisponibles.firstWhere(
(item) => codigoLocaleIdioma(item.locale) == codigo,
orElse: () => idiomasDisponibles.first,
);
return idioma.nombreNativo;
}
class _CuerpoIdioma extends StatelessWidget {
const _CuerpoIdioma();
static const _codigoSistema = 'system';
static const _idiomas = [
_IdiomaDisponible(Locale('en'), 'English'),
_IdiomaDisponible(Locale('es'), 'Español'),
_IdiomaDisponible(Locale('zh'), '中文'),
_IdiomaDisponible(Locale('hi'), 'हिन्दी'),
_IdiomaDisponible(Locale('ar'), 'العربية'),
_IdiomaDisponible(Locale('pt'), 'Português'),
_IdiomaDisponible(Locale('fr'), 'Français'),
_IdiomaDisponible(Locale('ru'), 'Русский'),
_IdiomaDisponible(Locale('de'), 'Deutsch'),
_IdiomaDisponible(Locale('ja'), '日本語'),
_IdiomaDisponible(Locale('id'), 'Bahasa Indonesia'),
_IdiomaDisponible(Locale('bn'), 'বাংলা'),
_IdiomaDisponible(Locale('it'), 'Italiano'),
];
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final estadoIdioma = context.watch<EstadoIdioma>();
final locale = estadoIdioma.localeSeleccionado;
final valorActual = locale == null ? _codigoSistema : _codigoLocale(locale);
final valorActual =
locale == null ? codigoIdiomaSistema : codigoLocaleIdioma(locale);
return PluriGlassSurface(
child: Column(
@@ -73,18 +101,18 @@ class _CuerpoIdioma extends StatelessWidget {
),
items: [
DropdownMenuItem(
value: _codigoSistema,
value: codigoIdiomaSistema,
child: Text(l10n.languageSystemDefault),
),
for (final idioma in _idiomas)
for (final idioma in idiomasDisponibles)
DropdownMenuItem(
value: _codigoLocale(idioma.locale),
value: codigoLocaleIdioma(idioma.locale),
child: Text(idioma.nombreNativo),
),
],
onChanged: (codigo) async {
if (codigo == null) return;
if (codigo == _codigoSistema) {
if (codigo == codigoIdiomaSistema) {
await context.read<EstadoIdioma>().seleccionarSistema();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
@@ -93,9 +121,9 @@ class _CuerpoIdioma extends StatelessWidget {
return;
}
final idioma = _idiomas.firstWhere(
(item) => _codigoLocale(item.locale) == codigo,
orElse: () => _idiomas.first,
final idioma = idiomasDisponibles.firstWhere(
(item) => codigoLocaleIdioma(item.locale) == codigo,
orElse: () => idiomasDisponibles.first,
);
await context.read<EstadoIdioma>().seleccionarLocale(
idioma.locale,
@@ -113,18 +141,10 @@ class _CuerpoIdioma extends StatelessWidget {
),
);
}
static String _codigoLocale(Locale locale) {
final countryCode = locale.countryCode;
if (countryCode == null || countryCode.isEmpty) {
return locale.languageCode;
}
return '${locale.languageCode}_$countryCode';
}
}
class _IdiomaDisponible {
const _IdiomaDisponible(this.locale, this.nombreNativo);
class IdiomaDisponible {
const IdiomaDisponible(this.locale, this.nombreNativo);
final Locale locale;
final String nombreNativo;
+35 -5
View File
@@ -17,7 +17,12 @@ class GrupoAjustes extends StatelessWidget {
/// already in its display form — this style never applies `toUpperCase()`.
final String titulo;
final List<FilaAjuste> filas;
/// S8 (Tier 1 visual fidelity): `Widget`, not `List<FilaAjuste>` — a few
/// rows source their current-value text asynchronously (e.g. recordings
/// count, app version) and wrap their own `FilaAjuste` in a
/// `FutureBuilder`. `GrupoAjustes` only iterates and inserts dividers; it
/// never reaches into `FilaAjuste`-specific state.
final List<Widget> filas;
@override
Widget build(BuildContext context) {
@@ -38,29 +43,54 @@ class GrupoAjustes extends StatelessWidget {
}
}
/// A single Settings navigation row: icon, title, and a trailing chevron.
/// Tapping it is the row's only behaviour — it carries no switches, sliders
/// or text fields, which is what "zero inline controls" means at the root.
/// A single Settings navigation row: icon, title, an optional trailing
/// current-value string, and a trailing chevron. Tapping it is the row's
/// only behaviour — it carries no switches, sliders or text fields, which
/// is what "zero inline controls" means at the root.
class FilaAjuste extends StatelessWidget {
const FilaAjuste({
super.key,
required this.icon,
required this.titulo,
required this.onTap,
this.valor,
});
final IconData icon;
final String titulo;
final VoidCallback onTap;
/// S8 (Tier 1 visual fidelity): the prototype puts a trailing current
/// value on nearly every row (t4 lines 512-539, 625 — e.g. "3 guardados",
/// "Alfabético", "Español"), 13px `rgba(242,247,250,.55)`. Null means
/// "no current value to show" — the row renders exactly as before.
final String? valor;
@override
Widget build(BuildContext context) {
final type = context.pluriType;
final valorActual = valor;
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(icon),
title: Text(titulo, style: type.cardTitle),
trailing: const Icon(Icons.chevron_right_rounded),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (valorActual != null) ...[
Text(
valorActual,
// bodyStrong is already 13/w600, matching the prototype's row
// value spec exactly — only the colour needs overriding.
style: type.bodyStrong.copyWith(
color: const Color(0xFFF2F7FA).withValues(alpha: 0.55),
),
),
const SizedBox(width: 6),
],
const Icon(Icons.chevron_right_rounded),
],
),
onTap: onTap,
);
}
+113 -22
View File
@@ -1,10 +1,18 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../estado/estado_ecualizador.dart';
import '../estado/estado_grabacion.dart';
import '../estado/estado_idioma.dart';
import '../estado/estado_radio.dart';
import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.dart';
import '../widgets/pluri_icon.dart';
import '../modelos/archivo_grabacion.dart';
import '../modelos/emisora.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,14 +37,13 @@ class PantallaAjustes extends StatelessWidget {
return ListView(
padding: PluriLayout.pageListPadding,
children: [
PluriScreenHeader(
// 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,
subtitle: l10n.settingsSubtitle,
glyph: PluriIconGlyph.settings,
trailing: PluriStatusPill(
icon: Icons.security_rounded,
label: l10n.settingsSafeStatus,
),
onSleepTimer: () => showPluriSleepTimerSheet(context),
),
const Padding(
padding: PluriLayout.pageContentPadding,
@@ -58,6 +65,39 @@ class _AjustesContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
// S8 (Tier 1 visual fidelity): the prototype shows every row's current
// value (t4 lines 512-539, 625). 8 of the 12 rows below read it from
// state already provided at the app root — no new providers needed.
//
// S4-R5 convention (see pantalla_inicio.dart, pantalla_buscar.dart):
// `context.select` per scalar, NOT a root `context.watch<EstadoRadio>()`
// — EstadoRadio also notifies on audio buffer/position events, which
// this screen has nothing to do with. A root watch here rebuilds the
// WHOLE settings list (including the Grabaciones row's FutureBuilder,
// which would re-issue `listarGrabaciones()` on every single one of
// those unrelated notifications) far more often than intended.
final gruposCount = context.select<EstadoRadio, int>(
(e) => e.gruposFavoritos.length,
);
final emisoraPreferida = context.select<EstadoRadio, Emisora?>(
(e) => e.emisoraPreferida,
);
final emisorasCustomCount = context.select<EstadoRadio, int>(
(e) => e.emisorasCustom.length,
);
final ordenListas = context.select<EstadoRadio, OrdenEmisoras>(
(e) => e.ordenListas,
);
final timerActivo = context.select<EstadoRadio, bool>(
(e) => e.timer.activo,
);
final ecualizadorActivo = context.select<EstadoEcualizador, bool>(
(e) => e.activo,
);
final grabacion = context.watch<EstadoGrabacion>();
final idioma = context.select<EstadoIdioma, Locale?>(
(e) => e.localeSeleccionado,
);
return Column(
children: [
@@ -67,12 +107,18 @@ class _AjustesContent extends StatelessWidget {
FilaAjuste(
icon: Icons.equalizer_rounded,
titulo: l10n.equalizerTitle,
valor:
ecualizadorActivo
? l10n.equalizerActive
: l10n.equalizerDisabled,
onTap:
() => PluriPushScaffold.push(
context,
(_) => const PantallaAjustesEcualizador(),
),
),
// No `valor`: per-device output naming needs a friendly-name
// lookup this root doesn't have (only a raw device id).
FilaAjuste(
icon: Icons.devices_rounded,
titulo: l10n.advancedEqSectionTitle,
@@ -85,6 +131,10 @@ class _AjustesContent extends StatelessWidget {
FilaAjuste(
icon: Icons.bedtime_rounded,
titulo: l10n.timerSectionTitle,
// Reuses the equalizer's own "Active" string (generic enough
// in every locale) — shown only while running, matching how
// the preferred-station row shows nothing when unset.
valor: timerActivo ? l10n.equalizerActive : null,
onTap:
() => PluriPushScaffold.push(
context,
@@ -100,6 +150,7 @@ class _AjustesContent extends StatelessWidget {
FilaAjuste(
icon: Icons.playlist_add_check_circle_rounded,
titulo: l10n.favoriteGroupsTitle,
valor: '$gruposCount',
onTap:
() => PluriPushScaffold.push(
context,
@@ -109,6 +160,10 @@ class _AjustesContent extends StatelessWidget {
FilaAjuste(
icon: Icons.radio_rounded,
titulo: l10n.preferredStationTitle,
valor:
emisoraPreferida != null
? localizedStationName(l10n, emisoraPreferida.nombre)
: l10n.dash,
onTap:
() => PluriPushScaffold.push(
context,
@@ -118,6 +173,7 @@ class _AjustesContent extends StatelessWidget {
FilaAjuste(
icon: Icons.add_circle_outline_rounded,
titulo: l10n.customStationsTitle,
valor: '$emisorasCustomCount',
onTap:
() => PluriPushScaffold.push(
context,
@@ -127,6 +183,10 @@ class _AjustesContent extends StatelessWidget {
FilaAjuste(
icon: Icons.sort_rounded,
titulo: l10n.stationOrderTitle,
valor:
ordenListas == OrdenEmisoras.calidad
? l10n.stationOrderByQuality
: l10n.stationOrderByName,
onTap:
() => PluriPushScaffold.push(
context,
@@ -139,20 +199,39 @@ class _AjustesContent extends StatelessWidget {
GrupoAjustes(
titulo: l10n.settingsGroupRecordingsTitle,
filas: [
FilaAjuste(
icon: Icons.radio_button_checked_rounded,
titulo: l10n.recordingsSectionTitle,
// WU15b: this row opens the recordings LIBRARY
// (PantallaGrabaciones), matching the approved mockup's
// "Ajustes > Grabaciones" screen. The folder/size settings
// form (PantallaAjustesGrabaciones) is still reachable, but
// now from within the library via its own settings action.
onTap:
() => PluriPushScaffold.push(
context,
(_) => const PantallaGrabaciones(),
),
// FutureBuilder-wrapped (not a plain FilaAjuste): the count ·
// size value needs an async disk listing
// (EstadoGrabacion.listarGrabaciones), same source
// PantallaGrabaciones itself reads.
FutureBuilder<List<ArchivoGrabacion>>(
future: grabacion.listarGrabaciones(),
builder: (context, snapshot) {
final archivos = snapshot.data;
return FilaAjuste(
icon: Icons.radio_button_checked_rounded,
titulo: l10n.recordingsSectionTitle,
valor:
archivos == null
? null
: '${archivos.length} · ${_totalMb(archivos)} MB',
// WU15b: this row opens the recordings LIBRARY
// (PantallaGrabaciones), matching the approved mockup's
// "Ajustes > Grabaciones" screen. The folder/size
// settings form (PantallaAjustesGrabaciones) is still
// reachable, but now from within the library via its own
// settings action.
onTap:
() => PluriPushScaffold.push(
context,
(_) => const PantallaGrabaciones(),
),
);
},
),
// No `valor`: the configured folder is a SAF tree URI resolved
// by an async native channel call
// (FuenteMusicaLocalAutoImpl.carpetaActual) this root would
// need to invoke itself, unmocked, just to render a value.
FilaAjuste(
icon: Icons.library_music_outlined,
titulo: l10n.localMusicSectionTitle,
@@ -171,12 +250,15 @@ class _AjustesContent extends StatelessWidget {
FilaAjuste(
icon: Icons.language_rounded,
titulo: l10n.languageSectionTitle,
valor: nombreIdiomaActual(idioma, l10n),
onTap:
() => PluriPushScaffold.push(
context,
(_) => const PantallaAjustesIdioma(),
),
),
// No `valor`: there is no persisted "last backup" timestamp to
// read — showing one here would mean fabricating it.
FilaAjuste(
icon: Icons.backup_outlined,
titulo: l10n.backupSectionTitle,
@@ -186,6 +268,10 @@ class _AjustesContent extends StatelessWidget {
(_) => const PantallaAjustesBackup(),
),
),
// No `valor`: PackageInfo.fromPlatform() has no test-environment
// fallback anywhere else in this codebase either (see
// PantallaAjustesInfo's own FutureBuilder) — wiring it here
// would add a value this pass cannot deterministically test.
FilaAjuste(
icon: Icons.info_outline_rounded,
titulo: l10n.infoSectionTitle,
@@ -200,4 +286,9 @@ class _AjustesContent extends StatelessWidget {
],
);
}
static int _totalMb(List<ArchivoGrabacion> archivos) {
final totalBytes = archivos.fold<int>(0, (a, b) => a + b.tamanoBytes);
return (totalBytes / (1024 * 1024)).round();
}
}
+19 -11
View File
@@ -14,10 +14,10 @@ import '../tema/pluriwave_theme.dart';
import '../tema/pluriwave_tokens.dart';
import '../widgets/editor_hora_inline.dart';
import '../widgets/pluri_glass_surface.dart';
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,16 +33,24 @@ class PantallaAlarmas extends StatelessWidget {
child: ListView(
padding: PluriLayout.pageListPadding,
children: [
PluriScreenHeader(
// 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).
// S2 (Tier 1 visual fidelity): PluriScreenHeader (the glass hero
// this used to be) is retired — it is not in the prototype at
// all. Its ONE functional bit, the create-alarm action, moves
// into PluriRootHeader's `actions` slot so it stays reachable.
PluriRootHeader(
title: l10n.alarmScreenTitle,
subtitle: l10n.alarmScreenSubtitle,
glyph: PluriIconGlyph.alarm,
primaryActionLabel: l10n.createAlarmAction,
onPrimaryAction: () => _abrirEditor(context),
trailing: PluriStatusPill(
icon: Icons.alarm_on_rounded,
label: l10n.alarmsCount(estado.alarmas.length),
),
onSleepTimer: () => showPluriSleepTimerSheet(context),
actions: [
FilledButton.tonalIcon(
onPressed: () => _abrirEditor(context),
icon: const Icon(Icons.auto_awesome_rounded, size: 18),
label: Text(l10n.createAlarmAction),
),
],
),
Padding(
padding: PluriLayout.pageContentPadding,
+17 -9
View File
@@ -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,17 +142,23 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
return ListView(
padding: PluriLayout.pageListPadding,
children: [
PluriScreenHeader(
// S1/S2 (Tier 1 visual fidelity): the prototype has no global
// AppBar and no PluriScreenHeader (the glass hero this used to be
// is retired everywhere) — this root draws its own 56px title row
// instead, carrying the filters entry point that used to live on
// the hero's `trailing` slot.
PluriRootHeader(
title: l10n.searchScreenTitle,
subtitle: l10n.searchScreenSubtitle,
glyph: PluriIconGlyph.search,
trailing: GestureDetector(
onTap: _abrirFiltros,
child: PluriStatusPill(
icon: Icons.tune_rounded,
label: l10n.searchFiltersLabel,
onSleepTimer: () => showPluriSleepTimerSheet(context),
actions: [
GestureDetector(
onTap: _abrirFiltros,
child: PluriStatusPill(
icon: Icons.tune_rounded,
label: l10n.searchFiltersLabel,
),
),
),
],
),
Padding(
padding: const EdgeInsets.fromLTRB(
+33 -20
View File
@@ -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';
@@ -58,23 +60,35 @@ class _PantallaFavoritosState extends State<PantallaFavoritos> {
/// global position [EstadoRadio.reordenarFavorito] expects, so a drag
/// while a group chip is active still produces a coherent global order
/// (other groups' relative order is left untouched).
///
/// [newIndex] arrives in `ReorderableListView.onReorder`'s pre-removal
/// coordinate space: dragging downwards reports the slot the row would
/// occupy while it is still in the list. The logic below indexes into the
/// list AFTER the row is removed, so shift by one in that direction first.
void _onReorder(
List<Emisora> filtrados,
List<Emisora> favoritos,
int oldIndex,
int newIndex,
) {
if (newIndex > oldIndex) newIndex -= 1;
final movido = filtrados[oldIndex];
final restantes = List<Emisora>.from(filtrados)..removeAt(oldIndex);
// `ServicioFavoritos.reordenar` removes the station first and THEN
// inserts at the index it is given, so the target index must be
// expressed in the global list WITHOUT the moved station. Locating the
// neighbour in the untrimmed list instead drifts by one whenever the
// moved station sits before it.
final globalSinMovido =
favoritos.where((e) => e.uuid != movido.uuid).toList();
final int nuevoIndiceGlobal;
if (restantes.isEmpty) {
nuevoIndiceGlobal = favoritos.length - 1;
nuevoIndiceGlobal = globalSinMovido.length;
} else if (newIndex >= restantes.length) {
nuevoIndiceGlobal = favoritos.indexWhere(
(e) => e.uuid == restantes.last.uuid,
);
nuevoIndiceGlobal =
globalSinMovido.indexWhere((e) => e.uuid == restantes.last.uuid) + 1;
} else {
nuevoIndiceGlobal = favoritos.indexWhere(
nuevoIndiceGlobal = globalSinMovido.indexWhere(
(e) => e.uuid == restantes[newIndex].uuid,
);
}
@@ -101,14 +115,16 @@ class _PantallaFavoritosState extends State<PantallaFavoritos> {
return ListView(
padding: PluriLayout.pageListPadding,
children: [
PluriScreenHeader(
// 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).
// S2 (Tier 1 visual fidelity): PluriScreenHeader (the glass hero
// this used to be) is retired — it is not in the prototype at
// all, and carried no functional action on this screen.
PluriRootHeader(
title: l10n.favoritesTitle,
subtitle: l10n.favoritesHeaderSubtitle,
glyph: PluriIconGlyph.favorites,
trailing: PluriStatusPill(
icon: Icons.favorite_rounded,
label: l10n.favoritesCollection,
),
onSleepTimer: () => showPluriSleepTimerSheet(context),
),
SizedBox(
height: 320,
@@ -168,14 +184,11 @@ class _PantallaFavoritosState extends State<PantallaFavoritos> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PluriScreenHeader(
// S1/S2 (Tier 1 visual fidelity): see the empty-state branch
// above — PluriScreenHeader is retired everywhere.
PluriRootHeader(
title: l10n.favoritesTitle,
subtitle: l10n.favoritesHeaderSubtitle,
glyph: PluriIconGlyph.favorites,
trailing: PluriStatusPill(
icon: Icons.library_music_rounded,
label: l10n.favoritesSavedCount(favoritos.length),
),
onSleepTimer: () => showPluriSleepTimerSheet(context),
),
const SizedBox(height: 12),
Row(
@@ -217,7 +230,7 @@ class _PantallaFavoritosState extends State<PantallaFavoritos> {
onTap: _abrirFormularioEmisoraPersonalizada,
),
),
onReorderItem:
onReorder:
(oldIndex, newIndex) =>
_onReorder(filtrados, favoritos, oldIndex, newIndex),
children: [
+15 -1
View File
@@ -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<PantallaInicio> {
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<PantallaInicio> {
),
);
}
}
/// WU5, design ADR-7: the Escuchar embedded player. `EstadoRadio` is the
@@ -215,6 +226,9 @@ class _EscucharHero extends StatelessWidget {
0,
),
child: PluriGlassSurface(
// S3 (Tier 1 visual fidelity): the active/now-playing card — the
// system rule's other named exception to the opaque default.
glass: true,
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
+18 -1
View File
@@ -14,6 +14,7 @@ class PluriWaveTokens extends ThemeExtension<PluriWaveTokens> {
required this.listSurface,
required this.liveGreen,
required this.offlineAccent,
required this.balloonSurface,
required this.radiusSm,
required this.radiusMd,
required this.radiusLg,
@@ -46,7 +47,18 @@ class PluriWaveTokens extends ThemeExtension<PluriWaveTokens> {
/// promote, but the hex value itself is not this file's discretion.
final Color offlineAccent;
/// Solid capsule fill for the "barra globo" bottom navigation (Design turn
/// t4, option 4a — the balloon and the flat bar share this colour so their
/// union reads as a single inflated shape). No prior literal to promote —
/// new brand-system near-black distinct from [deepViolet].
final Color balloonSurface;
final double radiusSm;
/// S4 (Tier 1 visual fidelity): the prototype's dominant CARD radius —
/// `t4` lines 512, 613, 715, 133 (Ajustes group, storage card, welcome
/// CTA, tray tiles). Was 22, a systematic +4px drift versus every card
/// that defaults to this token (`PluriGlassSurface.borderRadius`).
final double radiusMd;
final double radiusLg;
@@ -75,8 +87,9 @@ class PluriWaveTokens extends ThemeExtension<PluriWaveTokens> {
listSurface: Color(0xFF102532),
liveGreen: Color(0xFF7EE4C2),
offlineAccent: Color(0xFFE8879A),
balloonSurface: Color(0xFF0A1B24),
radiusSm: 14,
radiusMd: 22,
radiusMd: 18,
radiusLg: 30,
spacingXs: 4,
spacingSm: 8,
@@ -95,6 +108,7 @@ class PluriWaveTokens extends ThemeExtension<PluriWaveTokens> {
Color? listSurface,
Color? liveGreen,
Color? offlineAccent,
Color? balloonSurface,
double? radiusSm,
double? radiusMd,
double? radiusLg,
@@ -113,6 +127,7 @@ class PluriWaveTokens extends ThemeExtension<PluriWaveTokens> {
listSurface: listSurface ?? this.listSurface,
liveGreen: liveGreen ?? this.liveGreen,
offlineAccent: offlineAccent ?? this.offlineAccent,
balloonSurface: balloonSurface ?? this.balloonSurface,
radiusSm: radiusSm ?? this.radiusSm,
radiusMd: radiusMd ?? this.radiusMd,
radiusLg: radiusLg ?? this.radiusLg,
@@ -143,6 +158,8 @@ class PluriWaveTokens extends ThemeExtension<PluriWaveTokens> {
liveGreen: Color.lerp(liveGreen, other.liveGreen, t) ?? liveGreen,
offlineAccent:
Color.lerp(offlineAccent, other.offlineAccent, t) ?? offlineAccent,
balloonSurface:
Color.lerp(balloonSurface, other.balloonSurface, t) ?? balloonSurface,
radiusSm: lerpDouble(radiusSm, other.radiusSm, t) ?? radiusSm,
radiusMd: lerpDouble(radiusMd, other.radiusMd, t) ?? radiusMd,
radiusLg: lerpDouble(radiusLg, other.radiusLg, t) ?? radiusLg,
+12 -1
View File
@@ -52,12 +52,14 @@ class PluriWaveTypography extends ThemeExtension<PluriWaveTypography> {
FontWeight fontWeight, {
double? letterSpacing,
double? height,
Color? color,
}) {
return fallback.copyWith(
fontSize: fontSize,
fontWeight: fontWeight,
letterSpacing: letterSpacing,
height: height,
color: color,
);
}
@@ -67,7 +69,16 @@ class PluriWaveTypography extends ThemeExtension<PluriWaveTypography> {
screenTitle: style(19, FontWeight.w800, letterSpacing: -0.4),
cardTitle: style(14.5, FontWeight.w700),
bodyStrong: style(13, FontWeight.w600),
eyebrowLabel: style(11, FontWeight.w800, letterSpacing: 0.8),
// S9 (Tier 1 visual fidelity): the prototype's eyebrows are always
// rgba(242,247,250,.42) (t4 lines 254, 299, 381, 450, 511, 660) — this
// style used to carry no colour at all, so it rendered at whatever
// full-opacity default the ambient text theme resolved to.
eyebrowLabel: style(
11,
FontWeight.w800,
letterSpacing: 0.8,
color: const Color(0xFFF2F7FA).withValues(alpha: 0.42),
),
);
}
+3
View File
@@ -72,6 +72,9 @@ class _MiniReproductorState extends State<MiniReproductor> {
t.spacingSm,
),
child: PluriGlassSurface(
// S3 (Tier 1 visual fidelity): chrome — one of the system rule's
// two named exceptions to the opaque list-surface default.
glass: true,
padding: EdgeInsets.symmetric(
horizontal: t.spacingSm,
vertical: t.spacingXs,
+237 -93
View File
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import '../tema/pluriwave_theme.dart';
import 'pluri_glass_surface.dart';
import 'pluri_icon.dart';
class PluriNavItem {
@@ -11,6 +10,13 @@ class PluriNavItem {
final String label;
}
/// "Barra globo" bottom navigation — Design turn t4, option 4a. Spec
/// transcribed verbatim from `PluriWave Rediseno.dc.html` lines 91-99: a
/// solid capsule bar with a taller "balloon" capsule that slides beneath
/// whichever tab is active. The balloon and the bar share one fill colour
/// ([PluriWaveTokens.balloonSurface]) so their union reads as a single
/// inflated shape — same design vocabulary as the prototype's own
/// `balloon()` helper (`PluriWave Rediseno.dc.html:2130-2155`).
class PluriBottomNavigation extends StatelessWidget {
const PluriBottomNavigation({
super.key,
@@ -23,30 +29,177 @@ class PluriBottomNavigation extends StatelessWidget {
final int selectedIndex;
final ValueChanged<int> onSelected;
/// t4/4a spec: container `height:74px`. Fixed by construction (the root is
/// a `SizedBox` of this exact height, not content-driven), so this is also
/// what a real `tester.getSize` measurement returns — see
/// `pluri_bottom_navigation_test.dart`'s measurement assertion, which
/// backs `PluriLayout.bottomChromeInset`.
static const double altura = 74;
/// t4/4a spec: `width:110px` for the glow/balloon/teal-wash layers,
/// verbatim — independent of tab count (the prototype's own screen uses 4
/// cells; this app has 5, so the balloon is wider than one cell and
/// intentionally overlaps its neighbours, matching the literal spec
/// numbers rather than rescaling them).
static const double _balloonWidth = 110;
/// Test hook for the balloon's own positioned box.
@visibleForTesting
static const balloonKey = ValueKey('pluriBottomNavigationBalloon');
/// Test hook for a given tab's tappable/semantics region.
@visibleForTesting
static Key itemKey(int index) => ValueKey('pluriBottomNavigationItem_$index');
@override
Widget build(BuildContext context) {
final t = context.pluriTokens;
return PluriGlassSurface(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 7),
borderRadius: BorderRadius.circular(999),
glowColor: t.glowColor.withValues(alpha: 0.28),
child: Row(
children: [
for (var i = 0; i < items.length; i++)
Expanded(
flex: i == selectedIndex ? 15 : 10,
child: _PluriNavButton(
item: items[i],
selected: i == selectedIndex,
onTap: () => onSelected(i),
final motion = context.pluriMotion;
return SizedBox(
height: altura,
child: LayoutBuilder(
builder: (context, constraints) {
final cellWidth = constraints.maxWidth / items.length;
final balloonLeft =
cellWidth * selectedIndex + (cellWidth - _balloonWidth) / 2;
return Stack(
clipBehavior: Clip.none,
children: [
// Layer 1: radial glow behind the active balloon.
AnimatedPositioned(
duration: motion.normal,
curve: Curves.easeOutCubic,
left: balloonLeft,
bottom: -8,
width: _balloonWidth,
height: 92,
child: IgnorePointer(
child: DecoratedBox(
// Spec: `border-radius:50%` on a 110x92 box, i.e. an
// ellipse — Flutter's `BoxShape.circle` only supports
// equal-radius circles (it would clip this non-square
// box down to a 92-diameter circle, losing width). The
// radial gradient already fades to fully transparent at
// 66% of its radius, well before any box edge, so the
// rectangular (unclipped) paint area is visually
// indistinguishable from a true ellipse clip here.
decoration: BoxDecoration(
gradient: RadialGradient(
colors: [
t.electricMagenta.withValues(alpha: 0.32),
t.electricMagenta.withValues(alpha: 0),
],
stops: const [0, 0.66],
),
),
),
),
),
),
],
// Layer 2 (shadow layer): the flat bar and the balloon, both
// filled with the same solid colour and carrying the same two
// drop-shadows, so their overlap reads as one union.
Positioned(
left: 0,
right: 0,
bottom: 0,
height: 52,
child: DecoratedBox(
decoration: BoxDecoration(
color: t.balloonSurface,
borderRadius: BorderRadius.circular(999),
boxShadow: _shellShadows,
),
),
),
AnimatedPositioned(
key: balloonKey,
duration: motion.normal,
curve: Curves.easeOutCubic,
left: balloonLeft,
bottom: 0,
width: _balloonWidth,
height: 74,
child: DecoratedBox(
decoration: BoxDecoration(
color: t.balloonSurface,
borderRadius: BorderRadius.circular(999),
boxShadow: _shellShadows,
),
),
),
// Layer 3: teal wash over the balloon only.
AnimatedPositioned(
duration: motion.normal,
curve: Curves.easeOutCubic,
left: balloonLeft,
bottom: 0,
width: _balloonWidth,
height: 74,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999),
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
t.electricMagenta.withValues(alpha: 0.2),
t.electricMagenta.withValues(alpha: 0),
],
stops: const [0, 0.62],
),
),
),
),
),
// Layer 4: the row of items, topmost and interactive. Spans
// the FULL container height (not just the 52px bar strip) so
// the active tab's lifted icon/label — which visually pokes
// up into the balloon's extra headroom — stays within its own
// tap/semantics region instead of falling through to the
// (non-interactive) balloon shape behind it.
Positioned.fill(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < items.length; i++)
Expanded(
key: itemKey(i),
child: _PluriNavButton(
item: items[i],
selected: i == selectedIndex,
onTap: () => onSelected(i),
),
),
],
),
),
],
);
},
),
);
}
}
/// t4/4a spec: `drop-shadow(0 -1.5px 0 rgba(255,255,255,.17))` (a hard-edged
/// rim highlight, zero blur) plus `drop-shadow(0 14px 26px rgba(0,0,0,.5))`
/// (a soft downward shadow). CSS applies these to the *union* of the bar and
/// balloon via one shared `filter`; Flutter has no direct primitive for a
/// shadow-of-a-path-union, so the same two shadows are applied to both
/// shapes individually — since both are solid, identically-coloured pills,
/// the balloon's opaque fill already covers the seam where the bar's own
/// shadow would otherwise show through.
List<BoxShadow> get _shellShadows => [
BoxShadow(color: Colors.white.withValues(alpha: 0.17), offset: const Offset(0, -1.5)),
BoxShadow(
color: Colors.black.withValues(alpha: 0.5),
offset: const Offset(0, 14),
blurRadius: 26,
),
];
class _PluriNavButton extends StatelessWidget {
const _PluriNavButton({
required this.item,
@@ -61,91 +214,82 @@ class _PluriNavButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final t = context.pluriTokens;
final foreground = Theme.of(context).colorScheme.onSurface;
return Semantics(
button: true,
selected: selected,
label: item.label,
child: AnimatedContainer(
duration: context.pluriMotion.normal,
curve: Curves.easeOutCubic,
margin: EdgeInsets.symmetric(horizontal: selected ? 3 : 2),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999),
gradient:
selected
? LinearGradient(
colors: [
t.electricMagenta.withValues(alpha: 0.32),
t.warmCoral.withValues(alpha: 0.18),
],
)
: null,
border: Border.all(
color:
selected
? Colors.white.withValues(alpha: 0.22)
: Colors.white.withValues(alpha: 0.06),
),
boxShadow:
selected
? [
BoxShadow(
color: t.glowColor.withValues(alpha: 0.36),
blurRadius: 24,
spreadRadius: -6,
offset: const Offset(0, 8),
),
]
: const [],
),
child: Material(
type: MaterialType.transparency,
child: InkWell(
borderRadius: BorderRadius.circular(999),
onTap: onTap,
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: selected ? 8 : 3,
vertical: selected ? 8 : 7,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedScale(
scale: selected ? 1.16 : 0.96,
duration: context.pluriMotion.quick,
curve: Curves.easeOutBack,
child: PluriIcon(
glyph: item.glyph,
variant:
selected
? PluriIconVariant.activeGlow
: PluriIconVariant.filled,
size: selected ? 42 : 34,
child: Align(
alignment: Alignment.bottomCenter,
// t4/4a spec: the items row itself is 52px tall — this inner
// box reproduces that visual strip even though the outer
// tappable region now spans the full 74px balloon area.
child: SizedBox(
height: 52,
child: Center(
// The outer Semantics(button:.., label: item.label) already
// fully describes this control. Without this, PluriIcon's
// own inner Semantics(image:true) node (and, for the active
// tab, Text's auto-generated semantics) has no container
// boundary to stop it merging into the button's node —
// Flutter joins merged labels with `\n`, so screen readers
// would announce "Alarmas\nAlarmas" instead of "Alarmas".
child: ExcludeSemantics(
child: Transform.translate(
// t4/4a spec: active item lift `translateY(-15px)`.
offset: Offset(0, selected ? -15 : 0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Opacity(
// t4/4a spec: active icon full colour; inactive
// `rgba(242,247,250,.46)` — .46 applied here as
// uniform opacity dims both the fallback Icon
// (already `onSurface` from
// PluriIconVariant.filled) and the real raster
// badge asset identically.
opacity: selected ? 1 : 0.46,
child: PluriIcon(
glyph: item.glyph,
variant: PluriIconVariant.filled,
// t4/4a spec: icon `font-size:25px`/`23px`.
size: selected ? 25 : 23,
color: selected ? t.electricMagenta : null,
// Same ARB string the outer Semantics already
// uses — passing it explicitly skips
// PluriIcon's own AppLocalizations.of lookup
// (excluded from the tree above regardless).
semanticLabel: item.label,
),
),
if (selected) ...[
const SizedBox(height: 3),
Text(
item.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
// t4/4a spec: label `font-size:11px;
// font-weight:800; line-height:1.25`, brand
// colour.
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(
fontSize: 11,
fontWeight: FontWeight.w800,
height: 1.25,
letterSpacing: 0,
color: t.electricMagenta,
),
),
],
],
),
),
),
AnimatedSize(
duration: context.pluriMotion.quick,
curve: Curves.easeOutCubic,
child:
selected
? Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
item.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(
color: foreground,
fontWeight: FontWeight.w900,
letterSpacing: -0.2,
),
),
)
: const SizedBox.shrink(),
),
],
),
),
),
),
+42 -16
View File
@@ -4,6 +4,15 @@ import 'package:flutter/material.dart';
import '../tema/pluriwave_theme.dart';
/// S3+S4 (Tier 1 visual fidelity): the prototype's own system rule (`t4`
/// line 40, verbatim) — "Superficie de lista opaca #102532, cristal solo en
/// el cromo y en la tarjeta activa" ("Opaque list surface #102532, glass
/// only in the chrome and in the active card"). This primitive backs nearly
/// every card/row in the app, so [glass] defaults to `false`: an OPAQUE
/// [PluriWaveTokens.listSurface] fill, no blur. Pass `glass: true` for the
/// rule's two named exceptions — chrome (e.g. `MiniReproductor`) and the
/// active/now-playing card (Escuchar's `_EscucharHero`) — to keep the
/// original translucent, blurred look there.
class PluriGlassSurface extends StatelessWidget {
const PluriGlassSurface({
super.key,
@@ -12,6 +21,7 @@ class PluriGlassSurface extends StatelessWidget {
this.borderRadius,
this.blurSigma = 18,
this.glowColor,
this.glass = false,
});
final Widget child;
@@ -20,31 +30,47 @@ class PluriGlassSurface extends StatelessWidget {
final double blurSigma;
final Color? glowColor;
/// See class doc — `false` (opaque `listSurface`) is the system default.
final bool glass;
@override
Widget build(BuildContext context) {
final t = context.pluriTokens;
final radius = borderRadius ?? BorderRadius.circular(t.radiusMd);
final decoratedChild = DecoratedBox(
decoration: BoxDecoration(
color: glass ? t.glassSurface : t.listSurface,
borderRadius: radius,
border: Border.all(
color: glass ? t.glassBorder : Colors.white.withValues(alpha: 0.07),
),
boxShadow:
glass
? [
BoxShadow(
color: glowColor ?? t.glowColor.withValues(alpha: 0.12),
blurRadius: 30,
spreadRadius: -14,
offset: const Offset(0, 18),
),
]
: null,
),
child: Padding(padding: padding, child: child),
);
if (!glass) {
return RepaintBoundary(
child: ClipRRect(borderRadius: radius, child: decoratedChild),
);
}
return RepaintBoundary(
child: ClipRRect(
borderRadius: radius,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma),
child: DecoratedBox(
decoration: BoxDecoration(
color: t.glassSurface,
borderRadius: radius,
border: Border.all(color: t.glassBorder),
boxShadow: [
BoxShadow(
color: glowColor ?? t.glowColor.withValues(alpha: 0.12),
blurRadius: 30,
spreadRadius: -14,
offset: const Offset(0, 18),
),
],
),
child: Padding(padding: padding, child: child),
),
child: decoratedChild,
),
),
);
+8
View File
@@ -15,6 +15,7 @@ class PluriIcon extends StatelessWidget {
this.variant = PluriIconVariant.outline,
this.size = 24,
this.semanticLabel,
this.color,
});
final PluriIconGlyph glyph;
@@ -22,6 +23,12 @@ class PluriIcon extends StatelessWidget {
final double size;
final String? semanticLabel;
/// Explicit colour override for the Icon-fallback render path (used when
/// the raster asset fails to decode). `null` (default) preserves the
/// existing variant-driven colour for every current call site — see
/// `_resolveColor`.
final Color? color;
@override
Widget build(BuildContext context) {
final tokens = context.pluriTokens;
@@ -82,6 +89,7 @@ class PluriIcon extends StatelessWidget {
}
Color _resolveColor(BuildContext context, PluriWaveTokens tokens) {
if (color != null) return color!;
if (variant == PluriIconVariant.activeGlow) return tokens.electricMagenta;
if (variant == PluriIconVariant.filled) {
return Theme.of(context).colorScheme.onSurface;
+25 -1
View File
@@ -2,13 +2,31 @@
import '../tema/pluriwave_theme.dart';
import 'mini_reproductor.dart';
import 'pluri_bottom_navigation.dart';
abstract final class PluriLayout {
// S5 (Tier 1 visual fidelity): the prototype (`t4`) runs a 3-tier
// horizontal padding scale — 20px for section titles/eyebrows (lines
// 153, 254, 299, 511), 16px for cards (lines 327, 512, 610), 12px for
// background-less list rows (lines 174, 226, 301). [horizontal] IS the
// card tier — its value and every existing call site stay unchanged;
// [titleHorizontal] and [rowHorizontal] are new.
static const double titleHorizontal = 20;
static const double horizontal = 16;
static const double rowHorizontal = 12;
static const double sectionGap = 12;
static const double panelGap = 12;
static const double compactGap = 8;
static const double bottomChromeInset = 146;
/// `app.dart`'s `bottomNavigationBar` stacks `MiniReproductor` above
/// `PluriBottomNavigation` with no gap between them, wrapped in a
/// `SafeArea(minimum: EdgeInsets.only(bottom: compactGap))` — this is that
/// same sum, derived from the two chrome widgets' own measured heights
/// instead of a guessed constant. See
/// `pluri_bottom_navigation_test.dart`'s composed-chrome-height assertion.
static const double bottomChromeInset =
MiniReproductor.altura + PluriBottomNavigation.altura + compactGap;
/// ADR-7(b): `bottomChromeInset` assumes `MiniReproductor` is visible.
/// Escuchar hides it (design's one exception — the embedded hero already
@@ -27,6 +45,12 @@ abstract final class PluriLayout {
horizontal: horizontal,
);
/// S5: the title/eyebrow tier's own content padding — e.g. a section
/// heading living directly on the page background, outside any card.
static const EdgeInsets titleContentPadding = EdgeInsets.symmetric(
horizontal: titleHorizontal,
);
static const EdgeInsets sheetPadding = EdgeInsets.all(18);
}
-229
View File
@@ -1,217 +1,9 @@
import 'package:flutter/material.dart';
import '../tema/pluriwave_theme.dart';
import '../tema/pluriwave_tokens.dart';
import 'pluri_glass_surface.dart';
import 'pluri_icon.dart';
class PluriScreenHeader extends StatelessWidget {
const PluriScreenHeader({
super.key,
required this.title,
required this.subtitle,
required this.glyph,
this.primaryActionLabel,
this.onPrimaryAction,
this.trailing,
});
final String title;
final String subtitle;
final PluriIconGlyph glyph;
final String? primaryActionLabel;
final VoidCallback? onPrimaryAction;
final Widget? trailing;
@override
Widget build(BuildContext context) {
final t = context.pluriTokens;
final theme = Theme.of(context);
final width = MediaQuery.sizeOf(context).width;
final scale = MediaQuery.textScalerOf(context).scale(1);
final compact = width < 380 || scale >= 1.25;
final iconSize = compact ? 50.0 : 56.0;
Widget glyphBadge() => Container(
width: iconSize,
height: iconSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [
PluriWaveTokens.brightCyan.withValues(alpha: 0.95),
t.electricMagenta,
t.warmCoral,
],
),
boxShadow: [
BoxShadow(color: t.glowColor, blurRadius: 28, spreadRadius: 2),
],
),
child: Center(
child: PluriIcon(
glyph: glyph,
variant: PluriIconVariant.filled,
size: compact ? 25 : 28,
),
),
);
Widget textBlock() => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: compact ? 2 : 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w900,
letterSpacing: -0.7,
height: 1.05,
),
),
const SizedBox(height: 6),
Text(
subtitle,
maxLines: compact ? 4 : 3,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.78),
height: 1.28,
),
),
if (primaryActionLabel != null) ...[
const SizedBox(height: 12),
FilledButton.tonalIcon(
onPressed: onPrimaryAction,
icon: const Icon(Icons.auto_awesome_rounded, size: 18),
label: Text(primaryActionLabel!),
),
],
],
);
Widget foreground() {
if (compact) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
glyphBadge(),
const SizedBox(width: 14),
Expanded(child: textBlock()),
],
),
if (trailing != null) ...[
const SizedBox(height: 14),
Align(alignment: Alignment.centerLeft, child: trailing!),
],
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
glyphBadge(),
const SizedBox(width: 14),
Expanded(child: textBlock()),
if (trailing != null) ...[
const SizedBox(width: 12),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 220),
child: trailing!,
),
],
],
);
}
return Padding(
padding: EdgeInsets.fromLTRB(
t.spacingMd,
t.spacingSm,
t.spacingMd,
t.spacingSm,
),
child: PluriGlassSurface(
borderRadius: BorderRadius.circular(t.radiusLg + 8),
padding: EdgeInsets.symmetric(
horizontal: compact ? 16 : 20,
vertical: compact ? 18 : 20,
),
child: Stack(
children: [
Positioned.fill(
child: ClipRRect(
borderRadius: BorderRadius.circular(t.radiusLg + 8),
child: Opacity(
opacity: 0.24,
child: Image.asset(
'assets/images/aurora_wave_banner.png',
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
),
),
),
),
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(t.radiusLg + 8),
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Colors.black.withValues(alpha: 0.58),
Colors.black.withValues(alpha: 0.18),
],
),
),
),
),
Positioned(
right: -36,
top: -42,
child: _Orb(
color: t.electricMagenta.withValues(alpha: 0.38),
size: 128,
),
),
Positioned(
right: 10,
top: 10,
child: Opacity(
opacity: 0.18,
child: Image.asset(
'assets/icons/pluriwave_app_mark.png',
width: 120,
height: 120,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
),
),
),
Positioned(
right: 44,
bottom: -54,
child: _Orb(
color: PluriWaveTokens.brightCyan.withValues(alpha: 0.22),
size: 116,
),
),
Padding(
padding: EdgeInsets.all(compact ? 2 : 4),
child: foreground(),
),
],
),
),
);
}
}
class PluriStatusPill extends StatelessWidget {
const PluriStatusPill({
super.key,
@@ -313,24 +105,3 @@ class PluriEmptyState extends StatelessWidget {
);
}
}
class _Orb extends StatelessWidget {
const _Orb({required this.color, required this.size});
final Color color;
final double size;
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(colors: [color, color.withValues(alpha: 0)]),
),
),
);
}
}
+71
View File
@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import '../l10n/gen/app_localizations.dart';
import '../tema/pluriwave_theme.dart';
import 'pluri_layout.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,
this.actions = const <Widget>[],
});
final String title;
final VoidCallback onSleepTimer;
/// S2 (Tier 1 visual fidelity): the now-retired `PluriScreenHeader` was
/// also the only home for a couple of roots' SINGLE functional action —
/// Alarmas' create-alarm button, Buscar's filters entry point. Rendered
/// before the shared bedtime button; empty by default (every other root
/// needs nothing extra here).
final List<Widget> actions;
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(
// S5: the prototype's own header padding is title-tier on the
// left, row-tier on the right (t4 e.g. Alarmas
// `padding:0 12px 0 20px`).
padding: const EdgeInsets.fromLTRB(
PluriLayout.titleHorizontal,
0,
PluriLayout.rowHorizontal,
0,
),
child: Row(
children: [
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: type.sectionTitle,
),
),
...actions,
IconButton(
icon: const Icon(Icons.bedtime_outlined),
tooltip: l10n.sleepTimer,
onPressed: onSleepTimer,
),
],
),
),
);
}
}
+253
View File
@@ -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(),
),
);
}
}
+14 -32
View File
@@ -8,7 +8,6 @@ import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../tema/pluriwave_theme.dart';
import '../tema/pluriwave_tokens.dart';
import 'pluri_glass_surface.dart';
import 'pluri_icon.dart';
@@ -65,8 +64,11 @@ class _TarjetaEmisoraState extends State<TarjetaEmisora> {
label: l10n.stationSemanticLabel(stationName),
child: PluriGlassSurface(
padding: EdgeInsets.zero,
// S4 (Tier 1 visual fidelity): compact (esCompacta) is a flat-list
// ROW — prototype dominant row radius 14 (t4 lines 175, 227, 302).
// The full grid card keeps the dominant CARD radius, 18.
borderRadius: BorderRadius.circular(
widget.esCompacta ? t.radiusMd : t.radiusLg,
widget.esCompacta ? t.radiusSm : t.radiusMd,
),
child: Material(
color: Colors.transparent,
@@ -180,35 +182,13 @@ class _TarjetaEmisoraState extends State<TarjetaEmisora> {
),
child: Row(
children: [
Stack(
alignment: Alignment.center,
children: [
Container(
width: 58,
height: 58,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: SweepGradient(
colors: [
t.electricMagenta,
PluriWaveTokens.brightCyan,
t.warmCoral,
t.electricMagenta,
],
),
boxShadow: [
BoxShadow(
color: t.glowColor.withValues(alpha: 0.24),
blurRadius: 22,
),
],
),
),
ClipRRect(
borderRadius: BorderRadius.circular(18),
child: SizedBox(width: 50, height: 50, child: _logo(24)),
),
],
// S6 (Tier 1 visual fidelity): the prototype's station thumbnail
// is a plain square, radius 11/12, with NO ring or glow (t4 lines
// 84, 175, 227, 302, 614) — was a 58x58 circle wrapped in a
// SweepGradient ring and a 22-blur glow.
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(width: 48, height: 48, child: _logo(22)),
),
SizedBox(width: t.spacingSm),
Expanded(
@@ -451,7 +431,9 @@ class TarjetaEmisoraShimmer extends StatelessWidget {
esCompacta
? Row(
children: [
bloque(width: 58, height: 58, shape: BoxShape.circle),
// S6: matches the real square thumbnail (48x48, radius 12)
// — was a 58x58 circle block.
bloque(width: 48, height: 48, radius: 12),
const SizedBox(width: 10),
Expanded(
child: Column(
+23
View File
@@ -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',
);
});
}
+2 -2
View File
@@ -44,8 +44,8 @@ void main() {
for (final locale in _auditedLocales) {
final arb = readArb(locale);
for (final key in realKeys(arb)) {
if (!es.containsKey(key))
continue; // arb_parity_test's job, not this one's
// Missing keys are arb_parity_test's job, not this one's.
if (!es.containsKey(key)) continue;
if (arb[key] == es[key] &&
!identicalValueAllowlist.contains((locale, key))) {
unlisted.add('$locale/$key = "${arb[key]}"');
@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/pantallas/ajustes/widgets/fila_ajuste.dart';
import 'package:pluriwave/tema/pluriwave_theme.dart';
/// S8 (Tier 1 visual fidelity): the prototype puts a trailing "current
/// value" on nearly every settings row, 13px `rgba(242,247,250,.55)` (t4
/// lines 512-539, 625 — "Voz clara", "Alta", "3 guardados", "Alfabético",
/// "7 · 84 MB", "Español", "Hoy, 08:12", "200 MB"). `FilaAjuste` used to
/// accept only `icon`/`titulo`/`onTap` — no value slot at all.
void main() {
Widget host(Widget child) {
return MaterialApp(
theme: PluriWaveTheme.dark(),
home: Scaffold(body: child),
);
}
testWidgets('renders the trailing value before the chevron when provided', (
tester,
) async {
await tester.pumpWidget(
host(
FilaAjuste(
icon: Icons.language_rounded,
titulo: 'Language',
valor: 'English',
onTap: () {},
),
),
);
expect(find.text('English'), findsOneWidget);
expect(find.byIcon(Icons.chevron_right_rounded), findsOneWidget);
});
testWidgets('renders no trailing value text when valor is omitted '
'(unchanged pre-S8 behaviour)', (tester) async {
await tester.pumpWidget(
host(
FilaAjuste(
icon: Icons.info_outline_rounded,
titulo: 'Info',
onTap: () {},
),
),
);
expect(find.text('Info'), findsOneWidget);
expect(find.byIcon(Icons.chevron_right_rounded), findsOneWidget);
});
}
@@ -0,0 +1,174 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_ecualizador.dart';
import 'package:pluriwave/estado/estado_grabacion.dart';
import 'package:pluriwave/estado/estado_idioma.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/archivo_grabacion.dart';
import 'package:pluriwave/pantallas/pantalla_ajustes.dart';
import 'package:pluriwave/servicios/servicio_grabacion_radio.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes.dart';
/// S8 (Tier 1 visual fidelity): the prototype puts a trailing "current
/// value" on nearly every settings row (t4 lines 512-539, 625 — e.g.
/// "3 guardados", "Alfabético", "Español", "7 · 84 MB"). Wires 8 of the 12
/// built rows to real, already-available state. The other 4 (Salida de
/// audio, Música local, Backup, Info's version) are deliberately left
/// without a value — see the apply-progress note for why each lacks a
/// low-risk, deterministically-testable data source.
///
/// 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);
}
/// Returns a fixed, in-memory recordings list — `listarGrabaciones()`'s real
/// implementation touches the filesystem directly (`Directory.listSync`),
/// which the project's own convention forbids exercising bare in a widget
/// test.
class _FakeServicioGrabacionConArchivos extends ServicioGrabacionRadio {
final _controller = StreamController<EstadoGrabacionRadio>.broadcast();
@override
EstadoGrabacionRadio get estado => const EstadoGrabacionRadio.inactiva();
@override
Stream<EstadoGrabacionRadio> get estadoStream => _controller.stream;
@override
Future<void> inicializar() async {}
@override
Future<List<ArchivoGrabacion>> listarGrabaciones() async => [
ArchivoGrabacion(
ruta: '/a.m4a',
nombre: 'a',
fecha: DateTime(2026, 1, 1),
tamanoBytes: 2 * 1024 * 1024,
),
ArchivoGrabacion(
ruta: '/b.m4a',
nombre: 'b',
fecha: DateTime(2026, 1, 2),
tamanoBytes: 5 * 1024 * 1024,
),
];
@override
Future<void> dispose() => _controller.close();
}
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
Future<File> archivoCustomVacio() async => File(
'${Directory.current.path}/test/fixtures/emisoras_custom_vacio.json',
);
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));
}
Widget buildAjustes(EstadoRadio estado, EstadoIdioma idioma) {
return MultiProvider(
providers: [
ChangeNotifierProvider<EstadoRadio>.value(value: estado),
ListenableProvider<EstadoEcualizador>.value(value: estado.ecualizador),
ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion),
ChangeNotifierProvider<EstadoIdioma>.value(value: idioma),
],
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: PantallaAjustes()),
),
);
}
testWidgets('settings rows show their current value: EQ on, favourite groups '
'count, preferred station name, custom stations count, sort order, '
'recordings count · size, and the current language', (tester) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: _FakeServicioGrabacionConArchivos(),
resolverArchivoCustom: archivoCustomVacio,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
await estado.ecualizador.cargarPersistido();
await estado.ecualizador.cambiarActivo(true);
final favoritos = estado.favoritos as FakeServicioFavoritos;
await favoritos.crearGrupo('Rock');
await favoritos.crearGrupo('Jazz');
await estado.cargarGruposFavoritos();
final preferida = emisoraDemo(uuid: 'pref-1', nombre: 'Radio Horizonte');
await favoritos.agregar(preferida);
await estado.cargarFavoritos();
await estado.cambiarEmisoraPreferida(preferida);
await estado.ordenarFavoritos(OrdenEmisoras.calidad);
final idioma = EstadoIdioma();
// EstadoIdioma's constructor kicks off its own async `_cargar()` read
// from SharedPreferences; without waiting for it to settle first, it
// can resolve AFTER `seleccionarLocale` below and clobber the
// selection back to null (a real race, not a test flake). `tester.
// pump()`, NOT a bare `Future.delayed` — a real Timer/delay never
// fires inside `testWidgets`' fake-async zone without something
// driving fake time forward, and hangs the whole test.
await tester.pump();
await idioma.seleccionarLocale(const Locale('en'));
await tester.pumpWidget(buildAjustes(estado, idioma));
await pumpStable(tester);
// Lets the recordings FutureBuilder resolve.
await tester.pump();
final l10n = await AppLocalizations.delegate.load(const Locale('en'));
expect(find.text(l10n.equalizerActive), findsOneWidget);
// 3, not 2 — FakeServicioFavoritos seeds a protected "unassigned"
// group by default, on top of the 2 this test creates.
expect(find.text('3'), findsOneWidget); // favourite groups
expect(find.text('Radio Horizonte'), findsOneWidget);
expect(find.text(l10n.stationOrderByQuality), findsOneWidget);
expect(find.text('2 · 7 MB'), findsOneWidget);
expect(find.text('English'), findsOneWidget);
});
}
+41 -4
View File
@@ -226,10 +226,13 @@ void main() {
find.byType(ReorderableListView),
);
// Drag the 3rd row (index 2, "Station C") to the 1st position (index
// 0) exercised via the real onReorderItem callback the widget wires
// up. onReorderItem (not the deprecated onReorder) already adjusts
// newIndex for the removed item, so no manual index math here.
lista.onReorderItem!(2, 0);
// 0), exercised via the real onReorder callback the widget wires up.
// `onReorder` is the API present across Flutter versions (the newer
// `onReorderItem` does not exist on the CI SDK), so it reports
// newIndex in the PRE-removal coordinate space; `_onReorder`
// compensates internally. Moving upwards needs no shift, which is why
// (2, 0) maps straight through.
lista.onReorder!(2, 0);
await pumpStable(tester);
expect(estado.listaFavoritosManual.map((e) => e.uuid).toList(), [
@@ -247,6 +250,40 @@ void main() {
]);
});
testWidgets('dragging the 1st item downwards lands it in the right slot', (
tester,
) async {
// Guards the pre-removal index compensation in `_onReorder`. Downward
// drags are the ONLY direction `ReorderableListView.onReorder` reports
// in the pre-removal coordinate space, so an off-by-one here would slip
// past the upward-drag test above entirely.
setLargeSurface(tester);
_suppressListTileInkAssertion();
final estado = await crearEstadoConFavoritos();
addTearDown(estado.dispose);
await tester.pumpWidget(buildScreen(estado));
await pumpStable(tester);
final lista = tester.widget<ReorderableListView>(
find.byType(ReorderableListView),
);
// Move "Station A" (index 0) into the MIDDLE slot. onReorder reports
// newIndex == 2 in the pre-removal space; `_onReorder` shifts it to 1.
// This specific case is what makes the test meaningful: dropping at the
// very end (0, 3) yields the same answer with or without the shift,
// because both land in the `newIndex >= restantes.length` branch. Only a
// mid-list drop separates the two.
lista.onReorder!(0, 2);
await pumpStable(tester);
expect(estado.listaFavoritosManual.map((e) => e.uuid).toList(), [
'b',
'a',
'c',
]);
});
testWidgets(
'swap_vert sort action applies OrdenEmisoras.nombre and re-renders '
'alphabetically',
@@ -0,0 +1,173 @@
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_idioma.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/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';
/// S2 (Tier 1 visual fidelity): `PluriScreenHeader` — a 38-radius glass
/// hero with an aurora banner, a black scrim, two radial orbs, a 120px
/// watermark and a tri-gradient glyph badge — is not in the prototype at
/// all (`t4` never draws it). It is retired from all 4 roots that used it;
/// each root's ONLY title chrome is now `PluriRootHeader` (S1). Its
/// subtitle text was the one thing the hero rendered that nothing else on
/// these screens does — its absence is this suite's signal that the hero
/// is really gone, since the class itself is deleted and can no longer be
/// referenced by type from a test.
///
/// 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),
ChangeNotifierProvider<EstadoIdioma>.value(value: EstadoIdioma()),
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));
}
testWidgets(
'Buscar: the retired hero subtitle is gone, PluriRootHeader is the '
'only header, and the filters entry point is still reachable',
(tester) async {
setLargeSurface(tester);
final estado = crearEstadoRadio();
addTearDown(estado.dispose);
final l10n = await AppLocalizations.delegate.load(const Locale('en'));
await tester.pumpWidget(testApp(estado, const PantallaBuscar()));
await pumpStable(tester);
expect(find.text(l10n.searchScreenSubtitle), findsNothing);
expect(find.byType(PluriRootHeader), findsOneWidget);
expect(find.text(l10n.searchFiltersLabel), findsWidgets);
},
);
testWidgets('Favoritos (empty state): the retired hero subtitle is gone', (
tester,
) async {
setLargeSurface(tester);
final estado = crearEstadoRadio();
addTearDown(estado.dispose);
final l10n = await AppLocalizations.delegate.load(const Locale('en'));
await tester.pumpWidget(testApp(estado, const PantallaFavoritos()));
await pumpStable(tester);
expect(find.text(l10n.favoritesHeaderSubtitle), findsNothing);
expect(find.byType(PluriRootHeader), findsOneWidget);
});
testWidgets(
'Alarmas: the retired hero subtitle is gone, and the create-alarm '
'action is still reachable',
(tester) async {
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);
final l10n = await AppLocalizations.delegate.load(const Locale('en'));
await tester.pumpWidget(
testApp(estado, const PantallaAlarmas(), alarmas: alarmas),
);
await pumpStable(tester);
expect(find.text(l10n.alarmScreenSubtitle), findsNothing);
expect(find.byType(PluriRootHeader), findsOneWidget);
expect(find.text(l10n.createAlarmAction), findsOneWidget);
},
);
testWidgets('Ajustes: the retired hero subtitle is gone', (tester) async {
_suppressListTileInkAssertion();
setLargeSurface(tester);
final estado = crearEstadoRadio();
addTearDown(estado.dispose);
final l10n = await AppLocalizations.delegate.load(const Locale('en'));
await tester.pumpWidget(testApp(estado, const PantallaAjustes()));
await pumpStable(tester);
expect(find.text(l10n.settingsSubtitle), findsNothing);
expect(find.byType(PluriRootHeader), findsOneWidget);
});
}
+208
View File
@@ -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_idioma.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`). [PluriRootHeader]'s own title text is asserted via
/// `find.descendant` throughout — see `pluri_screen_header_retired_test.dart`
/// for S2's separate assertion that the old glass-hero subtitle is gone.
///
/// 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),
ChangeNotifierProvider<EstadoIdioma>.value(value: EstadoIdioma()),
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,
);
},
);
}
+4 -1
View File
@@ -12,7 +12,10 @@ void main() {
group('PluriWaveTokens — redesign colours', () {
test('dark keeps the pre-existing base palette', () {
expect(PluriWaveTokens.dark.deepViolet, const Color(0xFF07121A));
expect(PluriWaveTokens.dark.radiusMd, 22);
// S4 (Tier 1 visual fidelity): the prototype's dominant card radius
// is 18 (t4 lines 512, 613, 715, 133) — radiusMd used to be 22, a
// systematic +4px drift across every card that defaults to it.
expect(PluriWaveTokens.dark.radiusMd, 18);
expect(PluriWaveTokens.dark.spacingMd, 16);
});
+9
View File
@@ -65,6 +65,15 @@ void main() {
expect(type.eyebrowLabel.letterSpacing, 0.8);
});
test('S9 (Tier 1 visual fidelity): eyebrowLabel bakes in the prototype '
"colour rgba(242,247,250,.42) — it used to carry no colour at all, "
'so it rendered at full onSurface opacity', () {
expect(
type.eyebrowLabel.color,
const Color(0xFFF2F7FA).withValues(alpha: 0.42),
);
});
testWidgets('context.pluriType exposes the registered extension', (
tester,
) async {
@@ -0,0 +1,287 @@
import 'dart:ui' show Tristate;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/tema/pluriwave_theme.dart';
import 'package:pluriwave/tema/pluriwave_tokens.dart';
import 'package:pluriwave/widgets/mini_reproductor.dart';
import 'package:pluriwave/widgets/pluri_bottom_navigation.dart';
import 'package:pluriwave/widgets/pluri_icon.dart';
import 'package:pluriwave/widgets/pluri_layout.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes.dart';
/// "Barra globo" bottom navigation — Design turn t4, option 4a. Spec
/// transcribed verbatim from `PluriWave Rediseno.dc.html` lines 91-99: a
/// solid capsule bar with a taller balloon capsule that slides beneath
/// whichever tab is active.
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
const items = [
PluriNavItem(glyph: PluriIconGlyph.home, label: 'Escuchar'),
PluriNavItem(glyph: PluriIconGlyph.search, label: 'Buscar'),
PluriNavItem(glyph: PluriIconGlyph.favorites, label: 'Favoritos'),
PluriNavItem(glyph: PluriIconGlyph.alarm, label: 'Alarmas'),
PluriNavItem(glyph: PluriIconGlyph.settings, label: 'Ajustes'),
];
Widget hostFor(int selectedIndex, {ValueChanged<int>? onSelected}) {
return MaterialApp(
theme: PluriWaveTheme.dark(),
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SizedBox(
width: 390,
child: PluriBottomNavigation(
items: items,
selectedIndex: selectedIndex,
onSelected: onSelected ?? (_) {},
),
),
),
);
}
testWidgets('only the active tab renders a visible text label', (
tester,
) async {
await tester.pumpWidget(hostFor(1));
await tester.pump();
expect(find.text('Buscar'), findsOneWidget);
expect(find.text('Escuchar'), findsNothing);
expect(find.text('Favoritos'), findsNothing);
expect(find.text('Alarmas'), findsNothing);
expect(find.text('Ajustes'), findsNothing);
});
testWidgets('active icon and label use the brand colour (#21D4D9)', (
tester,
) async {
await tester.pumpWidget(hostFor(2));
await tester.pump();
final activeIcon = tester.widget<PluriIcon>(
find.descendant(
of: find.byKey(PluriBottomNavigation.itemKey(2)),
matching: find.byType(PluriIcon),
),
);
expect(activeIcon.color, PluriWaveTokens.brand);
final label = tester.widget<Text>(find.text('Favoritos'));
expect(label.style?.color, PluriWaveTokens.brand);
});
testWidgets('inactive icons are dimmed to 46% and carry no colour override', (
tester,
) async {
await tester.pumpWidget(hostFor(0));
await tester.pump();
final inactiveIcon = tester.widget<PluriIcon>(
find.descendant(
of: find.byKey(PluriBottomNavigation.itemKey(1)),
matching: find.byType(PluriIcon),
),
);
expect(inactiveIcon.color, isNull);
final dimmed = tester.widget<Opacity>(
find.ancestor(
of: find.descendant(
of: find.byKey(PluriBottomNavigation.itemKey(1)),
matching: find.byType(PluriIcon),
),
matching: find.byType(Opacity),
),
);
expect(dimmed.opacity, closeTo(0.46, 0.001));
});
testWidgets(
'the balloon exists, sized per spec, and centred over the active tab',
(tester) async {
await tester.pumpWidget(hostFor(0));
await tester.pump();
final balloon = find.byKey(PluriBottomNavigation.balloonKey);
expect(balloon, findsOneWidget);
expect(tester.getSize(balloon), const Size(110, 74));
final balloonCenterX = tester.getCenter(balloon).dx;
final tabCenterX =
tester.getCenter(find.byKey(PluriBottomNavigation.itemKey(0))).dx;
expect(balloonCenterX, closeTo(tabCenterX, 1));
final decoratedBox = tester.widget<DecoratedBox>(
find.descendant(of: balloon, matching: find.byType(DecoratedBox)),
);
final decoration = decoratedBox.decoration as BoxDecoration;
expect(decoration.color, PluriWaveTokens.dark.balloonSurface);
},
);
testWidgets('selection changes slide the balloon to the newly active tab', (
tester,
) async {
var selected = 0;
await tester.pumpWidget(
StatefulBuilder(
builder:
(context, setState) => MaterialApp(
theme: PluriWaveTheme.dark(),
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SizedBox(
width: 390,
child: PluriBottomNavigation(
items: items,
selectedIndex: selected,
onSelected: (i) => setState(() => selected = i),
),
),
),
),
),
);
await tester.pump();
final initialX =
tester.getCenter(find.byKey(PluriBottomNavigation.balloonKey)).dx;
await tester.tap(find.byKey(PluriBottomNavigation.itemKey(3)));
await tester.pump();
await tester.pump(const Duration(milliseconds: 260));
final finalX =
tester.getCenter(find.byKey(PluriBottomNavigation.balloonKey)).dx;
expect(finalX, isNot(closeTo(initialX, 1)));
expect(
finalX,
closeTo(
tester.getCenter(find.byKey(PluriBottomNavigation.itemKey(3))).dx,
1,
),
);
});
testWidgets('inactive tabs keep their semantic label without visible text', (
tester,
) async {
final semantics = tester.ensureSemantics();
await tester.pumpWidget(hostFor(0));
await tester.pump();
final node = tester.getSemantics(
find.byKey(PluriBottomNavigation.itemKey(3)),
);
expect(node.label, 'Alarmas');
expect(node.flagsCollection.isButton, isTrue);
expect(node.flagsCollection.isSelected, Tristate.isFalse);
semantics.dispose();
});
testWidgets('every tab meets the 48x48dp minimum tap target', (
tester,
) async {
await tester.pumpWidget(hostFor(0));
await tester.pump();
for (var i = 0; i < items.length; i++) {
final size = tester.getSize(
find.byKey(PluriBottomNavigation.itemKey(i)),
);
expect(size.width, greaterThanOrEqualTo(48));
expect(size.height, greaterThanOrEqualTo(48));
}
});
testWidgets('PluriBottomNavigation.altura matches its real laid-out height', (
tester,
) async {
await tester.pumpWidget(hostFor(0));
await tester.pump();
final real = tester.getSize(find.byType(PluriBottomNavigation)).height;
expect(PluriBottomNavigation.altura, closeTo(real, 0.5));
});
testWidgets(
'PluriLayout.bottomChromeInset matches the real composed chrome height '
'(MiniReproductor + PluriBottomNavigation + the safe-area gap)',
(tester) async {
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
await estado.reproducir(emisoraDemo(uuid: 'a', nombre: 'Station A'));
const chromeKey = Key('chromeSafeArea');
await tester.pumpWidget(
ChangeNotifierProvider<EstadoRadio>.value(
value: estado,
child: MaterialApp(
theme: PluriWaveTheme.dark(),
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: const SizedBox.shrink(),
bottomNavigationBar: SafeArea(
key: chromeKey,
top: false,
minimum: const EdgeInsets.only(
bottom: PluriLayout.compactGap,
),
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const MiniReproductor(),
PluriBottomNavigation(
items: items,
selectedIndex: 0,
onSelected: (_) {},
),
],
),
),
),
),
),
),
);
await tester.pump();
final medido = tester.getSize(find.byKey(chromeKey)).height;
expect(
PluriLayout.bottomChromeInset,
closeTo(medido, 4),
reason:
'bottomChromeInset must be derived from the real composed chrome '
'height (MiniReproductor + PluriBottomNavigation + the '
'safe-area gap), not guessed — if this fails, re-measure and '
'update the formula in pluri_layout.dart',
);
},
);
}
@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/tema/pluriwave_theme.dart';
import 'package:pluriwave/tema/pluriwave_tokens.dart';
import 'package:pluriwave/widgets/pluri_glass_surface.dart';
/// S3+S4 (Tier 1 visual fidelity): the prototype's own system rule (`t4`
/// line 40, verbatim) — "Superficie de lista opaca #102532, cristal solo en
/// el cromo y en la tarjeta activa" ("Opaque list surface #102532, glass
/// only in the chrome and in the active card"). [PluriGlassSurface] is used
/// for nearly every card/row in the app, so its OWN default must be the
/// opaque fill; only callers that are chrome or the active/now-playing card
/// opt into the old blurred look via `glass: true`.
void main() {
Widget host(Widget child) {
return MaterialApp(
theme: PluriWaveTheme.dark(),
home: Scaffold(body: child),
);
}
BoxDecoration decorationOf(WidgetTester tester) {
final box = tester.widget<DecoratedBox>(find.byType(DecoratedBox).first);
return box.decoration as BoxDecoration;
}
testWidgets('defaults to an OPAQUE listSurface fill with no backdrop blur', (
tester,
) async {
await tester.pumpWidget(host(const PluriGlassSurface(child: Text('row'))));
final decoration = decorationOf(tester);
expect(decoration.color, PluriWaveTokens.dark.listSurface);
expect(decoration.color!.a, 1.0, reason: 'must be fully opaque');
expect(find.byType(BackdropFilter), findsNothing);
});
testWidgets('defaults its border radius to radiusMd (18 — the '
"prototype's dominant card radius)", (tester) async {
await tester.pumpWidget(host(const PluriGlassSurface(child: Text('row'))));
final clip = tester.widget<ClipRRect>(find.byType(ClipRRect).first);
expect(
clip.borderRadius,
BorderRadius.circular(PluriWaveTokens.dark.radiusMd),
);
expect(PluriWaveTokens.dark.radiusMd, 18);
});
testWidgets('glass:true keeps the old translucent, blurred chrome look', (
tester,
) async {
await tester.pumpWidget(
host(const PluriGlassSurface(glass: true, child: Text('chrome'))),
);
final decoration = decorationOf(tester);
expect(decoration.color, PluriWaveTokens.dark.glassSurface);
expect(find.byType(BackdropFilter), findsOneWidget);
});
}
+29
View File
@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/widgets/pluri_layout.dart';
/// S5 (Tier 1 visual fidelity): the prototype runs a 3-tier horizontal
/// padding scale (`t4`) — 20px for section titles/eyebrows (lines 153, 254,
/// 299, 511), 16px for cards (lines 327, 512, 610), 12px for
/// background-less list rows (lines 174, 226, 301). The build had
/// collapsed all three into a single `PluriLayout.horizontal = 16`.
void main() {
test('titleHorizontal is 20 — the prototype\'s title/eyebrow tier', () {
expect(PluriLayout.titleHorizontal, 20);
});
test('horizontal (the card tier) stays 16 — unchanged', () {
expect(PluriLayout.horizontal, 16);
});
test('rowHorizontal is 12 — the prototype\'s background-less row tier', () {
expect(PluriLayout.rowHorizontal, 12);
});
test('titleContentPadding is symmetric horizontal titleHorizontal', () {
expect(
PluriLayout.titleContentPadding,
const EdgeInsets.symmetric(horizontal: PluriLayout.titleHorizontal),
);
});
}
+100
View File
@@ -0,0 +1,100 @@
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_layout.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('S5: uses the title tier (20px) on the left, matching the '
"prototype's own header padding (t4 e.g. Alarmas "
'`padding:0 12px 0 20px`)', (tester) async {
await tester.pumpWidget(
host(PluriRootHeader(title: 'Settings', onSleepTimer: () {})),
);
final padding = tester.widget<Padding>(find.byType(Padding).first);
final insets = padding.padding as EdgeInsets;
expect(insets.left, PluriLayout.titleHorizontal);
expect(insets.right, PluriLayout.rowHorizontal);
});
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);
});
testWidgets('S2: renders optional actions BEFORE the bedtime button — a few '
"roots' single functional action (e.g. Alarmas' create-alarm button, "
"Buscar's filters entry point) that used to live on the now-retired "
'PluriScreenHeader', (tester) async {
await tester.pumpWidget(
host(
PluriRootHeader(
title: 'Alarms',
onSleepTimer: () {},
actions: [
IconButton(icon: const Icon(Icons.add_rounded), onPressed: () {}),
],
),
),
);
expect(find.byIcon(Icons.add_rounded), findsOneWidget);
expect(find.byIcon(Icons.bedtime_outlined), findsOneWidget);
final actionRect = tester.getRect(find.byIcon(Icons.add_rounded));
final bedtimeRect = tester.getRect(find.byIcon(Icons.bedtime_outlined));
expect(
actionRect.left,
lessThan(bedtimeRect.left),
reason: 'actions render before (to the left of) the bedtime button',
);
});
}
+4 -1
View File
@@ -9,7 +9,10 @@ import 'package:pluriwave/widgets/pluri_wave_scaffold.dart';
void main() {
test('PluriWaveTokens.dark mantiene valores base esperados', () {
expect(PluriWaveTokens.dark.deepViolet, const Color(0xFF07121A));
expect(PluriWaveTokens.dark.radiusMd, 22);
// S4 (Tier 1 visual fidelity): the prototype's dominant card radius is
// 18 (t4 lines 512, 613, 715, 133) — radiusMd used to be 22, a
// systematic +4px drift across every card that defaults to it.
expect(PluriWaveTokens.dark.radiusMd, 18);
expect(PluriWaveTokens.dark.spacingMd, 16);
});
@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/tema/pluriwave_tokens.dart';
import 'package:pluriwave/widgets/pluri_glass_surface.dart';
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes.dart';
/// S4 (Tier 1 visual fidelity): the prototype's dominant ROW radius is 14
/// (t4 lines 175, 227, 302 — Favoritos/Buscar/results rows, all rendered by
/// `TarjetaEmisora(esCompacta: true)`), its dominant CARD radius is 18
/// (lines 155-160 — "Cerca de ti", rendered by the non-compact grid card).
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
Widget host(Widget child) {
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
return ChangeNotifierProvider<EstadoRadio>.value(
value: estado,
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(body: Center(child: child)),
),
);
}
BorderRadius radiusOf(WidgetTester tester) {
final surface = tester.widget<PluriGlassSurface>(
find.byType(PluriGlassSurface),
);
return surface.borderRadius as BorderRadius;
}
testWidgets('esCompacta:true (row) uses radiusSm — 14', (tester) async {
await tester.pumpWidget(
host(
SizedBox(
width: 320,
child: TarjetaEmisora(
emisora: emisoraDemo(uuid: 'row', nombre: 'Row FM'),
esCompacta: true,
),
),
),
);
await tester.pump();
expect(
radiusOf(tester),
BorderRadius.circular(PluriWaveTokens.dark.radiusSm),
);
expect(PluriWaveTokens.dark.radiusSm, 14);
});
testWidgets('esCompacta:false (card) uses radiusMd — 18', (tester) async {
await tester.pumpWidget(
host(
SizedBox(
width: 220,
height: 320,
child: TarjetaEmisora(
emisora: emisoraDemo(uuid: 'card', nombre: 'Card FM'),
),
),
),
);
await tester.pump();
expect(
radiusOf(tester),
BorderRadius.circular(PluriWaveTokens.dark.radiusMd),
);
expect(PluriWaveTokens.dark.radiusMd, 18);
});
}
@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes.dart';
/// S6 (Tier 1 visual fidelity): the prototype's station thumbnail is a
/// plain 44-48px square, radius 11/12, with NO ring or glow (t4 lines 84
/// `44px/radius 11`, 175 `46px/radius 12`, 227 `48px/radius 12`, 302
/// `48px/radius 12`, 614 `44px/radius 12`). The compact row variant used to
/// paint a 58x58 circle with a `SweepGradient` ring and a 22-blur glow
/// behind a 50x50 `ClipRRect(18)`.
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
Widget host(Widget child) {
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
return ChangeNotifierProvider<EstadoRadio>.value(
value: estado,
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(body: Center(child: child)),
),
);
}
bool isRingContainer(Widget w) =>
w is Container &&
w.decoration is BoxDecoration &&
(w.decoration as BoxDecoration).shape == BoxShape.circle &&
(w.decoration as BoxDecoration).gradient is SweepGradient;
testWidgets('esCompacta (row) thumbnail is a 48x48 square, radius 12, no '
'ring/glow', (tester) async {
await tester.pumpWidget(
host(
SizedBox(
width: 320,
child: TarjetaEmisora(
emisora: emisoraDemo(uuid: 'row', nombre: 'Row FM'),
esCompacta: true,
),
),
),
);
await tester.pump();
expect(find.byWidgetPredicate(isRingContainer), findsNothing);
// Not `.first` — the outer PluriGlassSurface also renders its own
// ClipRRect (radius 14, the row surface radius from S4), so the
// thumbnail's own clip must be found by its distinct radius instead of
// tree order.
final thumbnailClip = find.byWidgetPredicate(
(w) => w is ClipRRect && w.borderRadius == BorderRadius.circular(12),
);
expect(thumbnailClip, findsOneWidget);
expect(tester.getSize(thumbnailClip), const Size(48, 48));
});
testWidgets('esCompacta shimmer placeholder is a square block, not a '
'circle', (tester) async {
await tester.pumpWidget(
host(const TarjetaEmisoraShimmer(esCompacta: true)),
);
await tester.pump();
expect(find.byWidgetPredicate(isRingContainer), findsNothing);
final circles = tester
.widgetList<Container>(find.byType(Container))
.where(
(c) =>
c.decoration is BoxDecoration &&
(c.decoration as BoxDecoration).shape == BoxShape.circle,
);
expect(
circles,
isEmpty,
reason: 'the real thumbnail is square now — its shimmer must match',
);
});
}