From 5a5f1655a9db64ffbe8d005eab5bec76c6ac9268 Mon Sep 17 00:00:00 2001 From: freetlab Date: Wed, 29 Jul 2026 20:23:55 +0200 Subject: [PATCH] fix(surfaces): make list/card surfaces opaque by default, fix card radius MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- lib/pantallas/pantalla_inicio.dart | 3 + lib/tema/pluriwave_tokens.dart | 10 ++- lib/widgets/mini_reproductor.dart | 3 + lib/widgets/pluri_glass_surface.dart | 58 ++++++++---- lib/widgets/tarjeta_emisora.dart | 5 +- test/tema/pluriwave_tokens_test.dart | 5 +- test/widgets/pluri_glass_surface_test.dart | 61 +++++++++++++ test/widgets/pluriwave_foundations_test.dart | 5 +- .../widgets/tarjeta_emisora_surface_test.dart | 90 +++++++++++++++++++ 9 files changed, 218 insertions(+), 22 deletions(-) create mode 100644 test/widgets/pluri_glass_surface_test.dart create mode 100644 test/widgets/tarjeta_emisora_surface_test.dart diff --git a/lib/pantallas/pantalla_inicio.dart b/lib/pantallas/pantalla_inicio.dart index cca7819..679a315 100644 --- a/lib/pantallas/pantalla_inicio.dart +++ b/lib/pantallas/pantalla_inicio.dart @@ -226,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, diff --git a/lib/tema/pluriwave_tokens.dart b/lib/tema/pluriwave_tokens.dart index 963193d..7cae4cd 100644 --- a/lib/tema/pluriwave_tokens.dart +++ b/lib/tema/pluriwave_tokens.dart @@ -54,6 +54,11 @@ class PluriWaveTokens extends ThemeExtension { 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; @@ -84,7 +89,7 @@ class PluriWaveTokens extends ThemeExtension { offlineAccent: Color(0xFFE8879A), balloonSurface: Color(0xFF0A1B24), radiusSm: 14, - radiusMd: 22, + radiusMd: 18, radiusLg: 30, spacingXs: 4, spacingSm: 8, @@ -154,8 +159,7 @@ class PluriWaveTokens extends ThemeExtension { offlineAccent: Color.lerp(offlineAccent, other.offlineAccent, t) ?? offlineAccent, balloonSurface: - Color.lerp(balloonSurface, other.balloonSurface, t) ?? - 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, diff --git a/lib/widgets/mini_reproductor.dart b/lib/widgets/mini_reproductor.dart index b9feac6..eeaac5d 100644 --- a/lib/widgets/mini_reproductor.dart +++ b/lib/widgets/mini_reproductor.dart @@ -72,6 +72,9 @@ class _MiniReproductorState extends State { 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, diff --git a/lib/widgets/pluri_glass_surface.dart b/lib/widgets/pluri_glass_surface.dart index 6465a2e..6151d7d 100644 --- a/lib/widgets/pluri_glass_surface.dart +++ b/lib/widgets/pluri_glass_surface.dart @@ -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, ), ), ); diff --git a/lib/widgets/tarjeta_emisora.dart b/lib/widgets/tarjeta_emisora.dart index 1e3e04f..9e26236 100644 --- a/lib/widgets/tarjeta_emisora.dart +++ b/lib/widgets/tarjeta_emisora.dart @@ -65,8 +65,11 @@ class _TarjetaEmisoraState extends State { 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, diff --git a/test/tema/pluriwave_tokens_test.dart b/test/tema/pluriwave_tokens_test.dart index b7ed267..82b2156 100644 --- a/test/tema/pluriwave_tokens_test.dart +++ b/test/tema/pluriwave_tokens_test.dart @@ -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); }); diff --git a/test/widgets/pluri_glass_surface_test.dart b/test/widgets/pluri_glass_surface_test.dart new file mode 100644 index 0000000..e1220f1 --- /dev/null +++ b/test/widgets/pluri_glass_surface_test.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(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(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); + }); +} diff --git a/test/widgets/pluriwave_foundations_test.dart b/test/widgets/pluriwave_foundations_test.dart index 4b05fbf..08a545f 100644 --- a/test/widgets/pluriwave_foundations_test.dart +++ b/test/widgets/pluriwave_foundations_test.dart @@ -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); }); diff --git a/test/widgets/tarjeta_emisora_surface_test.dart b/test/widgets/tarjeta_emisora_surface_test.dart new file mode 100644 index 0000000..fc595d8 --- /dev/null +++ b/test/widgets/tarjeta_emisora_surface_test.dart @@ -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.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( + 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); + }); +}