diff --git a/lib/app.dart b/lib/app.dart index 4caffb7..a618333 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -8,6 +8,7 @@ import 'estado/estado_grabacion.dart'; import 'estado/estado_radio.dart'; import 'estado/estado_alarmas.dart'; import 'estado/estado_idioma.dart'; +import 'estado/estado_navegacion.dart'; import 'l10n/display_names.dart'; import 'l10n/gen/app_localizations.dart'; import 'modelos/alarma_musical.dart'; @@ -70,6 +71,9 @@ class PluriWaveApp extends StatelessWidget { ChangeNotifierProvider( create: (_) => EstadoIdioma(sharedPreferences: prefs), ), + // Design ADR-8: root-to-root navigation state. `_PaginaPrincipal` + // watches this instead of owning `_indice` locally. + ChangeNotifierProvider(create: (_) => EstadoNavegacionRaiz()), ], child: Consumer( builder: @@ -98,7 +102,6 @@ class _PaginaPrincipal extends StatefulWidget { class _PaginaPrincipalState extends State<_PaginaPrincipal> with WidgetsBindingObserver { - int _indice = 0; StreamSubscription? _errorSubscription; StreamSubscription? _alarmaSubscription; StreamSubscription? _alarmaVencidaSubscription; @@ -206,6 +209,8 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); + final navegacion = context.watch(); + final indice = navegacion.indice; return PluriWaveScaffold( appBar: AppBar( @@ -236,8 +241,8 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> ), ), child: KeyedSubtree( - key: ValueKey(_indice), - child: _paginas[_indice], + key: ValueKey(indice), + child: _paginas[indice], ), ), ), @@ -252,8 +257,8 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> const MiniReproductor(), PluriBottomNavigation( items: _navItems(l10n), - selectedIndex: _indice, - onSelected: (i) => setState(() => _indice = i), + selectedIndex: indice, + onSelected: (i) => navegacion.irA(RaizPluriWave.values[i]), ), ], ), @@ -307,7 +312,7 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> if (evento.accion.endsWith('.SKIP_NEXT')) { await estado.saltarProxima(alarma.id); if (!mounted) return; - setState(() => _indice = 3); + context.read().irA(RaizPluriWave.alarmas); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( @@ -330,7 +335,7 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> ejecucion, ); if (!mounted) return; - setState(() => _indice = 3); + context.read().irA(RaizPluriWave.alarmas); // posponerProximaDesdePreaviso no longer throws on a native scheduling // failure — it records the failure into EstadoAlarmas.error instead. // Branch on it here so the user sees the real outcome instead of an @@ -347,7 +352,7 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> return; } if (evento.accion.endsWith('.PRE_NOTICE')) { - setState(() => _indice = 3); + context.read().irA(RaizPluriWave.alarmas); return; } await _mostrarAlarmaSonando(alarma); diff --git a/lib/estado/estado_navegacion.dart b/lib/estado/estado_navegacion.dart new file mode 100644 index 0000000..2cc40ef --- /dev/null +++ b/lib/estado/estado_navegacion.dart @@ -0,0 +1,24 @@ +import 'package:flutter/foundation.dart'; + +/// The five root tabs, in their declared (and displayed) order. Declaration +/// order IS the tab order — the single source shared by `_paginas` and +/// `_navItems` in app.dart (Design ADR-8). +enum RaizPluriWave { escuchar, buscar, favoritos, alarmas, ajustes } + +/// Root-to-root navigation state (Design ADR-8). The single source of truth +/// for which of the 5 root tabs is active. Switching roots is a plain +/// notifier update — it never pushes a route. +class EstadoNavegacionRaiz extends ChangeNotifier { + RaizPluriWave _actual = RaizPluriWave.escuchar; + + RaizPluriWave get actual => _actual; + + /// Declaration order doubles as the bottom-nav index. + int get indice => _actual.index; + + void irA(RaizPluriWave raiz) { + if (raiz == _actual) return; + _actual = raiz; + notifyListeners(); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 61ebbf0..97dc7c2 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,7 +1,7 @@ { "@@locale": "en", "appTitle": "PluriWave", - "navHome": "Home", + "navHome": "Listen", "navSearch": "Search", "navFavorites": "Favorites", "navAlarms": "Alarms", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index a352c1c..7ed82c3 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1,7 +1,7 @@ { "@@locale": "es", "appTitle": "PluriWave", - "navHome": "Inicio", + "navHome": "Escuchar", "navSearch": "Buscar", "navFavorites": "Favoritos", "navAlarms": "Alarmas", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 9a7e805..85f3f02 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -129,7 +129,7 @@ abstract class AppLocalizations { /// No description provided for @navHome. /// /// In es, this message translates to: - /// **'Inicio'** + /// **'Escuchar'** String get navHome; /// No description provided for @navSearch. diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 56bd3a2..ff179ca 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -12,7 +12,7 @@ class AppLocalizationsEn extends AppLocalizations { String get appTitle => 'PluriWave'; @override - String get navHome => 'Home'; + String get navHome => 'Listen'; @override String get navSearch => 'Search'; diff --git a/lib/l10n/gen/app_localizations_es.dart b/lib/l10n/gen/app_localizations_es.dart index 8981351..cb87915 100644 --- a/lib/l10n/gen/app_localizations_es.dart +++ b/lib/l10n/gen/app_localizations_es.dart @@ -12,7 +12,7 @@ class AppLocalizationsEs extends AppLocalizations { String get appTitle => 'PluriWave'; @override - String get navHome => 'Inicio'; + String get navHome => 'Escuchar'; @override String get navSearch => 'Buscar'; diff --git a/lib/tema/pluriwave_theme.dart b/lib/tema/pluriwave_theme.dart index e6e8b0d..fea1470 100644 --- a/lib/tema/pluriwave_theme.dart +++ b/lib/tema/pluriwave_theme.dart @@ -3,16 +3,21 @@ import 'package:google_fonts/google_fonts.dart'; import 'pluriwave_motion.dart'; import 'pluriwave_tokens.dart'; +import 'pluriwave_typography.dart'; abstract final class PluriWaveTheme { static ThemeData dark() { const tokens = PluriWaveTokens.dark; + final baseTextTheme = GoogleFonts.plusJakartaSansTextTheme( + ThemeData.dark().textTheme, + ); + final typography = PluriWaveTypography.from(baseTextTheme); final colorScheme = const ColorScheme.dark().copyWith( primary: tokens.electricMagenta, - secondary: const Color(0xFF7EE4C2), + secondary: tokens.liveGreen, tertiary: tokens.warmCoral, surface: const Color(0xFF0D1B24), - surfaceContainerLow: const Color(0xFF102532), + surfaceContainerLow: tokens.listSurface, surfaceContainerHighest: const Color(0xFF1B3942), onSurface: const Color(0xFFF2F7FA), onPrimary: Colors.white, @@ -22,10 +27,12 @@ abstract final class PluriWaveTheme { useMaterial3: true, colorScheme: colorScheme, scaffoldBackgroundColor: tokens.deepViolet, - textTheme: GoogleFonts.plusJakartaSansTextTheme( - ThemeData.dark().textTheme, - ), - extensions: const >[tokens, PluriWaveMotion.dark], + textTheme: baseTextTheme, + extensions: >[ + tokens, + PluriWaveMotion.dark, + typography, + ], appBarTheme: const AppBarTheme( centerTitle: false, backgroundColor: Colors.transparent, @@ -69,4 +76,8 @@ extension PluriWaveThemeContextX on BuildContext { PluriWaveMotion get pluriMotion => Theme.of(this).extension() ?? PluriWaveMotion.dark; + + PluriWaveTypography get pluriType => + Theme.of(this).extension() ?? + PluriWaveTypography.from(Theme.of(this).textTheme); } diff --git a/lib/tema/pluriwave_tokens.dart b/lib/tema/pluriwave_tokens.dart index 4b0210b..0fe0b9e 100644 --- a/lib/tema/pluriwave_tokens.dart +++ b/lib/tema/pluriwave_tokens.dart @@ -11,6 +11,9 @@ class PluriWaveTokens extends ThemeExtension { required this.glassSurface, required this.glassBorder, required this.glowColor, + required this.listSurface, + required this.liveGreen, + required this.offlineAccent, required this.radiusSm, required this.radiusMd, required this.radiusLg, @@ -27,6 +30,21 @@ class PluriWaveTokens extends ThemeExtension { final Color glassBorder; final Color glowColor; + /// Redesign additions (Design ADR-1). Row/card surface for the flat lists + /// introduced by the redesign (Favoritos, Buscar results). De-literalised + /// from `pluriwave_theme.dart`'s previous `surfaceContainerLow` literal — + /// same value, now named — so rendered output stays byte-identical. + final Color listSurface; + + /// "EN DIRECTO" indicator and the Escuchar hero's waveform colour. + /// De-literalised from `pluriwave_theme.dart`'s previous `secondary` + /// literal — same value, now named. + final Color liveGreen; + + /// Offline/reconnect banner accent (WU16). No prior literal to promote — + /// a genuinely new colour. + final Color offlineAccent; + final double radiusSm; final double radiusMd; final double radiusLg; @@ -53,6 +71,9 @@ class PluriWaveTokens extends ThemeExtension { glassSurface: Color(0x1FFFFFFF), glassBorder: Color(0x33FFFFFF), glowColor: Color(0x6621D4D9), + listSurface: Color(0xFF102532), + liveGreen: Color(0xFF7EE4C2), + offlineAccent: Color(0xFF94A3B8), radiusSm: 14, radiusMd: 22, radiusLg: 30, @@ -70,6 +91,9 @@ class PluriWaveTokens extends ThemeExtension { Color? glassSurface, Color? glassBorder, Color? glowColor, + Color? listSurface, + Color? liveGreen, + Color? offlineAccent, double? radiusSm, double? radiusMd, double? radiusLg, @@ -85,6 +109,9 @@ class PluriWaveTokens extends ThemeExtension { glassSurface: glassSurface ?? this.glassSurface, glassBorder: glassBorder ?? this.glassBorder, glowColor: glowColor ?? this.glowColor, + listSurface: listSurface ?? this.listSurface, + liveGreen: liveGreen ?? this.liveGreen, + offlineAccent: offlineAccent ?? this.offlineAccent, radiusSm: radiusSm ?? this.radiusSm, radiusMd: radiusMd ?? this.radiusMd, radiusLg: radiusLg ?? this.radiusLg, @@ -111,6 +138,10 @@ class PluriWaveTokens extends ThemeExtension { Color.lerp(glassSurface, other.glassSurface, t) ?? glassSurface, glassBorder: Color.lerp(glassBorder, other.glassBorder, t) ?? glassBorder, glowColor: Color.lerp(glowColor, other.glowColor, t) ?? glowColor, + listSurface: Color.lerp(listSurface, other.listSurface, t) ?? listSurface, + liveGreen: Color.lerp(liveGreen, other.liveGreen, t) ?? liveGreen, + offlineAccent: + Color.lerp(offlineAccent, other.offlineAccent, t) ?? offlineAccent, radiusSm: lerpDouble(radiusSm, other.radiusSm, t) ?? radiusSm, radiusMd: lerpDouble(radiusMd, other.radiusMd, t) ?? radiusMd, radiusLg: lerpDouble(radiusLg, other.radiusLg, t) ?? radiusLg, diff --git a/lib/tema/pluriwave_typography.dart b/lib/tema/pluriwave_typography.dart new file mode 100644 index 0000000..8a8d5e3 --- /dev/null +++ b/lib/tema/pluriwave_typography.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; + +/// Design ADR-1: the named type scale, split out of [PluriWaveTokens] into +/// its own ThemeExtension so screens stop copy-pasting +/// `.copyWith(fontWeight: w900, letterSpacing: …)`. The set is closed at six +/// styles — a seventh requires amending ADR-1. +/// +/// [eyebrowLabel] never applies `toUpperCase()` — casing is a +/// locale-hostile transform (Turkish dotless i, and scripts with no case at +/// all). The style carries weight/letter-spacing only; each ARB value is +/// authored in its own display form. +@immutable +class PluriWaveTypography extends ThemeExtension { + const PluriWaveTypography({ + required this.heroTime, + required this.sectionTitle, + required this.screenTitle, + required this.cardTitle, + required this.bodyStrong, + required this.eyebrowLabel, + }); + + /// 88 / w800 / height 1.0 / ls -2.0. Alarm ringing, inline time editor. + /// Every call site must wrap this in `FittedBox(fit: BoxFit.scaleDown)` — + /// it renders at 176px under a 2.0 text scaler. + final TextStyle heroTime; + + /// 23 / w800 / ls -0.6. Root screen section headings. + final TextStyle sectionTitle; + + /// 19 / w800 / ls -0.4. PluriPushScaffold header title. + final TextStyle screenTitle; + + /// 14.5 / w700. Station cards, settings rows, alarm cards. + final TextStyle cardTitle; + + /// 13 / w600. Card subtitles, meta lines. + final TextStyle bodyStrong; + + /// 11 / w800 / ls 0.8. "EN DIRECTO", "PROGRAMADOS", settings group + /// headers. + final TextStyle eyebrowLabel; + + /// Builds the scale from [family] — the SAME resolved Google Fonts + /// TextTheme instance [PluriWaveTheme.dark] already computes. Building the + /// styles anywhere else would re-merge the font family, which is the + /// copy-paste this extension exists to eliminate. + factory PluriWaveTypography.from(TextTheme family) { + final fallback = family.bodyMedium ?? const TextStyle(); + TextStyle style( + double fontSize, + FontWeight fontWeight, { + double? letterSpacing, + double? height, + }) { + return fallback.copyWith( + fontSize: fontSize, + fontWeight: fontWeight, + letterSpacing: letterSpacing, + height: height, + ); + } + + return PluriWaveTypography( + heroTime: style(88, FontWeight.w800, letterSpacing: -2.0, height: 1.0), + sectionTitle: style(23, FontWeight.w800, letterSpacing: -0.6), + 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), + ); + } + + @override + PluriWaveTypography copyWith({ + TextStyle? heroTime, + TextStyle? sectionTitle, + TextStyle? screenTitle, + TextStyle? cardTitle, + TextStyle? bodyStrong, + TextStyle? eyebrowLabel, + }) { + return PluriWaveTypography( + heroTime: heroTime ?? this.heroTime, + sectionTitle: sectionTitle ?? this.sectionTitle, + screenTitle: screenTitle ?? this.screenTitle, + cardTitle: cardTitle ?? this.cardTitle, + bodyStrong: bodyStrong ?? this.bodyStrong, + eyebrowLabel: eyebrowLabel ?? this.eyebrowLabel, + ); + } + + @override + PluriWaveTypography lerp( + covariant ThemeExtension? other, + double t, + ) { + if (other is! PluriWaveTypography) return this; + return PluriWaveTypography( + heroTime: TextStyle.lerp(heroTime, other.heroTime, t) ?? heroTime, + sectionTitle: + TextStyle.lerp(sectionTitle, other.sectionTitle, t) ?? sectionTitle, + screenTitle: + TextStyle.lerp(screenTitle, other.screenTitle, t) ?? screenTitle, + cardTitle: TextStyle.lerp(cardTitle, other.cardTitle, t) ?? cardTitle, + bodyStrong: TextStyle.lerp(bodyStrong, other.bodyStrong, t) ?? bodyStrong, + eyebrowLabel: + TextStyle.lerp(eyebrowLabel, other.eyebrowLabel, t) ?? eyebrowLabel, + ); + } +} diff --git a/lib/widgets/pluri_push_scaffold.dart b/lib/widgets/pluri_push_scaffold.dart new file mode 100644 index 0000000..cd8b22e --- /dev/null +++ b/lib/widgets/pluri_push_scaffold.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; + +import '../tema/pluriwave_theme.dart'; +import 'pluri_wave_scaffold.dart'; + +/// Design ADR-2: chrome is decided by route topology, not a parameter. +/// [PluriPushScaffold] is the ONE shape a second-level (pushed) screen may +/// take — root screens are plain body widgets living in `_PaginaPrincipal` +/// and never construct a Scaffold themselves. +/// +/// The load-bearing part of this API is what it does NOT have: there is no +/// `bottomNavigationBar` parameter. That absence is what makes "second-level +/// screens have no tab bar" unrepresentable rather than a convention someone +/// can forget. +class PluriPushScaffold extends StatelessWidget { + const PluriPushScaffold({ + super.key, + required this.title, + required this.body, + this.titleOverride, + this.leadingIcon = Icons.arrow_back_rounded, + this.onBack, + this.actions = const [], + this.bottom, + this.floatingActionButton, + }); + + /// Styled once, here, with [PluriWaveTypography.screenTitle] — the reason + /// this is a String and not a Widget, so screens stop copy-pasting the + /// style. See [titleOverride] for the one documented exception. + final String title; + + final Widget body; + + /// Single documented exception to [title]: the full player's centered + /// "EN DIRECTO" pill (WU14). A second consumer means ADR-2 gets revisited, + /// not that this parameter quietly becomes the general case. + final Widget? titleOverride; + + /// Defaults to a back arrow; `pantalla_reproductor.dart` dismisses with + /// `keyboard_arrow_down` instead — still one pushed route, one back + /// affordance, only the glyph differs. + final IconData leadingIcon; + + /// Defaults to [Navigator.maybePop]. + final VoidCallback? onBack; + + final List actions; + + /// Optional persistent footer (e.g. a CTA). + final PreferredSizeWidget? bottom; + + final Widget? floatingActionButton; + + static const double headerHeight = 56; + + /// Centralises route construction so the transition is uniform and a test + /// can assert "pushed, not index-switched". Precedent: + /// `PantallaReproductor.abrir`. + static Future push(BuildContext context, WidgetBuilder builder) { + return Navigator.of( + context, + ).push(MaterialPageRoute(builder: builder)); + } + + @override + Widget build(BuildContext context) { + final type = context.pluriType; + return PluriWaveScaffold( + appBar: AppBar( + toolbarHeight: headerHeight, + automaticallyImplyLeading: false, + leading: IconButton( + icon: Icon(leadingIcon), + onPressed: onBack ?? () => Navigator.maybePop(context), + ), + title: titleOverride ?? Text(title, style: type.screenTitle), + actions: actions, + bottom: bottom, + ), + body: body, + floatingActionButton: floatingActionButton, + ); + } +} diff --git a/openspec/changes/rediseno-funcional/tasks.md b/openspec/changes/rediseno-funcional/tasks.md index 3b7a521..8d79cfb 100644 --- a/openspec/changes/rediseno-funcional/tasks.md +++ b/openspec/changes/rediseno-funcional/tasks.md @@ -101,31 +101,31 @@ Switching Without Push (provider only; consumption is WU5), Escuchar Tab Rename **New tests**: `test/tema/pluriwave_typography_test.dart`, `test/widgets/pluri_push_scaffold_test.dart`, `test/estado/estado_navegacion_test.dart` **Modified tests**: `test/tema/pluriwave_tokens_test.dart` (or equivalent), `test/widget_test.dart` -- [ ] 1.1 RED — write `pluriwave_typography_test.dart` asserting the 6 named styles (`heroTime`, `sectionTitle`, +- [x] 1.1 RED — write `pluriwave_typography_test.dart` asserting the 6 named styles (`heroTime`, `sectionTitle`, `screenTitle`, `cardTitle`, `bodyStrong`, `eyebrowLabel`) exist with their documented size/weight/letter-spacing and that `PluriWaveTheme.dark()` registers the extension. -- [ ] 1.2 RED — extend the tokens test asserting `PluriWaveTokens.listSurface`, `.liveGreen`, `.offlineAccent` exist +- [x] 1.2 RED — extend the tokens test asserting `PluriWaveTokens.listSurface`, `.liveGreen`, `.offlineAccent` exist and participate in `lerp`. -- [ ] 1.3 GREEN — add the 3 colour fields to `lib/tema/pluriwave_tokens.dart` (constructor, `copyWith`, `lerp`); +- [x] 1.3 GREEN — add the 3 colour fields to `lib/tema/pluriwave_tokens.dart` (constructor, `copyWith`, `lerp`); de-literalise `pluriwave_theme.dart:12,15`; create `lib/tema/pluriwave_typography.dart` built inside `PluriWaveTheme.dark()`. -- [ ] 1.4 RED — write `pluri_push_scaffold_test.dart`: each of the 5 root screens mounted bare builds zero +- [x] 1.4 RED — write `pluri_push_scaffold_test.dart`: each of the 5 root screens mounted bare builds zero `Scaffold`; a `PluriPushScaffold` renders exactly one 56px `AppBar` + back affordance and exposes no `bottomNavigationBar` parameter on its constructor. -- [ ] 1.5 GREEN — implement `lib/widgets/pluri_push_scaffold.dart` per the ADR-2 API (`title`, `body`, +- [x] 1.5 GREEN — implement `lib/widgets/pluri_push_scaffold.dart` per the ADR-2 API (`title`, `body`, `titleOverride`, `leadingIcon`, `onBack`, `actions`, `bottom`, `floatingActionButton`, static `push`). -- [ ] 1.6 RED — write `estado_navegacion_test.dart`: `irA()` transitions `RaizPluriWave`, no-ops and does not notify +- [x] 1.6 RED — write `estado_navegacion_test.dart`: `irA()` transitions `RaizPluriWave`, no-ops and does not notify on same-root, `indice` matches enum declaration order. -- [ ] 1.7 GREEN — implement `lib/estado/estado_navegacion.dart` (`RaizPluriWave` enum, `EstadoNavegacionRaiz`); +- [x] 1.7 GREEN — implement `lib/estado/estado_navegacion.dart` (`RaizPluriWave` enum, `EstadoNavegacionRaiz`); register it in `app.dart`'s `MultiProvider`; convert the 3 `setState(() => _indice = ...)` alarm-deep-link sites (`app.dart:310,333,350`) to `irA(...)`. -- [ ] 1.8 RED — update `test/widget_test.dart` to expect "Escuchar" (not "Inicio") as the first tab's label, with +- [x] 1.8 RED — update `test/widget_test.dart` to expect "Escuchar" (not "Inicio") as the first tab's label, with `PluriIconGlyph.home` unchanged as its icon. -- [ ] 1.9 GREEN — change `app_en.arb` line 4 `navHome` value to `"Listen"` and `app_es.arb` line 4 to `"Escuchar"`; +- [x] 1.9 GREEN — change `app_en.arb` line 4 `navHome` value to `"Listen"` and `app_es.arb` line 4 to `"Escuchar"`; retitle the first tab in `lib/app.dart`. Leave the other 11 ARB files untouched (WU18's job). -- [ ] 1.10 REFACTOR — sweep `lib/tema/`, `app.dart` for now-dead literals; confirm zero consumer screens were +- [x] 1.10 REFACTOR — sweep `lib/tema/`, `app.dart` for now-dead literals; confirm zero consumer screens were touched (WU1 converts zero screens to `PluriPushScaffold` — that is what keeps this commit at its line budget). -- [ ] 1.11 Verify — run the command above; confirm `git diff --stat` touches only tokens/theme/typography files, the +- [x] 1.11 Verify — run the command above; confirm `git diff --stat` touches only tokens/theme/typography files, the new push-scaffold and nav-state files, `app.dart`, `app_en.arb`, `app_es.arb`, and their tests. ## WU2 — Android Auto verification (zero code) diff --git a/test/estado/estado_navegacion_test.dart b/test/estado/estado_navegacion_test.dart new file mode 100644 index 0000000..eebd7c0 --- /dev/null +++ b/test/estado/estado_navegacion_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_navegacion.dart'; + +/// Design ADR-8: EstadoNavegacionRaiz is the single source of truth for +/// which of the 5 root tabs is active. Switching roots never pushes a +/// route — this is a plain ChangeNotifier update. +void main() { + group('RaizPluriWave', () { + test('declaration order is the tab order (0-indexed)', () { + expect(RaizPluriWave.escuchar.index, 0); + expect(RaizPluriWave.buscar.index, 1); + expect(RaizPluriWave.favoritos.index, 2); + expect(RaizPluriWave.alarmas.index, 3); + expect(RaizPluriWave.ajustes.index, 4); + expect(RaizPluriWave.values.length, 5); + }); + }); + + group('EstadoNavegacionRaiz', () { + test('starts on escuchar', () { + final estado = EstadoNavegacionRaiz(); + expect(estado.actual, RaizPluriWave.escuchar); + expect(estado.indice, 0); + }); + + test('irA transitions to the requested root', () { + final estado = EstadoNavegacionRaiz(); + + estado.irA(RaizPluriWave.favoritos); + + expect(estado.actual, RaizPluriWave.favoritos); + }); + + test('indice mirrors the enum declaration order after a transition', () { + final estado = EstadoNavegacionRaiz(); + + estado.irA(RaizPluriWave.alarmas); + + expect(estado.indice, RaizPluriWave.alarmas.index); + expect(estado.indice, 3); + }); + + test('irA notifies listeners on a real transition', () { + final estado = EstadoNavegacionRaiz(); + var notifyCount = 0; + estado.addListener(() => notifyCount++); + + estado.irA(RaizPluriWave.buscar); + + expect(notifyCount, 1); + }); + + test('irA no-ops and does not notify when already on that root', () { + final estado = EstadoNavegacionRaiz(); + var notifyCount = 0; + estado.addListener(() => notifyCount++); + + estado.irA(RaizPluriWave.escuchar); // already the starting root + + expect(notifyCount, 0); + expect(estado.actual, RaizPluriWave.escuchar); + }); + + test('a repeated irA to the same non-initial root only notifies once', () { + final estado = EstadoNavegacionRaiz(); + estado.irA(RaizPluriWave.ajustes); + var notifyCount = 0; + estado.addListener(() => notifyCount++); + + estado.irA(RaizPluriWave.ajustes); + + expect(notifyCount, 0); + }); + }); +} diff --git a/test/tema/pluriwave_tokens_test.dart b/test/tema/pluriwave_tokens_test.dart new file mode 100644 index 0000000..54f5575 --- /dev/null +++ b/test/tema/pluriwave_tokens_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/tema/pluriwave_tokens.dart'; + +/// Design ADR-1: 3 new colour fields join the existing PluriWaveTokens +/// extension (colours belong here because they must participate in lerp). +/// Dedicated file under test/tema/ so the WU1 verify command +/// (`flutter test test/tema/ ...`) discovers it; the single pre-existing +/// PluriWaveTokens assertion inside pluriwave_foundations_test.dart is left +/// untouched. +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); + expect(PluriWaveTokens.dark.spacingMd, 16); + }); + + test( + 'dark exposes listSurface and liveGreen de-literalised from theme.dart', + () { + // Design ADR-1: these match the theme's previous raw literals + // (surfaceContainerLow / secondary) exactly, so promoting them to + // named tokens keeps rendered output byte-identical. + expect(PluriWaveTokens.dark.listSurface, const Color(0xFF102532)); + expect(PluriWaveTokens.dark.liveGreen, const Color(0xFF7EE4C2)); + }, + ); + + test('dark exposes offlineAccent as a genuinely new colour', () { + expect(PluriWaveTokens.dark.offlineAccent, const Color(0xFF94A3B8)); + }); + + test('copyWith overrides only the new colour fields', () { + final overridden = PluriWaveTokens.dark.copyWith( + listSurface: Colors.black, + liveGreen: Colors.black, + offlineAccent: Colors.black, + ); + expect(overridden.listSurface, Colors.black); + expect(overridden.liveGreen, Colors.black); + expect(overridden.offlineAccent, Colors.black); + // Untouched fields keep their original value. + expect(overridden.deepViolet, PluriWaveTokens.dark.deepViolet); + }); + + test('lerp interpolates listSurface, liveGreen and offlineAccent', () { + const a = PluriWaveTokens.dark; + final b = a.copyWith( + listSurface: Colors.white, + liveGreen: Colors.white, + offlineAccent: Colors.white, + ); + + final mid = a.lerp(b, 0.5); + + expect(mid.listSurface, Color.lerp(a.listSurface, Colors.white, 0.5)); + expect(mid.liveGreen, Color.lerp(a.liveGreen, Colors.white, 0.5)); + expect(mid.offlineAccent, Color.lerp(a.offlineAccent, Colors.white, 0.5)); + }); + }); +} diff --git a/test/tema/pluriwave_typography_test.dart b/test/tema/pluriwave_typography_test.dart new file mode 100644 index 0000000..7d286b3 --- /dev/null +++ b/test/tema/pluriwave_typography_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/tema/pluriwave_theme.dart'; +import 'package:pluriwave/tema/pluriwave_typography.dart'; + +/// Design ADR-1: the named type scale is a second ThemeExtension, closed at +/// six styles. Pins the documented size/weight/letter-spacing per style and +/// confirms PluriWaveTheme.dark() registers it, mirroring how +/// PluriWaveTokens/PluriWaveMotion are already tested. +/// +/// PluriWaveTheme.dark() is only ever resolved from inside a testWidgets +/// callback (never from setUpAll or a plain test()). GoogleFonts. +/// plusJakartaSansTextTheme fires a background network fetch per text role; +/// TestWidgetsFlutterBinding only installs its HTTP-blocking override +/// inside an active test-widgets zone, so calling PluriWaveTheme.dark() +/// outside one lets a real (and here unreachable) network request escape +/// and fail asynchronously. The numeric values asserted below (fontSize/ +/// weight/letterSpacing/height) are set synchronously by +/// PluriWaveTypography.from regardless of that background fetch's outcome. +void main() { + late PluriWaveTypography type; + + group('PluriWaveTypography', () { + testWidgets('PluriWaveTheme.dark() registers the extension', ( + tester, + ) async { + final theme = PluriWaveTheme.dark(); + final extension = theme.extension(); + expect(extension, isNotNull); + type = extension!; + }); + + test('heroTime is 88 / w800 / height 1.0 / letter-spacing -2.0', () { + expect(type.heroTime.fontSize, 88); + expect(type.heroTime.fontWeight, FontWeight.w800); + expect(type.heroTime.height, 1.0); + expect(type.heroTime.letterSpacing, -2.0); + }); + + test('sectionTitle is 23 / w800 / letter-spacing -0.6', () { + expect(type.sectionTitle.fontSize, 23); + expect(type.sectionTitle.fontWeight, FontWeight.w800); + expect(type.sectionTitle.letterSpacing, -0.6); + }); + + test('screenTitle is 19 / w800 / letter-spacing -0.4', () { + expect(type.screenTitle.fontSize, 19); + expect(type.screenTitle.fontWeight, FontWeight.w800); + expect(type.screenTitle.letterSpacing, -0.4); + }); + + test('cardTitle is 14.5 / w700', () { + expect(type.cardTitle.fontSize, 14.5); + expect(type.cardTitle.fontWeight, FontWeight.w700); + }); + + test('bodyStrong is 13 / w600', () { + expect(type.bodyStrong.fontSize, 13); + expect(type.bodyStrong.fontWeight, FontWeight.w600); + }); + + test('eyebrowLabel is 11 / w800 / letter-spacing 0.8', () { + expect(type.eyebrowLabel.fontSize, 11); + expect(type.eyebrowLabel.fontWeight, FontWeight.w800); + expect(type.eyebrowLabel.letterSpacing, 0.8); + }); + + testWidgets('context.pluriType exposes the registered extension', ( + tester, + ) async { + late BuildContext capturedContext; + await tester.pumpWidget( + MaterialApp( + theme: PluriWaveTheme.dark(), + home: Builder( + builder: (context) { + capturedContext = context; + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect(capturedContext.pluriType.cardTitle.fontSize, 14.5); + }); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index fd5950c..bd0136c 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -3,7 +3,12 @@ // existe en este proyecto — corregido para usar PluriWaveApp. // Los tests de integración completos (audio, streaming) requieren un dispositivo. +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_bottom_navigation.dart'; +import 'package:pluriwave/widgets/pluri_icon.dart'; void main() { testWidgets('Placeholder — tests de integración requieren dispositivo', ( @@ -14,4 +19,54 @@ void main() { // por el test de smoke incorrecto del boilerplate original. expect(true, isTrue); }); + + // Spec app-navigation-shell — "Escuchar Tab Rename Preserves the ARB Key" + // and "Escuchar uses the home glyph": the former "Inicio" tab is + // relabeled by changing navHome's VALUE only; PluriIconGlyph.home stays + // its icon. app.dart's real _navItems()/_paginas wiring is private to + // that library, so this pins the same two contracts at the pieces it + // composes: the ARB value, the PluriNavItem the tab is built from, and + // that PluriIconGlyph.home still resolves to a renderable icon. + group('Escuchar tab rename (WU1)', () { + test('navHome value renamed in en/es without touching the key', () async { + final l10nEs = await AppLocalizations.delegate.load(const Locale('es')); + final l10nEn = await AppLocalizations.delegate.load(const Locale('en')); + + expect(l10nEs.navHome, 'Escuchar'); + expect(l10nEn.navHome, 'Listen'); + }); + + test( + 'the home tab item still pairs navHome with PluriIconGlyph.home', + () async { + final l10n = await AppLocalizations.delegate.load(const Locale('es')); + + final item = PluriNavItem( + glyph: PluriIconGlyph.home, + label: l10n.navHome, + ); + + expect(item.label, 'Escuchar'); + expect(item.glyph, PluriIconGlyph.home); + }, + ); + + testWidgets('PluriIconGlyph.home still renders as the home icon', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: PluriWaveTheme.dark(), + home: const Scaffold( + body: PluriIcon( + glyph: PluriIconGlyph.home, + semanticLabel: 'Escuchar', + ), + ), + ), + ); + + expect(find.bySemanticsLabel('Escuchar'), findsOneWidget); + }); + }); } diff --git a/test/widgets/pluri_push_scaffold_test.dart b/test/widgets/pluri_push_scaffold_test.dart new file mode 100644 index 0000000..fac7f22 --- /dev/null +++ b/test/widgets/pluri_push_scaffold_test.dart @@ -0,0 +1,280 @@ +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_push_scaffold.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../helpers/fakes.dart'; +import '../helpers/fakes_alarmas.dart'; + +/// Design ADR-2 enforcement test: chrome is decided by route topology, not a +/// parameter. Mounting each of the 5 root screens bare (no ancestor +/// PluriWaveScaffold/Scaffold) must render zero Scaffolds — "no tab bar on +/// second-level screens" becomes a structural fact, not a convention. A +/// PluriPushScaffold, by contrast, is the ONE place a second-level screen +/// gets a Scaffold, and it must render exactly one 56px AppBar with a back +/// affordance. +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + Widget testApp(Widget home) { + return MaterialApp( + locale: const Locale('es'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + // No Scaffold ancestor here on purpose — a bare Material is enough + // for InkWell/Card without secretly providing the very Scaffold this + // test asserts is absent. + home: Material(child: home), + ); + } + + Future archivoCustomVacio() async => File( + '${Directory.current.path}/test/fixtures/emisoras_custom_vacio.json', + ); + + // Existing project convention (see pantalla_inicio_test.dart et al.): the + // default 800x600 test surface clips these content-heavy root screens. + void setLargeSurface(WidgetTester tester) { + tester.view.physicalSize = const Size(1440, 3200); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + } + + // 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); + } + + group('The 5 root screens build zero Scaffold when mounted bare', () { + testWidgets('PantallaInicio', (tester) async { + setLargeSurface(tester); + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + servicioGrabacion: FakeServicioGrabacionRadioInactiva(), + resolverArchivoCustom: archivoCustomVacio, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: estado), + ListenableProvider.value( + value: estado.ecualizador, + ), + ListenableProvider.value(value: estado.grabacion), + ListenableProvider.value(value: estado.busqueda), + ], + child: testApp(const PantallaInicio()), + ), + ); + await tester.pump(); + + expect(find.byType(Scaffold), findsNothing); + }); + + testWidgets('PantallaBuscar', (tester) async { + setLargeSurface(tester); + final busqueda = EstadoBusqueda(radio: FakeServicioRadio()); + addTearDown(busqueda.dispose); + + await tester.pumpWidget( + ListenableProvider.value( + value: busqueda, + child: testApp(const PantallaBuscar()), + ), + ); + await tester.pump(); + + expect(find.byType(Scaffold), findsNothing); + }); + + testWidgets('PantallaFavoritos', (tester) async { + setLargeSurface(tester); + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + servicioGrabacion: FakeServicioGrabacionRadioInactiva(), + resolverArchivoCustom: archivoCustomVacio, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: estado, + child: testApp(const PantallaFavoritos()), + ), + ); + await tester.pump(); + + expect(find.byType(Scaffold), findsNothing); + }); + + testWidgets('PantallaAlarmas', (tester) async { + setLargeSurface(tester); + final android = FakePuertoAlarmasAndroid(); + final estadoAlarmas = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: DateTime.now), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estadoAlarmas.dispose); + addTearDown(android.dispose); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: estadoAlarmas, + child: testApp(const PantallaAlarmas()), + ), + ); + await tester.pump(); + + expect(find.byType(Scaffold), findsNothing); + }); + + testWidgets('PantallaAjustes', (tester) async { + setLargeSurface(tester); + suppressListTileInkAssertion(); + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + servicioGrabacion: FakeServicioGrabacionRadioInactiva(), + resolverArchivoCustom: archivoCustomVacio, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + final estadoIdioma = EstadoIdioma(); + addTearDown(estadoIdioma.dispose); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: estado), + ListenableProvider.value( + value: estado.ecualizador, + ), + ListenableProvider.value(value: estado.grabacion), + ChangeNotifierProvider.value(value: estadoIdioma), + ], + child: testApp(const PantallaAjustes()), + ), + ); + await tester.pump(); + + expect(find.byType(Scaffold), findsNothing); + }); + }); + + group('PluriPushScaffold', () { + testWidgets('renders exactly one 56px AppBar with a back affordance', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Navigator( + onGenerateRoute: + (settings) => MaterialPageRoute( + builder: + (_) => const PluriPushScaffold( + title: 'Detalle', + body: SizedBox.shrink(), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(Scaffold), findsOneWidget); + expect(find.byType(AppBar), findsOneWidget); + final appBar = tester.widget(find.byType(AppBar)); + expect(appBar.toolbarHeight, PluriPushScaffold.headerHeight); + expect(PluriPushScaffold.headerHeight, 56); + expect(find.byIcon(Icons.arrow_back_rounded), findsOneWidget); + expect(find.text('Detalle'), findsOneWidget); + }); + + // Design ADR-2's load-bearing contract: PluriPushScaffold has NO + // bottomNavigationBar parameter. This is a type-system guarantee, not a + // runtime one — passing `bottomNavigationBar: ...` below would simply + // fail to compile. There is nothing to assert dynamically; the absence + // of that parameter IS the enforcement. + + testWidgets('push navigates via Navigator.push (route depth increases)', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: + (context) => Material( + child: Center( + child: ElevatedButton( + onPressed: + () => PluriPushScaffold.push( + context, + (_) => const PluriPushScaffold( + title: 'Empujada', + body: SizedBox.shrink(), + ), + ), + child: const Text('abrir'), + ), + ), + ), + ), + ), + ); + + expect(find.byType(PluriPushScaffold), findsNothing); + await tester.tap(find.text('abrir')); + await tester.pumpAndSettle(); + + expect(find.byType(PluriPushScaffold), findsOneWidget); + expect(find.text('Empujada'), findsOneWidget); + }); + }); +}