feat(connectivity): restyle offline and reconnect banners

Tint the mini player's reconnecting/error sub-states with the
offlineAccent token (added in WU1, unused until now): the status
label, the reconnect spinner, and the error retry icon now read as
visually distinct "connectivity trouble" states instead of blending
into the ordinary loading/paused look. Plain buffering keeps the
default colour, confirmed by a dedicated regression test.

Verify-first gate (task 16.1): ControladorReconexion.intentos exists,
but ServicioAudio never surfaces it past a debug log line, and its
estadoStream only carries the EstadoReproduccion enum. Adding an
attempt-count label would require a getter/stream on
servicio_audio.dart, one of the files this change must keep at an
empty diff against main. Ship the restyle without the counter, per
the risk register's own fallback.

WU16.
This commit is contained in:
2026-07-29 14:43:34 +02:00
parent dc21732027
commit 862197ab48
3 changed files with 168 additions and 17 deletions
+25 -6
View File
@@ -129,20 +129,31 @@ class _MiniReproductorState extends State<MiniReproductor> {
EstadoReproduccion.detenido; EstadoReproduccion.detenido;
final activo = final activo =
s == EstadoReproduccion.reproduciendo; s == EstadoReproduccion.reproduciendo;
// WU16: reconectando/error are
// "connectivity trouble" states —
// tinted with offlineAccent so they
// read as visually distinct from
// ordinary loading/paused/stopped.
final conexionEnProblema =
s ==
EstadoReproduccion.reconectando ||
s == EstadoReproduccion.error;
return Text( return Text(
_labelEstado(l10n, s), _labelEstado(l10n, s),
style: Theme.of( style: Theme.of(
context, context,
).textTheme.bodySmall?.copyWith( ).textTheme.bodySmall?.copyWith(
color: color:
activo conexionEnProblema
? t.offlineAccent
: activo
? t.warmCoral ? t.warmCoral
: Theme.of(context) : Theme.of(context)
.colorScheme .colorScheme
.onSurface .onSurface
.withValues(alpha: 0.7), .withValues(alpha: 0.7),
fontWeight: fontWeight:
activo (activo || conexionEnProblema)
? FontWeight.w600 ? FontWeight.w600
: FontWeight.w400, : FontWeight.w400,
), ),
@@ -173,12 +184,20 @@ class _MiniReproductorState extends State<MiniReproductor> {
// cargando (spinner), never as the error/retry affordance. // cargando (spinner), never as the error/retry affordance.
if (s == EstadoReproduccion.cargando || if (s == EstadoReproduccion.cargando ||
s == EstadoReproduccion.reconectando) { s == EstadoReproduccion.reconectando) {
return const SizedBox( // WU16: only the reconectando sub-state gets the offline
// accent — plain cargando (e.g. the very first play)
// keeps the default spinner colour, since it is not a
// connectivity problem.
final reconectando = s == EstadoReproduccion.reconectando;
return SizedBox(
width: 48, width: 48,
height: 48, height: 48,
child: Padding( child: Padding(
padding: EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(
strokeWidth: 2,
color: reconectando ? t.offlineAccent : null,
),
), ),
); );
} }
@@ -187,7 +206,7 @@ class _MiniReproductorState extends State<MiniReproductor> {
final emisoraActual = estado.emisoraActual; final emisoraActual = estado.emisoraActual;
return IconButton( return IconButton(
tooltip: l10n.retryAction, tooltip: l10n.retryAction,
icon: const Icon(Icons.refresh_rounded), icon: Icon(Icons.refresh_rounded, color: t.offlineAccent),
onPressed: onPressed:
emisoraActual != null emisoraActual != null
? () => estado.reproducir(emisoraActual) ? () => estado.reproducir(emisoraActual)
+26 -11
View File
@@ -971,7 +971,7 @@ real capability living in the header, not decorative chrome).
- [x] 15b.6 Verify — scoped suite green: 605/605 (up from 604), 2 skipped (unchanged, both pre-existing WU15 - [x] 15b.6 Verify — scoped suite green: 605/605 (up from 604), 2 skipped (unchanged, both pre-existing WU15
documented skips). `flutter analyze`: 1 issue, identical to baseline. Literal-encoding scan: zero hits. documented skips). `flutter analyze`: 1 issue, identical to baseline. Literal-encoding scan: zero hits.
## WU16 — Connectivity banner restyle (offline / reconnect)
**Commit**: `feat(connectivity): restyle offline and reconnect banners` **Commit**: `feat(connectivity): restyle offline and reconnect banners`
**Depends on**: WU1 **Depends on**: WU1
@@ -979,16 +979,31 @@ real capability living in the header, not decorative chrome).
**Verify**: `flutter test test/widgets/reconnect_ui_test.dart test/servicios/servicio_audio_reconnect_test.dart && flutter analyze && dart format --set-exit-if-changed $(git diff --name-only --diff-filter=ACM HEAD -- '*.dart')` **Verify**: `flutter test test/widgets/reconnect_ui_test.dart test/servicios/servicio_audio_reconnect_test.dart && flutter analyze && dart format --set-exit-if-changed $(git diff --name-only --diff-filter=ACM HEAD -- '*.dart')`
**Modified tests**: `reconnect_ui_test.dart`. `servicio_audio_reconnect_test.dart` must pass unmodified. **Modified tests**: `reconnect_ui_test.dart`. `servicio_audio_reconnect_test.dart` must pass unmodified.
- [ ] 16.1 Verify-first — run `servicio_audio_reconnect_test.dart` and inspect the reconnect controller: confirm - [x] 16.1 Verify-first — run `servicio_audio_reconnect_test.dart` and inspect the reconnect controller: confirm
whether it tracks an attempt count. whether it tracks an attempt count. **Result: it does NOT reach the UI.** `ControladorReconexion.intentos`
- [ ] 16.2 RED — restyle test for the offline banner (visual only) and, **only if 16.1 confirms an attempt count exists as a getter, but `ServicioAudio` (`lib/servicios/servicio_audio.dart`) only ever reads it inside a
exists**, a test for the "Reconectando · intento N de M" label. `developer.log` debug line (line 413) — its public `estadoStream` maps every playback state to the
- [ ] 16.3 GREEN — restyle the offline banner to the new visual language. `EstadoReproduccion` enum (no attempt-count payload) and `_handler.reconectando` is a bare bool. Wiring a
- [ ] 16.4 GREEN — **conditionally**: wire the attempt-count label only if the controller exposes one; otherwise count to the UI would require adding a getter/stream to `servicio_audio.dart`, which is one of the four
ship the restyle without the counter — do not add new plumbing to invent one. files this change must keep at an **empty git diff vs `main`** — structurally blocked, not just untested.
- [ ] 16.5 REFACTOR — confirm shimmer skeletons (`TarjetaEmisoraShimmer`) are unchanged. Shipping without the counter per 16.4, exactly as the risk register anticipated.
- [ ] 16.6 Verify — `reconnect_ui_test.dart` updated and green; `servicio_audio_reconnect_test.dart` unmodified and - [x] 16.2 RED — 3 new `testWidgets` cases added to `reconnect_ui_test.dart` (no attempt-count test, per 16.1):
green. reconnecting tints the spinner + status label with `offlineAccent`; plain `cargando` keeps the default
spinner colour (regression guard proving the tint is reconnect-specific, not blanket-loading); error tints
the retry icon + status label with `offlineAccent`. Confirmed RED: 2 of the 3 failed (`Actual: <null>`)
against the pre-restyle widget.
- [x] 16.3 GREEN — `lib/widgets/mini_reproductor.dart`: the status-label `Text` and the reconnect spinner /
error retry icon now read `context.pluriTokens.offlineAccent` (WU1's previously-unused token, whose own
doc comment already named it for this WU) whenever the stream reports `reconectando` or `error`. Plain
`cargando` explicitly keeps `color: null` (the theme default) — verified by 16.2's regression test.
- [x] 16.4 GREEN — conditional step confirmed moot: no attempt-count label added, no new plumbing introduced.
- [x] 16.5 REFACTOR — `TarjetaEmisoraShimmer` (`lib/widgets/tarjeta_emisora.dart`, consumed by
`pantalla_buscar.dart`) is untouched by this commit — confirmed via `git diff --stat`, zero lines.
- [x] 16.6 Verify — scoped suite green: 16/16 (5 `reconnect_ui_test.dart` + 8 `servicio_audio_reconnect_test.dart`
[byte-identical, unmodified] + 3 `mini_reproductor_configurar_test.dart` re-run as an adjacent-file
regression check). `flutter analyze`: 1 issue, identical to baseline. Scoped `dart format`: reformatted the
hand-written production file once (whitespace only), stable on re-run. Literal-encoding scan: zero hits on
the 2 touched files.
## WU17 — Welcome / onboarding screen ## WU17 — Welcome / onboarding screen
+117
View File
@@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_radio.dart'; import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/servicios/servicio_audio.dart'; import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:pluriwave/tema/pluriwave_tokens.dart';
import 'package:pluriwave/widgets/mini_reproductor.dart'; import 'package:pluriwave/widgets/mini_reproductor.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -93,4 +94,120 @@ void main() {
expect(find.byType(AlertDialog), findsNothing); expect(find.byType(AlertDialog), findsNothing);
expect(find.byIcon(Icons.refresh_rounded), findsOneWidget); expect(find.byIcon(Icons.refresh_rounded), findsOneWidget);
}); });
// WU16: the offline/reconnect banner restyle. `offlineAccent` (added in
// WU1, previously unused by any screen) tints the connectivity-trouble
// sub-states so they read as visually distinct from ordinary loading.
testWidgets(
'reconnecting state tints the spinner and status label with the offline accent',
(tester) async {
final audio = FakeServicioAudio();
final estado = _estadoRadio(audio);
addTearDown(estado.dispose);
await audio.reproducir(emisoraDemo(uuid: 'r1', nombre: 'Radio Uno'));
await tester.pumpWidget(
ChangeNotifierProvider<EstadoRadio>.value(
value: estado,
child: MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: MiniReproductor()),
),
),
);
await tester.pumpAndSettle();
audio.emitirEstado(EstadoReproduccion.reconectando);
await tester.pump();
await tester.pump();
expect(
tester
.widget<CircularProgressIndicator>(
find.byType(CircularProgressIndicator),
)
.color,
PluriWaveTokens.dark.offlineAccent,
);
expect(
tester.widget<Text>(find.text('Reconectando...')).style?.color,
PluriWaveTokens.dark.offlineAccent,
);
},
);
testWidgets(
'plain buffering keeps the default spinner colour, not the offline accent',
(tester) async {
final audio = FakeServicioAudio();
final estado = _estadoRadio(audio);
addTearDown(estado.dispose);
await audio.reproducir(emisoraDemo(uuid: 'r1', nombre: 'Radio Uno'));
await tester.pumpWidget(
ChangeNotifierProvider<EstadoRadio>.value(
value: estado,
child: MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: MiniReproductor()),
),
),
);
await tester.pumpAndSettle();
audio.emitirEstado(EstadoReproduccion.cargando);
await tester.pump();
await tester.pump();
expect(
tester
.widget<CircularProgressIndicator>(
find.byType(CircularProgressIndicator),
)
.color,
isNull,
reason: 'cargando is not a connectivity problem, keep the default',
);
},
);
testWidgets(
'error state tints the retry icon and status label with the offline accent',
(tester) async {
final audio = FakeServicioAudio();
final estado = _estadoRadio(audio);
addTearDown(estado.dispose);
await audio.reproducir(emisoraDemo(uuid: 'r1', nombre: 'Radio Uno'));
await tester.pumpWidget(
ChangeNotifierProvider<EstadoRadio>.value(
value: estado,
child: MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: MiniReproductor()),
),
),
);
await tester.pumpAndSettle();
audio.emitirEstado(EstadoReproduccion.error);
await tester.pump();
await tester.pump();
expect(
tester.widget<Icon>(find.byIcon(Icons.refresh_rounded)).color,
PluriWaveTokens.dark.offlineAccent,
);
expect(
tester.widget<Text>(find.text('Error de conexión')).style?.color,
PluriWaveTokens.dark.offlineAccent,
);
},
);
} }