From 445e4518f703bd77ddbe099da0dfd088e48965fd Mon Sep 17 00:00:00 2001 From: ShanaiaBot Date: Fri, 31 Jul 2026 23:30:53 +0200 Subject: [PATCH 1/5] chore: bump version to 1.2.7+129 [ci skip] --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index f9f1fb3..036ab13 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: pluriwave description: "Radio mundial con ecualizador, reconocimiento de canciones y UI premium" publish_to: 'none' -version: 1.2.6+128 +version: 1.2.7+129 environment: sdk: ^3.7.0 From cfd8bc9e6a26ea3a9c2caf77c5a5bcfd838047b7 Mon Sep 17 00:00:00 2001 From: freetlab Date: Sat, 1 Aug 2026 11:26:24 +0200 Subject: [PATCH 2/5] 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. --- lib/estado/estado_radio.dart | 40 +++++-- lib/servicios/navegacion_auto.dart | 34 +++--- test/estado/estado_radio_test.dart | 130 +++++++++++++++++++++++ test/servicios/navegacion_auto_test.dart | 26 +++-- 4 files changed, 201 insertions(+), 29 deletions(-) diff --git a/lib/estado/estado_radio.dart b/lib/estado/estado_radio.dart index 5bf7e08..4236b50 100644 --- a/lib/estado/estado_radio.dart +++ b/lib/estado/estado_radio.dart @@ -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 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; } diff --git a/lib/servicios/navegacion_auto.dart b/lib/servicios/navegacion_auto.dart index b2ad3d8..1965e93 100644 --- a/lib/servicios/navegacion_auto.dart +++ b/lib/servicios/navegacion_auto.dart @@ -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 hijos(String parentId, {required List 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:` @@ -824,10 +831,12 @@ class ConstructorArbolAuto { } /// Members of the favorite group identified by [grupoMediaId] (a - /// `grupo:` 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), 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 hijosGrupo( String grupoMediaId, { required List 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 diff --git a/test/estado/estado_radio_test.dart b/test/estado/estado_radio_test.dart index f8d2f76..b1fd6c5 100644 --- a/test/estado/estado_radio_test.dart +++ b/test/estado/estado_radio_test.dart @@ -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(); diff --git a/test/servicios/navegacion_auto_test.dart b/test/servicios/navegacion_auto_test.dart index 674452a..5328d9f 100644 --- a/test/servicios/navegacion_auto_test.dart +++ b/test/servicios/navegacion_auto_test.dart @@ -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', From 4d54908be64de2ee80ede7555b39f0e1150f42f5 Mon Sep 17 00:00:00 2001 From: freetlab Date: Sat, 1 Aug 2026 12:00:48 +0200 Subject: [PATCH 3/5] 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). --- lib/widgets/pluri_root_header.dart | 74 +++++++++------ test/widgets/pluri_root_header_test.dart | 109 ++++++++++++++++++++++- 2 files changed, 153 insertions(+), 30 deletions(-) diff --git a/lib/widgets/pluri_root_header.dart b/lib/widgets/pluri_root_header.dart index cae32ae..f87efa5 100644 --- a/lib/widgets/pluri_root_header.dart +++ b/lib/widgets/pluri_root_header.dart @@ -29,41 +29,59 @@ class PluriRootHeader extends StatelessWidget { /// needs nothing extra here). final List 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( - height: height, - child: Padding( - // 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`). - padding: const EdgeInsets.fromLTRB( - PluriLayout.titleHorizontal, - 0, - PluriLayout.rowHorizontal, - 0, - ), - child: Row( - children: [ - Expanded( - child: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: type.sectionTitle, + 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`). + padding: const EdgeInsets.fromLTRB( + PluriLayout.titleHorizontal, + 0, + PluriLayout.rowHorizontal, + 0, + ), + child: Row( + children: [ + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: type.sectionTitle, + ), ), - ), - ...actions, - IconButton( - icon: const Icon(Icons.bedtime_outlined), - tooltip: l10n.sleepTimer, - onPressed: onSleepTimer, - ), - ], + ...actions, + IconButton( + icon: const Icon(Icons.bedtime_outlined), + tooltip: l10n.sleepTimer, + onPressed: onSleepTimer, + ), + ], + ), ), ), ); diff --git a/test/widgets/pluri_root_header_test.dart b/test/widgets/pluri_root_header_test.dart index eba0885..29c7f74 100644 --- a/test/widgets/pluri_root_header_test.dart +++ b/test/widgets/pluri_root_header_test.dart @@ -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(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( + 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 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); + }, + ); + }); } From 597701f49719634970e21496e541cd8ca6b53525 Mon Sep 17 00:00:00 2001 From: freetlab Date: Sat, 1 Aug 2026 12:06:00 +0200 Subject: [PATCH 4/5] 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. --- lib/pantallas/pantalla_vacaciones.dart | 51 ++++++- test/pantallas/pantalla_vacaciones_test.dart | 140 +++++++++++++++++++ 2 files changed, 187 insertions(+), 4 deletions(-) diff --git a/lib/pantallas/pantalla_vacaciones.dart b/lib/pantallas/pantalla_vacaciones.dart index 3fb2d97..79328ad 100644 --- a/lib/pantallas/pantalla_vacaciones.dart +++ b/lib/pantallas/pantalla_vacaciones.dart @@ -842,10 +842,38 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> { ], ), const SizedBox(height: 16), - FilledButton.icon( - onPressed: _guardar, - icon: const Icon(Icons.check_rounded), - label: Text(l10n.saveRangeAction), + 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 `_confirmarEliminarRange` 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 _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().eliminarRangoVacaciones(rango.id); + if (mounted) Navigator.pop(context); + } } class _PickerButton extends StatelessWidget { diff --git a/test/pantallas/pantalla_vacaciones_test.dart b/test/pantallas/pantalla_vacaciones_test.dart index f0e15bd..6cc9dca 100644 --- a/test/pantallas/pantalla_vacaciones_test.dart +++ b/test/pantallas/pantalla_vacaciones_test.dart @@ -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', + ); + }); + }); } From f4f9e87970eef6be253ae4d33fd566073c3f197d Mon Sep 17 00:00:00 2001 From: freetlab Date: Sat, 1 Aug 2026 12:08:14 +0200 Subject: [PATCH 5/5] docs(alarmas): fix helper name typo in vacation delete comment --- lib/pantallas/pantalla_vacaciones.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pantallas/pantalla_vacaciones.dart b/lib/pantallas/pantalla_vacaciones.dart index 79328ad..b347b33 100644 --- a/lib/pantallas/pantalla_vacaciones.dart +++ b/lib/pantallas/pantalla_vacaciones.dart @@ -933,7 +933,7 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> { } /// Fix `vacaciones-delete`: mirrors `_guardar`'s pop-on-success shape, - /// but confirms first (via the same `_confirmarEliminarRange` dialog the + /// 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).