6 Commits
Author SHA1 Message Date
FreeTLab 344af83e16 fix(ui,auto,alarmas): safe area, Android Auto order, vacation delete
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m23s
Three unrelated reports fixed together.

- PluriRootHeader ignored the top system inset, so every root screen's
  own title row sat under the status bar / notch. Now pads for
  MediaQuery top inset without touching app.dart's deliberate edge-to-edge
  SafeArea(top:false) background bleed.
- Android Auto pushed the global-sort-derived favorites list instead of
  the phone's own manual order, and the tree builder then force-sorted
  everything by a hardcoded criterion regardless of what arrived --
  incoming order is now preserved, and Todas/Mis emisoras follow the
  same ordenListas setting the phone itself uses.
- The vacation range edit sheet could save but not delete; it now offers
  both, reusing the existing confirm dialog and delete path.

Tests: 1051 -> 1064.
2026-08-01 12:52:15 +02:00
FreeTLab 257f1bbc68 merge: incorporate the CI version bump 2026-08-01 12:49:51 +02:00
FreeTLab f4f9e87970 docs(alarmas): fix helper name typo in vacation delete comment 2026-08-01 12:08:14 +02:00
FreeTLab 597701f497 fix(alarmas): add a delete action to the vacation range edit sheet
The vacation edit sheet could save changes to an existing range but had
no way to remove it, forcing users back to the swipe-to-delete gesture
on the list. When editing (not creating) a range, the sheet now shows
an outlined delete action next to Save; it reuses the existing
confirmation dialog and EstadoAlarmas.eliminarRangoVacaciones exactly
as the swipe gesture already does, then pops on success.
2026-08-01 12:06:00 +02:00
FreeTLab 4d54908be6 fix(ui): add top-inset awareness to PluriRootHeader
PluriRootHeader rendered its 56px title/actions row flush at y=0 on
every device, since app.dart's root SafeArea(top: false) deliberately
excludes the top inset (so each root's full-bleed background paints
edge-to-edge behind the status bar) but the header itself never added
MediaQuery.paddingOf(context).top anywhere. The header now wraps its
existing 56px content row in an outer top padding equal to that inset,
so total rendered height is height + topInset while `height` keeps
meaning the content row's own height (verified no call site did
total-height math against the old fixed constant).
2026-08-01 12:00:48 +02:00
FreeTLab cfd8bc9e6a fix(auto): preserve phone-chosen station order in Android Auto folders
Android Auto's Favoritos/Todas/Mis emisoras folders always re-sorted by
a hardcoded quality criterion in ConstructorArbolAuto.hijos/hijosGrupo,
discarding whatever order the caller passed in. EstadoRadio now pushes
already-ordered snapshots (listaFavoritosManual for Favoritos, and the
ordenListas-sorted populares/emisorasCustom getters for Todas/Mis
emisoras, re-pushed immediately on cambiarOrdenListas), and hijos/
hijosGrupo stop re-sorting so that order survives into the car.
2026-08-01 11:26:24 +02:00
8 changed files with 541 additions and 63 deletions
+34 -6
View File
@@ -394,7 +394,14 @@ class EstadoRadio extends ChangeNotifier {
_cargandoPopulares = false;
// Design "live snapshot the source prefers": Android Auto's `Todas`
// folder mirrors the same populares list the phone just loaded.
_fuenteAuto?.actualizarSnapshot(todas: _populares);
//
// Fix `android-auto-orden`: pushes the SORTED [populares] getter, not
// the raw [_populares] field — the same [_ordenListas] setting the
// phone's own discovery lists (e.g. Buscar's `tendencias`) already
// sort by must also govern this folder's order, not the API's raw
// arrival order. `navegacion_auto.dart`'s `hijos()` no longer
// re-sorts, so whatever order arrives here IS what the driver sees.
_fuenteAuto?.actualizarSnapshot(todas: populares);
notifyListeners();
}
}
@@ -402,7 +409,13 @@ class EstadoRadio extends ChangeNotifier {
Future<void> cargarFavoritos() async {
_listaFavoritos = await favoritos.obtenerTodos();
await _normalizarEmisoraPreferida();
_fuenteAuto?.actualizarSnapshot(favoritos: _listaFavoritos);
// Fix `android-auto-orden`: pushes the documented manual-order accessor
// explicitly. [listaFavoritosManual] is backed by the same list as
// [_listaFavoritos] today (obtenerTodos() already returns the persisted
// manual order), but naming the intent here — "the exact order the
// Favoritos screen shows and reorders" — keeps this call from silently
// drifting onto a re-sorted list in a future refactor.
_fuenteAuto?.actualizarSnapshot(favoritos: listaFavoritosManual);
notifyListeners();
}
@@ -520,6 +533,16 @@ class EstadoRadio extends ChangeNotifier {
await prefs.setString(_keyOrdenListas, orden.name);
// Search owns its own listeners (S4-R3) but sorts with this preference.
busqueda.notificarCambioOrden();
// Fix `android-auto-orden`: Todas/Mis emisoras' Android Auto order is
// derived from this same setting (see cargarPopulares/
// _cargarEmisorasCustom above) — without an immediate re-push, a live
// car session would keep showing the OLD order until the next full
// reload instead of updating right away, same as the phone does via
// this method's own memoized getters.
_fuenteAuto?.actualizarSnapshot(
todas: populares,
misEmisoras: emisorasCustom,
);
notifyListeners();
}
@@ -659,7 +682,9 @@ class EstadoRadio extends ChangeNotifier {
detalle: 'resolucion de ruta',
razon: e.toString(),
);
_fuenteAuto?.actualizarSnapshot(misEmisoras: _emisorasCustom);
// Fix `android-auto-orden`: pushes the SORTED [emisorasCustom] getter
// (see the doc on this method's other 3 identical call sites below).
_fuenteAuto?.actualizarSnapshot(misEmisoras: emisorasCustom);
notifyListeners();
return;
}
@@ -685,7 +710,8 @@ class EstadoRadio extends ChangeNotifier {
razon: e.toString(),
);
}
_fuenteAuto?.actualizarSnapshot(misEmisoras: _emisorasCustom);
// Fix `android-auto-orden`: sorted getter, not the raw field.
_fuenteAuto?.actualizarSnapshot(misEmisoras: emisorasCustom);
notifyListeners();
}
@@ -701,7 +727,8 @@ class EstadoRadio extends ChangeNotifier {
if (!await archivo.exists()) {
_emisorasCustom = [];
_customDegradado = false;
_fuenteAuto?.actualizarSnapshot(misEmisoras: _emisorasCustom);
// Fix `android-auto-orden`: sorted getter, not the raw field.
_fuenteAuto?.actualizarSnapshot(misEmisoras: emisorasCustom);
notifyListeners();
return null;
}
@@ -714,7 +741,8 @@ class EstadoRadio extends ChangeNotifier {
detalle: archivo.path,
razon: e.toString(),
);
_fuenteAuto?.actualizarSnapshot(misEmisoras: _emisorasCustom);
// Fix `android-auto-orden`: sorted getter, not the raw field.
_fuenteAuto?.actualizarSnapshot(misEmisoras: emisorasCustom);
notifyListeners();
return null;
}
+44 -1
View File
@@ -842,11 +842,39 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
],
),
const SizedBox(height: 16),
FilledButton.icon(
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: _guardar,
icon: const Icon(Icons.check_rounded),
label: Text(l10n.saveRangeAction),
),
),
// Fix `vacaciones-delete`: only when EDITING an existing
// range (never when creating one -- there is nothing to
// delete yet). Reuses the exact same confirmation dialog
// (`_confirmarEliminarRango`) and deletion method
// (`eliminarRangoVacaciones`) the swipe-to-delete gesture
// already uses on both `_HeroRangoActivo` and
// `_TarjetaRangoVacaciones` -- no new deletion path.
if (widget.rango != null) ...[
const SizedBox(width: 10),
OutlinedButton.icon(
key: const ValueKey('vacation-delete-button'),
style: OutlinedButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
side: BorderSide(
color: Theme.of(context).colorScheme.error,
),
),
onPressed: _eliminar,
icon: const Icon(Icons.delete_outline_rounded),
label: Text(l10n.deleteAction),
),
],
],
),
],
),
),
@@ -903,6 +931,21 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
}
if (mounted) Navigator.pop(context);
}
/// Fix `vacaciones-delete`: mirrors `_guardar`'s pop-on-success shape,
/// but confirms first (via the same `_confirmarEliminarRango` dialog the
/// swipe gesture uses) and calls `eliminarRangoVacaciones` instead of
/// saving. Only reachable when [widget.rango] is non-null (the delete
/// button itself is hidden otherwise).
Future<void> _eliminar() async {
final rango = widget.rango;
if (rango == null) return;
final l10n = AppLocalizations.of(context);
final confirmado = await _confirmarEliminarRango(context, l10n);
if (!confirmado || !mounted) return;
await context.read<EstadoAlarmas>().eliminarRangoVacaciones(rango.id);
if (mounted) Navigator.pop(context);
}
}
class _PickerButton extends StatelessWidget {
+21 -13
View File
@@ -6,7 +6,6 @@ import 'package:audio_service/audio_service.dart';
import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:path_provider/path_provider.dart';
import '../estado/orden_emisoras.dart';
import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.dart';
import '../modelos/pista_local.dart';
@@ -350,15 +349,23 @@ class ConstructorArbolAuto {
extras: _contentStyleLista,
);
/// Leaf items for [parentId], sorted via [ordenarEmisoras] and capped at
/// [_maxItemsPorCarpeta] (Design "which stations surface & ordering" —
/// avoids driver distraction and Auto list limits). Unknown [parentId]
/// (or an empty [emisoras]) returns an empty list instead of throwing.
/// Leaf items for [parentId], PRESERVING the incoming [emisoras] order and
/// capped at [_maxItemsPorCarpeta] (Design "which stations surface &
/// ordering" — avoids driver distraction and Auto list limits).
///
/// Fix `android-auto-orden`: this used to force
/// `ordenarEmisoras(emisoras, OrdenEmisoras.calidad)` unconditionally,
/// silently discarding whatever order the caller actually wanted —
/// Favoritos' manual drag-reorder order, or the global `ordenListas`
/// setting for Todas/Mis emisoras. Every caller (`EstadoRadio.
/// cargarFavoritos`/`cargarPopulares`/`_cargarEmisorasCustom`/
/// `cambiarOrdenListas`) now pushes an already-ordered snapshot, so this
/// only slices and maps — it must never re-sort. Unknown [parentId] (or
/// an empty [emisoras]) returns an empty list instead of throwing.
List<MediaItem> hijos(String parentId, {required List<Emisora> emisoras}) {
if (!_idsCarpetas.contains(parentId)) return const [];
if (emisoras.isEmpty) return const [];
final ordenadas = ordenarEmisoras(emisoras, OrdenEmisoras.calidad);
return ordenadas.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
return emisoras.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
}
/// Maps a single [Emisora] to a playable `MediaItem`: id `emisora:<uuid>`
@@ -824,10 +831,12 @@ class ConstructorArbolAuto {
}
/// Members of the favorite group identified by [grupoMediaId] (a
/// `grupo:<id>` id), sorted and capped like every other folder (Spec "Car
/// requests a group folder's stations"). An unknown/stale/malformed id
/// returns an empty list instead of throwing (Spec "Car requests an
/// unknown or stale group id").
/// `grupo:<id>` id), PRESERVING the incoming [favoritos] order (Favoritos'
/// manual order — see [hijos]' doc, fix `android-auto-orden`) and capped
/// like every other folder (Spec "Car requests a group folder's
/// stations"). An unknown/stale/malformed id returns an empty list
/// instead of throwing (Spec "Car requests an unknown or stale group
/// id").
List<MediaItem> hijosGrupo(
String grupoMediaId, {
required List<Emisora> favoritos,
@@ -837,8 +846,7 @@ class ConstructorArbolAuto {
if (id.isEmpty) return const [];
final miembros = favoritos.where((e) => e.grupoFavoritosId == id).toList();
if (miembros.isEmpty) return const [];
final ordenados = ordenarEmisoras(miembros, OrdenEmisoras.calidad);
return ordenados.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
return miembros.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
}
/// Equalizer preset-selection media-id prefix (decision
+19 -1
View File
@@ -29,15 +29,32 @@ class PluriRootHeader extends StatelessWidget {
/// needs nothing extra here).
final List<Widget> actions;
/// Fix `safearea-top-inset`: this is the CONTENT row's height only —
/// NOT this widget's total rendered height. `app.dart`'s root
/// `SafeArea(top: false, ...)` deliberately excludes the top inset (so
/// each root's own full-bleed background paints genuinely edge-to-edge
/// behind the status bar), which left this header's title/actions row
/// with zero top-inset awareness — flush at y=0 under the status bar /
/// camera cutout on every device. This widget now adds
/// `MediaQuery.paddingOf(context).top` ABOVE this content height itself
/// (see [build]), so the total rendered height is
/// `height + MediaQuery.paddingOf(context).top`. Callers doing
/// total-height math (none currently do — checked every `PluriRootHeader`
/// call site) must add that inset separately; this constant's MEANING
/// (content height) is unchanged.
static const double height = 56;
@override
Widget build(BuildContext context) {
final type = context.pluriType;
final l10n = AppLocalizations.of(context);
return SizedBox(
final topInset = MediaQuery.paddingOf(context).top;
return Padding(
padding: EdgeInsets.only(top: topInset),
child: SizedBox(
height: height,
child: Padding(
key: const ValueKey('pluri-root-header-content'),
// S5: the prototype's own header padding is title-tier on the
// left, row-tier on the right (t4 e.g. Alarmas
// `padding:0 12px 0 20px`).
@@ -66,6 +83,7 @@ class PluriRootHeader extends StatelessWidget {
],
),
),
),
);
}
}
+130
View File
@@ -607,6 +607,136 @@ void main() {
);
});
test(
'fix android-auto-orden: el snapshot de favoritos es EXACTAMENTE '
'listaFavoritosManual (el orden manual que la pantalla Favoritos '
'muestra y reordena), nunca la lista cruda ni un getter reordenado',
() async {
final favoritosServicio = FakeServicioFavoritos();
// Agregados en orden Z, A: si el push usara un getter reordenado
// (p. ej. alfabético), 'alfa' iría primero. El orden manual conserva
// el orden de inserción/persistencia: Z, A.
await favoritosServicio.agregar(
emisoraDemo(uuid: 'zulu', nombre: 'Zulu Fav'),
);
await favoritosServicio.agregar(
emisoraDemo(uuid: 'alfa', nombre: 'Alfa Fav'),
);
final fuenteAuto = _FuenteEmisorasAutoEspia();
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: favoritosServicio,
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
fuenteAuto: fuenteAuto,
iniciarAutomaticamente: false,
);
await estado.inicializar();
expect(fuenteAuto.ultimoFavoritos?.map((e) => e.uuid).toList(), [
'zulu',
'alfa',
]);
expect(fuenteAuto.ultimoFavoritos, equals(estado.listaFavoritosManual));
},
);
test('fix android-auto-orden: el snapshot de "Todas" honra el orden '
'global (ordenListas) igual que "Tendencias" en el teléfono, no el '
'orden crudo de llegada de la API', () async {
SharedPreferences.setMockInitialValues({
'orden_listas_emisoras_v1': 'nombre',
});
final fuenteAuto = _FuenteEmisorasAutoEspia();
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(
populares: [
emisoraDemo(uuid: 'zulu-pop', nombre: 'Zulu Pop'),
emisoraDemo(uuid: 'alfa-pop', nombre: 'Alfa Pop'),
],
),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
fuenteAuto: fuenteAuto,
iniciarAutomaticamente: false,
);
await estado.inicializar();
expect(fuenteAuto.ultimoTodas?.map((e) => e.uuid).toList(), [
'alfa-pop',
'zulu-pop',
]);
});
test('fix android-auto-orden: el snapshot de "Mis emisoras" honra el '
'orden global (ordenListas), no el orden crudo del archivo', () async {
SharedPreferences.setMockInitialValues({
'orden_listas_emisoras_v1': 'nombre',
});
final archivo = await _crearArchivoCustom([
emisoraDemo(uuid: 'zulu-custom', nombre: 'Zulu Custom'),
emisoraDemo(uuid: 'alfa-custom', nombre: 'Alfa Custom'),
]);
final fuenteAuto = _FuenteEmisorasAutoEspia();
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: () async => archivo,
fuenteAuto: fuenteAuto,
iniciarAutomaticamente: false,
);
await estado.inicializar();
expect(fuenteAuto.ultimoMisEmisoras?.map((e) => e.uuid).toList(), [
'alfa-custom',
'zulu-custom',
]);
});
test('fix android-auto-orden: cambiarOrdenListas re-empuja de inmediato '
'los snapshots de "Todas" y "Mis emisoras" con el nuevo orden, sin '
'esperar a la próxima recarga completa', () async {
final archivo = await _crearArchivoCustom([
emisoraDemo(uuid: 'zulu-custom', nombre: 'Zulu Custom'),
emisoraDemo(uuid: 'alfa-custom', nombre: 'Alfa Custom'),
]);
final fuenteAuto = _FuenteEmisorasAutoEspia();
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(
populares: [
emisoraDemo(uuid: 'zulu-pop', nombre: 'Zulu Pop'),
emisoraDemo(uuid: 'alfa-pop', nombre: 'Alfa Pop'),
],
),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: () async => archivo,
fuenteAuto: fuenteAuto,
iniciarAutomaticamente: false,
);
await estado.inicializar();
await estado.cambiarOrdenListas(OrdenEmisoras.nombre);
expect(fuenteAuto.ultimoTodas?.map((e) => e.uuid).toList(), [
'alfa-pop',
'zulu-pop',
]);
expect(fuenteAuto.ultimoMisEmisoras?.map((e) => e.uuid).toList(), [
'alfa-custom',
'zulu-custom',
]);
});
test('reconcilia _emisoraSeleccionada cuando la selección viene desde '
'el auto (no via reproducir())', () async {
final audio = _AudioControlado();
@@ -614,4 +614,144 @@ void main() {
},
);
});
group('fix vacaciones-delete: el editor ofrece una accion de eliminar solo '
'al editar un rango existente, reusando _confirmarEliminarRango y '
'eliminarRangoVacaciones exactamente como el swipe', () {
testWidgets(
'creando un rango NUEVO (CTA "Anadir rango"), el editor NO muestra '
'una accion de eliminar -- no hay nada que borrar todavia',
(tester) async {
final estado = await _crearEstado();
addTearDown(estado.dispose);
await tester.pumpWidget(_buildScreen(estado));
await _pumpEstable(tester);
final l10n = AppLocalizations.of(
tester.element(find.byType(PantallaVacaciones)),
);
await tester.tap(find.text(l10n.addVacationRangeCta));
await _pumpEstable(tester);
expect(find.text(l10n.newVacationRangeTitle), findsOneWidget);
expect(
find.byKey(const ValueKey('vacation-delete-button')),
findsNothing,
);
},
);
testWidgets('editando un rango EXISTENTE (tap en su tarjeta), el editor SI '
'muestra una accion de eliminar junto al boton de guardar', (
tester,
) async {
final estado = await _crearEstado(
vacaciones: [
RangoVacaciones(
id: 'f2',
nombre: 'Verano',
inicio: _hoyDia.add(const Duration(days: 20)),
fin: _hoyDia.add(const Duration(days: 25)),
),
],
);
addTearDown(estado.dispose);
await tester.pumpWidget(_buildScreen(estado));
await _pumpEstable(tester);
await tester.tap(find.byKey(const ValueKey('vacaciones-tarjeta-f2')));
await _pumpEstable(tester);
final l10n = AppLocalizations.of(
tester.element(find.byType(PantallaVacaciones)),
);
expect(find.text(l10n.editVacationRangeTitle), findsOneWidget);
expect(
find.byKey(const ValueKey('vacation-delete-button')),
findsOneWidget,
);
});
testWidgets(
'tocar eliminar en el editor pide confirmacion (misma que el swipe); '
'cancelar conserva el rango y el editor sigue abierto',
(tester) async {
final estado = await _crearEstado(
vacaciones: [
RangoVacaciones(
id: 'f2',
nombre: 'Verano',
inicio: _hoyDia.add(const Duration(days: 20)),
fin: _hoyDia.add(const Duration(days: 25)),
),
],
);
addTearDown(estado.dispose);
await tester.pumpWidget(_buildScreen(estado));
await _pumpEstable(tester);
final l10n = AppLocalizations.of(
tester.element(find.byType(PantallaVacaciones)),
);
await tester.tap(find.byKey(const ValueKey('vacaciones-tarjeta-f2')));
await _pumpEstable(tester);
await tester.tap(find.byKey(const ValueKey('vacation-delete-button')));
await _pumpEstable(tester);
expect(find.text(l10n.vacationDeleteConfirmTitle), findsOneWidget);
await tester.tap(find.text(l10n.cancelAction));
await _pumpEstable(tester);
expect(estado.vacaciones, hasLength(1));
expect(find.text(l10n.editVacationRangeTitle), findsOneWidget);
},
);
testWidgets('tocar eliminar en el editor y confirmar llama a '
'eliminarRangoVacaciones y cierra el editor (mismo efecto que el '
'swipe, sin pasar por _guardar)', (tester) async {
final estado = await _crearEstado(
vacaciones: [
RangoVacaciones(
id: 'f2',
nombre: 'Verano',
inicio: _hoyDia.add(const Duration(days: 20)),
fin: _hoyDia.add(const Duration(days: 25)),
),
],
);
addTearDown(estado.dispose);
await tester.pumpWidget(_buildScreen(estado));
await _pumpEstable(tester);
final l10n = AppLocalizations.of(
tester.element(find.byType(PantallaVacaciones)),
);
await tester.tap(find.byKey(const ValueKey('vacaciones-tarjeta-f2')));
await _pumpEstable(tester);
await tester.tap(find.byKey(const ValueKey('vacation-delete-button')));
await _pumpEstable(tester);
expect(find.text(l10n.vacationDeleteConfirmTitle), findsOneWidget);
// `find.widgetWithText(FilledButton, ...)`, not a bare
// `find.text(...)`: the sheet's own OutlinedButton delete action
// (same "Eliminar" label) is still in the tree behind the dialog,
// so a bare text finder would ambiguously match both.
await tester.tap(find.widgetWithText(FilledButton, l10n.deleteAction));
await _pumpEstable(tester);
expect(estado.vacaciones, isEmpty);
expect(
find.text(l10n.editVacationRangeTitle),
findsNothing,
reason: 'the sheet must pop, exactly like a successful _guardar',
);
});
});
}
+16 -10
View File
@@ -2741,7 +2741,15 @@ void main() {
});
group('ConstructorArbolAuto.hijos', () {
test('limita a 50 items y respeta el orden de ordenarEmisoras', () {
test('limita a 50 items y PRESERVA el orden recibido — item 2 '
'(android-auto-orden fix): ya no reordena por calidad. Cada llamador '
'(Favoritos/Todas/Mis emisoras) empuja el snapshot ya en el orden que '
'el teléfono usa (orden manual o el ajuste global ordenListas), así '
'que reordenar aquí volvería a descartarlo', () {
// bitrate ascendente a propósito: si hijos() todavía reordenara por
// calidad (bitrate descendente), el primero sería uuid-59, no
// uuid-0 — esta es la diferencia que distingue "preserva el orden"
// de "vuelve a ordenar".
final emisoras = List.generate(
60,
(i) => _emisora(uuid: 'uuid-$i', nombre: 'Radio $i', bitrate: i),
@@ -2753,11 +2761,8 @@ void main() {
);
expect(hijos, hasLength(50));
// ordenarEmisoras por calidad ordena por bitrate descendente: el
// primer item debe ser el de mayor bitrate (59) y el ultimo (el #50)
// el de bitrate 10 (59..10 son 50 valores).
expect(hijos.first.id, 'emisora:uuid-59');
expect(hijos.last.id, 'emisora:uuid-10');
expect(hijos.first.id, 'emisora:uuid-0');
expect(hijos.last.id, 'emisora:uuid-49');
});
test('lista vacía cuando no hay emisoras, sin lanzar', () {
@@ -2935,8 +2940,9 @@ void main() {
});
group('ConstructorArbolAuto.hijosGrupo', () {
test('filtra por grupoFavoritosId, ordena y capea a 50; id desconocido '
'devuelve lista vacía', () {
test('filtra por grupoFavoritosId, PRESERVA el orden recibido (ya no '
'reordena por calidad — item 2 android-auto-orden fix) y capea a 50; '
'id desconocido devuelve lista vacía', () {
final favoritos = List.generate(
60,
(i) => _emisora(
@@ -2953,8 +2959,8 @@ void main() {
);
expect(hijos, hasLength(50));
expect(hijos.first.id, 'emisora:uuid-59');
expect(hijos.last.id, 'emisora:uuid-10');
expect(hijos.first.id, 'emisora:uuid-0');
expect(hijos.last.id, 'emisora:uuid-49');
final vacio = ConstructorArbolAuto().hijosGrupo(
'grupo:g-inexistente',
+107 -2
View File
@@ -12,12 +12,23 @@ import 'package:pluriwave/widgets/pluri_root_header.dart';
/// sleep-timer action that used to live on `app.dart`'s single global
/// `AppBar` stays reachable from every tab.
void main() {
Widget host(Widget child) {
// [topInset] simulates `MediaQuery.paddingOf(context).top` (status bar /
// camera cutout) — the same technique already used by
// `pantalla_alarma_sonando_scaffold_test.dart`'s `MediaQuery` override via
// `MaterialApp.builder`.
Widget host(Widget child, {double topInset = 0}) {
return MaterialApp(
theme: PluriWaveTheme.dark(),
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
builder:
(context, app) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(padding: EdgeInsets.only(top: topInset)),
child: app!,
),
home: Scaffold(body: child),
);
}
@@ -42,7 +53,13 @@ void main() {
host(PluriRootHeader(title: 'Settings', onSleepTimer: () {})),
);
final padding = tester.widget<Padding>(find.byType(Padding).first);
// Keyed lookup (not `find.byType(Padding).first`): the top-inset fix
// wraps this content padding in an outer `Padding(top: topInset)`, so
// `.first` would no longer reliably resolve to the content row's own
// padding.
final padding = tester.widget<Padding>(
find.byKey(const ValueKey('pluri-root-header-content')),
);
final insets = padding.padding as EdgeInsets;
expect(insets.left, PluriLayout.titleHorizontal);
expect(insets.right, PluriLayout.rowHorizontal);
@@ -97,4 +114,92 @@ void main() {
reason: 'actions render before (to the left of) the bedtime button',
);
});
group('fix top-inset: total height and content position track '
'MediaQuery.paddingOf(context).top, instead of always sitting flush '
'at y=0 under the status bar', () {
// Vertical text centering inside the 56px content row means the
// title's own top-left never sits exactly AT the header's top-left
// (even at topInset=0) — so these tests compare each inset's title
// position against the topInset=0 BASELINE, isolating exactly the
// inset's own contribution instead of asserting a brittle absolute
// offset.
Future<double> tituloTopPara(WidgetTester tester, double topInset) async {
await tester.pumpWidget(
host(
PluriRootHeader(title: 'Settings', onSleepTimer: () {}),
topInset: topInset,
),
);
return tester.getTopLeft(find.text('Settings')).dy;
}
testWidgets(
'topInset=0 (e.g. desktop/no cutout): total height stays the plain '
'56px content height',
(tester) async {
await tester.pumpWidget(
host(PluriRootHeader(title: 'Settings', onSleepTimer: () {})),
);
expect(tester.getSize(find.byType(PluriRootHeader)).height, 56);
},
);
testWidgets(
'topInset=24 (typical status bar): total height becomes 56+24=80, '
'and the title shifts down by exactly the inset relative to the '
'topInset=0 baseline',
(tester) async {
final base = await tituloTopPara(tester, 0);
final conInset = await tituloTopPara(tester, 24);
expect(tester.getSize(find.byType(PluriRootHeader)).height, 80);
expect(conInset - base, 24);
},
);
testWidgets(
'topInset=44 (taller status bar): total height becomes 56+44=100, '
'and the title still shifts down by exactly the inset — never '
'clipped',
(tester) async {
final base = await tituloTopPara(tester, 0);
final conInset = await tituloTopPara(tester, 44);
expect(tester.getSize(find.byType(PluriRootHeader)).height, 100);
expect(conInset - base, 44);
},
);
testWidgets(
'topInset=60 (notch/camera-cutout simulation): total height becomes '
'56+60=116, and the title still fully clears the inset — never '
'overlapping it',
(tester) async {
final base = await tituloTopPara(tester, 0);
final conInset = await tituloTopPara(tester, 60);
expect(tester.getSize(find.byType(PluriRootHeader)).height, 116);
expect(conInset - base, 60);
},
);
testWidgets(
'PluriRootHeader.height stays the CONTENT height (56) regardless of '
'inset — callers doing total-height math must separately add '
'MediaQuery.paddingOf(context).top',
(tester) async {
await tester.pumpWidget(
host(
PluriRootHeader(title: 'Settings', onSleepTimer: () {}),
topInset: 44,
),
);
expect(PluriRootHeader.height, 56);
expect(tester.getSize(find.byType(PluriRootHeader)).height, 100);
},
);
});
}