feat(tokens): add design tokens, type scale, push scaffold, and root nav state

This commit is contained in:
2026-07-28 20:01:23 +02:00
parent 6dba89432e
commit 172b2a42ac
17 changed files with 856 additions and 30 deletions
+13 -8
View File
@@ -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<EstadoIdioma>(
builder:
@@ -98,7 +102,6 @@ class _PaginaPrincipal extends StatefulWidget {
class _PaginaPrincipalState extends State<_PaginaPrincipal>
with WidgetsBindingObserver {
int _indice = 0;
StreamSubscription<String>? _errorSubscription;
StreamSubscription<EventoAlarmaAndroid>? _alarmaSubscription;
StreamSubscription<AlarmaMusical>? _alarmaVencidaSubscription;
@@ -206,6 +209,8 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal>
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final navegacion = context.watch<EstadoNavegacionRaiz>();
final indice = navegacion.indice;
return PluriWaveScaffold(
appBar: AppBar(
@@ -236,8 +241,8 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal>
),
),
child: KeyedSubtree(
key: ValueKey<int>(_indice),
child: _paginas[_indice],
key: ValueKey<int>(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<EstadoNavegacionRaiz>().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<EstadoNavegacionRaiz>().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<EstadoNavegacionRaiz>().irA(RaizPluriWave.alarmas);
return;
}
await _mostrarAlarmaSonando(alarma);
+24
View File
@@ -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();
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"@@locale": "en",
"appTitle": "PluriWave",
"navHome": "Home",
"navHome": "Listen",
"navSearch": "Search",
"navFavorites": "Favorites",
"navAlarms": "Alarms",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"@@locale": "es",
"appTitle": "PluriWave",
"navHome": "Inicio",
"navHome": "Escuchar",
"navSearch": "Buscar",
"navFavorites": "Favoritos",
"navAlarms": "Alarmas",
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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';
+17 -6
View File
@@ -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 <ThemeExtension<dynamic>>[tokens, PluriWaveMotion.dark],
textTheme: baseTextTheme,
extensions: <ThemeExtension<dynamic>>[
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>() ?? PluriWaveMotion.dark;
PluriWaveTypography get pluriType =>
Theme.of(this).extension<PluriWaveTypography>() ??
PluriWaveTypography.from(Theme.of(this).textTheme);
}
+31
View File
@@ -11,6 +11,9 @@ class PluriWaveTokens extends ThemeExtension<PluriWaveTokens> {
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<PluriWaveTokens> {
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<PluriWaveTokens> {
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<PluriWaveTokens> {
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<PluriWaveTokens> {
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<PluriWaveTokens> {
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,
+111
View File
@@ -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<PluriWaveTypography> {
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<PluriWaveTypography>? 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,
);
}
}
+85
View File
@@ -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 <Widget>[],
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<Widget> 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<T?> push<T>(BuildContext context, WidgetBuilder builder) {
return Navigator.of(
context,
).push<T>(MaterialPageRoute<T>(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,
);
}
}