fix(ui,auto,alarmas): safe area, Android Auto order, vacation delete
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.
This commit is contained in:
@@ -394,7 +394,14 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
_cargandoPopulares = false;
|
_cargandoPopulares = false;
|
||||||
// Design "live snapshot the source prefers": Android Auto's `Todas`
|
// Design "live snapshot the source prefers": Android Auto's `Todas`
|
||||||
// folder mirrors the same populares list the phone just loaded.
|
// 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();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -402,7 +409,13 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
Future<void> cargarFavoritos() async {
|
Future<void> cargarFavoritos() async {
|
||||||
_listaFavoritos = await favoritos.obtenerTodos();
|
_listaFavoritos = await favoritos.obtenerTodos();
|
||||||
await _normalizarEmisoraPreferida();
|
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();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,6 +533,16 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
await prefs.setString(_keyOrdenListas, orden.name);
|
await prefs.setString(_keyOrdenListas, orden.name);
|
||||||
// Search owns its own listeners (S4-R3) but sorts with this preference.
|
// Search owns its own listeners (S4-R3) but sorts with this preference.
|
||||||
busqueda.notificarCambioOrden();
|
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();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -659,7 +682,9 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
detalle: 'resolucion de ruta',
|
detalle: 'resolucion de ruta',
|
||||||
razon: e.toString(),
|
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();
|
notifyListeners();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -685,7 +710,8 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
razon: e.toString(),
|
razon: e.toString(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
_fuenteAuto?.actualizarSnapshot(misEmisoras: _emisorasCustom);
|
// Fix `android-auto-orden`: sorted getter, not the raw field.
|
||||||
|
_fuenteAuto?.actualizarSnapshot(misEmisoras: emisorasCustom);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -701,7 +727,8 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
if (!await archivo.exists()) {
|
if (!await archivo.exists()) {
|
||||||
_emisorasCustom = [];
|
_emisorasCustom = [];
|
||||||
_customDegradado = false;
|
_customDegradado = false;
|
||||||
_fuenteAuto?.actualizarSnapshot(misEmisoras: _emisorasCustom);
|
// Fix `android-auto-orden`: sorted getter, not the raw field.
|
||||||
|
_fuenteAuto?.actualizarSnapshot(misEmisoras: emisorasCustom);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -714,7 +741,8 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
detalle: archivo.path,
|
detalle: archivo.path,
|
||||||
razon: e.toString(),
|
razon: e.toString(),
|
||||||
);
|
);
|
||||||
_fuenteAuto?.actualizarSnapshot(misEmisoras: _emisorasCustom);
|
// Fix `android-auto-orden`: sorted getter, not the raw field.
|
||||||
|
_fuenteAuto?.actualizarSnapshot(misEmisoras: emisorasCustom);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -842,11 +842,39 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
FilledButton.icon(
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton.icon(
|
||||||
onPressed: _guardar,
|
onPressed: _guardar,
|
||||||
icon: const Icon(Icons.check_rounded),
|
icon: const Icon(Icons.check_rounded),
|
||||||
label: Text(l10n.saveRangeAction),
|
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);
|
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 {
|
class _PickerButton extends StatelessWidget {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import 'package:audio_service/audio_service.dart';
|
|||||||
import 'package:flutter/foundation.dart' show visibleForTesting;
|
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
import '../estado/orden_emisoras.dart';
|
|
||||||
import '../modelos/emisora.dart';
|
import '../modelos/emisora.dart';
|
||||||
import '../modelos/grupo_favoritos.dart';
|
import '../modelos/grupo_favoritos.dart';
|
||||||
import '../modelos/pista_local.dart';
|
import '../modelos/pista_local.dart';
|
||||||
@@ -350,15 +349,23 @@ class ConstructorArbolAuto {
|
|||||||
extras: _contentStyleLista,
|
extras: _contentStyleLista,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Leaf items for [parentId], sorted via [ordenarEmisoras] and capped at
|
/// Leaf items for [parentId], PRESERVING the incoming [emisoras] order and
|
||||||
/// [_maxItemsPorCarpeta] (Design "which stations surface & ordering" —
|
/// capped at [_maxItemsPorCarpeta] (Design "which stations surface &
|
||||||
/// avoids driver distraction and Auto list limits). Unknown [parentId]
|
/// ordering" — avoids driver distraction and Auto list limits).
|
||||||
/// (or an empty [emisoras]) returns an empty list instead of throwing.
|
///
|
||||||
|
/// 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}) {
|
List<MediaItem> hijos(String parentId, {required List<Emisora> emisoras}) {
|
||||||
if (!_idsCarpetas.contains(parentId)) return const [];
|
if (!_idsCarpetas.contains(parentId)) return const [];
|
||||||
if (emisoras.isEmpty) return const [];
|
if (emisoras.isEmpty) return const [];
|
||||||
final ordenadas = ordenarEmisoras(emisoras, OrdenEmisoras.calidad);
|
return emisoras.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
|
||||||
return ordenadas.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maps a single [Emisora] to a playable `MediaItem`: id `emisora:<uuid>`
|
/// 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
|
/// Members of the favorite group identified by [grupoMediaId] (a
|
||||||
/// `grupo:<id>` id), sorted and capped like every other folder (Spec "Car
|
/// `grupo:<id>` id), PRESERVING the incoming [favoritos] order (Favoritos'
|
||||||
/// requests a group folder's stations"). An unknown/stale/malformed id
|
/// manual order — see [hijos]' doc, fix `android-auto-orden`) and capped
|
||||||
/// returns an empty list instead of throwing (Spec "Car requests an
|
/// like every other folder (Spec "Car requests a group folder's
|
||||||
/// unknown or stale group id").
|
/// 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(
|
List<MediaItem> hijosGrupo(
|
||||||
String grupoMediaId, {
|
String grupoMediaId, {
|
||||||
required List<Emisora> favoritos,
|
required List<Emisora> favoritos,
|
||||||
@@ -837,8 +846,7 @@ class ConstructorArbolAuto {
|
|||||||
if (id.isEmpty) return const [];
|
if (id.isEmpty) return const [];
|
||||||
final miembros = favoritos.where((e) => e.grupoFavoritosId == id).toList();
|
final miembros = favoritos.where((e) => e.grupoFavoritosId == id).toList();
|
||||||
if (miembros.isEmpty) return const [];
|
if (miembros.isEmpty) return const [];
|
||||||
final ordenados = ordenarEmisoras(miembros, OrdenEmisoras.calidad);
|
return miembros.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
|
||||||
return ordenados.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Equalizer preset-selection media-id prefix (decision
|
/// Equalizer preset-selection media-id prefix (decision
|
||||||
|
|||||||
@@ -29,15 +29,32 @@ class PluriRootHeader extends StatelessWidget {
|
|||||||
/// needs nothing extra here).
|
/// needs nothing extra here).
|
||||||
final List<Widget> actions;
|
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;
|
static const double height = 56;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final type = context.pluriType;
|
final type = context.pluriType;
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
return SizedBox(
|
final topInset = MediaQuery.paddingOf(context).top;
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(top: topInset),
|
||||||
|
child: SizedBox(
|
||||||
height: height,
|
height: height,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
key: const ValueKey('pluri-root-header-content'),
|
||||||
// S5: the prototype's own header padding is title-tier on the
|
// S5: the prototype's own header padding is title-tier on the
|
||||||
// left, row-tier on the right (t4 e.g. Alarmas
|
// left, row-tier on the right (t4 e.g. Alarmas
|
||||||
// `padding:0 12px 0 20px`).
|
// `padding:0 12px 0 20px`).
|
||||||
@@ -66,6 +83,7 @@ class PluriRootHeader extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 '
|
test('reconcilia _emisoraSeleccionada cuando la selección viene desde '
|
||||||
'el auto (no via reproducir())', () async {
|
'el auto (no via reproducir())', () async {
|
||||||
final audio = _AudioControlado();
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2741,7 +2741,15 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('ConstructorArbolAuto.hijos', () {
|
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(
|
final emisoras = List.generate(
|
||||||
60,
|
60,
|
||||||
(i) => _emisora(uuid: 'uuid-$i', nombre: 'Radio $i', bitrate: i),
|
(i) => _emisora(uuid: 'uuid-$i', nombre: 'Radio $i', bitrate: i),
|
||||||
@@ -2753,11 +2761,8 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(hijos, hasLength(50));
|
expect(hijos, hasLength(50));
|
||||||
// ordenarEmisoras por calidad ordena por bitrate descendente: el
|
expect(hijos.first.id, 'emisora:uuid-0');
|
||||||
// primer item debe ser el de mayor bitrate (59) y el ultimo (el #50)
|
expect(hijos.last.id, 'emisora:uuid-49');
|
||||||
// el de bitrate 10 (59..10 son 50 valores).
|
|
||||||
expect(hijos.first.id, 'emisora:uuid-59');
|
|
||||||
expect(hijos.last.id, 'emisora:uuid-10');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('lista vacía cuando no hay emisoras, sin lanzar', () {
|
test('lista vacía cuando no hay emisoras, sin lanzar', () {
|
||||||
@@ -2935,8 +2940,9 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('ConstructorArbolAuto.hijosGrupo', () {
|
group('ConstructorArbolAuto.hijosGrupo', () {
|
||||||
test('filtra por grupoFavoritosId, ordena y capea a 50; id desconocido '
|
test('filtra por grupoFavoritosId, PRESERVA el orden recibido (ya no '
|
||||||
'devuelve lista vacía', () {
|
'reordena por calidad — item 2 android-auto-orden fix) y capea a 50; '
|
||||||
|
'id desconocido devuelve lista vacía', () {
|
||||||
final favoritos = List.generate(
|
final favoritos = List.generate(
|
||||||
60,
|
60,
|
||||||
(i) => _emisora(
|
(i) => _emisora(
|
||||||
@@ -2953,8 +2959,8 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(hijos, hasLength(50));
|
expect(hijos, hasLength(50));
|
||||||
expect(hijos.first.id, 'emisora:uuid-59');
|
expect(hijos.first.id, 'emisora:uuid-0');
|
||||||
expect(hijos.last.id, 'emisora:uuid-10');
|
expect(hijos.last.id, 'emisora:uuid-49');
|
||||||
|
|
||||||
final vacio = ConstructorArbolAuto().hijosGrupo(
|
final vacio = ConstructorArbolAuto().hijosGrupo(
|
||||||
'grupo:g-inexistente',
|
'grupo:g-inexistente',
|
||||||
|
|||||||
@@ -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
|
/// sleep-timer action that used to live on `app.dart`'s single global
|
||||||
/// `AppBar` stays reachable from every tab.
|
/// `AppBar` stays reachable from every tab.
|
||||||
void main() {
|
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(
|
return MaterialApp(
|
||||||
theme: PluriWaveTheme.dark(),
|
theme: PluriWaveTheme.dark(),
|
||||||
locale: const Locale('en'),
|
locale: const Locale('en'),
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
builder:
|
||||||
|
(context, app) => MediaQuery(
|
||||||
|
data: MediaQuery.of(
|
||||||
|
context,
|
||||||
|
).copyWith(padding: EdgeInsets.only(top: topInset)),
|
||||||
|
child: app!,
|
||||||
|
),
|
||||||
home: Scaffold(body: child),
|
home: Scaffold(body: child),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -42,7 +53,13 @@ void main() {
|
|||||||
host(PluriRootHeader(title: 'Settings', onSleepTimer: () {})),
|
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;
|
final insets = padding.padding as EdgeInsets;
|
||||||
expect(insets.left, PluriLayout.titleHorizontal);
|
expect(insets.left, PluriLayout.titleHorizontal);
|
||||||
expect(insets.right, PluriLayout.rowHorizontal);
|
expect(insets.right, PluriLayout.rowHorizontal);
|
||||||
@@ -97,4 +114,92 @@ void main() {
|
|||||||
reason: 'actions render before (to the left of) the bedtime button',
|
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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user