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:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user