diff --git a/lib/pantallas/ajustes/pantalla_ajustes_grabaciones.dart b/lib/pantallas/ajustes/pantalla_ajustes_grabaciones.dart index bc4dc79..dabf986 100644 --- a/lib/pantallas/ajustes/pantalla_ajustes_grabaciones.dart +++ b/lib/pantallas/ajustes/pantalla_ajustes_grabaciones.dart @@ -12,12 +12,6 @@ import '../../widgets/pluri_push_scaffold.dart'; /// verbatim from the former `_SeccionGrabaciones` in `pantalla_ajustes.dart` /// — only the panel header's icon and title were removed (the pushed /// screen's title now carries them); every method below is unchanged. -/// -/// Known pre-existing bug, deliberately NOT fixed here (out of scope, moved -/// verbatim, tracked separately): [_editarTamanoMaximo] disposes its -/// [TextEditingController] immediately after `showModalBottomSheet` resolves, -/// racing the sheet's own close animation — the same shape of bug already -/// documented for `_editarGrupo` in `pantalla_ajustes_grupos_favoritos.dart`. class PantallaAjustesGrabaciones extends StatelessWidget { const PantallaAjustesGrabaciones({super.key}); @@ -92,53 +86,16 @@ class _CuerpoGrabaciones extends StatelessWidget { Future _editarTamanoMaximo(BuildContext context) async { final estado = context.read(); - final l10n = AppLocalizations.of(context); final actualMb = _bytesAMegabytes(estado.maxBytes); - final controller = TextEditingController(text: actualMb.toString()); final nuevoMb = await showModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, - builder: (ctx) { - final bottom = MediaQuery.viewInsetsOf(ctx).bottom; - return Padding( - padding: EdgeInsets.fromLTRB(20, 0, 20, bottom + 24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.recordingsMaxSizeDialogTitle, - style: Theme.of(ctx).textTheme.titleLarge, - ), - const SizedBox(height: 16), - TextField( - controller: controller, - autofocus: true, - keyboardType: TextInputType.number, - decoration: InputDecoration( - labelText: l10n.recordingsMaxSizeMbLabel, - border: const OutlineInputBorder(), - ), - ), - const SizedBox(height: 16), - FilledButton.icon( - onPressed: () { - final value = int.tryParse(controller.text.trim()); - if (value == null || value <= 0) return; - Navigator.of(ctx).pop(value); - }, - icon: const Icon(Icons.save_rounded), - label: Text(l10n.saveQuickAccessButton), - ), - ], - ), - ); - }, + builder: (ctx) => _HojaTamanoMaximo(actualMb: actualMb), ); - controller.dispose(); if (nuevoMb == null || !context.mounted) return; + final l10n = AppLocalizations.of(context); await estado.cambiarMaxBytes(nuevoMb * 1024 * 1024); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( @@ -215,3 +172,74 @@ class _CuerpoGrabaciones extends StatelessWidget { ); } } + +/// The "maximum recording size" bottom sheet's own content, as a +/// `StatefulWidget` so its `TextEditingController` is owned by the SHEET's +/// `State`, not by the caller's `async` function — same bugfix shape as +/// `_HojaEditarGrupo` in `pantalla_ajustes_grupos_favoritos.dart` (a +/// controller disposed immediately after `showModalBottomSheet` resolves +/// races the sheet's own close animation, which still holds a bound +/// `TextField` for a couple more frames). Flutter only calls +/// `State.dispose()` once this widget is actually removed from the tree, +/// i.e. after the close animation finishes. +class _HojaTamanoMaximo extends StatefulWidget { + const _HojaTamanoMaximo({required this.actualMb}); + + final int actualMb; + + @override + State<_HojaTamanoMaximo> createState() => _HojaTamanoMaximoState(); +} + +class _HojaTamanoMaximoState extends State<_HojaTamanoMaximo> { + late final TextEditingController _controller = TextEditingController( + text: widget.actualMb.toString(), + ); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _guardar() { + final value = int.tryParse(_controller.text.trim()); + if (value == null || value <= 0) return; + Navigator.of(context).pop(value); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final bottom = MediaQuery.viewInsetsOf(context).bottom; + return Padding( + padding: EdgeInsets.fromLTRB(20, 0, 20, bottom + 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.recordingsMaxSizeDialogTitle, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + TextField( + controller: _controller, + autofocus: true, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: l10n.recordingsMaxSizeMbLabel, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: _guardar, + icon: const Icon(Icons.save_rounded), + label: Text(l10n.saveQuickAccessButton), + ), + ], + ), + ); + } +} diff --git a/lib/pantallas/ajustes/pantalla_ajustes_grupos_favoritos.dart b/lib/pantallas/ajustes/pantalla_ajustes_grupos_favoritos.dart index 783c9e5..e6feeb1 100644 --- a/lib/pantallas/ajustes/pantalla_ajustes_grupos_favoritos.dart +++ b/lib/pantallas/ajustes/pantalla_ajustes_grupos_favoritos.dart @@ -34,54 +34,14 @@ class _CuerpoGruposFavoritos extends StatelessWidget { BuildContext context, [ GrupoFavoritos? grupo, ]) async { - final l10n = AppLocalizations.of(context); - final controller = TextEditingController(text: grupo?.nombre ?? ''); final nombre = await showModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, - builder: (ctx) { - final bottom = MediaQuery.viewInsetsOf(ctx).bottom; - return Padding( - padding: EdgeInsets.fromLTRB(20, 0, 20, bottom + 24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - grupo == null - ? l10n.favoriteGroupsAdd - : l10n.favoriteGroupsEdit, - style: Theme.of(ctx).textTheme.titleLarge, - ), - const SizedBox(height: 16), - TextField( - controller: controller, - autofocus: true, - maxLength: 28, - decoration: InputDecoration( - labelText: l10n.favoriteGroupsNameLabel, - helperText: l10n.favoriteGroupsNameTooLong, - border: const OutlineInputBorder(), - ), - ), - const SizedBox(height: 12), - FilledButton.icon( - icon: const Icon(Icons.save_rounded), - label: Text(AppLocalizations.of(ctx).saveQuickAccessButton), - onPressed: () { - final value = controller.text.trim(); - if (value.isEmpty || value.length > 28) return; - Navigator.pop(ctx, value); - }, - ), - ], - ), - ); - }, + builder: (ctx) => _HojaEditarGrupo(grupo: grupo), ); - controller.dispose(); if (nombre == null || !context.mounted) return; + final l10n = AppLocalizations.of(context); final estado = context.read(); if (grupo == null) { await estado.crearGrupoFavoritos(nombre); @@ -172,3 +132,77 @@ class _CuerpoGruposFavoritos extends StatelessWidget { ); } } + +/// The add/rename bottom sheet's own content, as a `StatefulWidget` so its +/// `TextEditingController` is owned by the SHEET's `State`, not by the +/// caller's `async` function (bugfix: a `TextEditingController` disposed +/// immediately after `showModalBottomSheet` resolves races the sheet's own +/// close animation, which still holds a `TextField` bound to that controller +/// for a couple more frames — "A TextEditingController was used after being +/// disposed"). Flutter only calls `State.dispose()` once this widget is +/// actually removed from the tree, i.e. after the close animation finishes, +/// so there is no dispose-timing decision left for the caller to get wrong. +class _HojaEditarGrupo extends StatefulWidget { + const _HojaEditarGrupo({required this.grupo}); + + final GrupoFavoritos? grupo; + + @override + State<_HojaEditarGrupo> createState() => _HojaEditarGrupoState(); +} + +class _HojaEditarGrupoState extends State<_HojaEditarGrupo> { + late final TextEditingController _controller = TextEditingController( + text: widget.grupo?.nombre ?? '', + ); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _guardar() { + final value = _controller.text.trim(); + if (value.isEmpty || value.length > 28) return; + Navigator.pop(context, value); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final bottom = MediaQuery.viewInsetsOf(context).bottom; + return Padding( + padding: EdgeInsets.fromLTRB(20, 0, 20, bottom + 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.grupo == null + ? l10n.favoriteGroupsAdd + : l10n.favoriteGroupsEdit, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + TextField( + controller: _controller, + autofocus: true, + maxLength: 28, + decoration: InputDecoration( + labelText: l10n.favoriteGroupsNameLabel, + helperText: l10n.favoriteGroupsNameTooLong, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + FilledButton.icon( + icon: const Icon(Icons.save_rounded), + label: Text(l10n.saveQuickAccessButton), + onPressed: _guardar, + ), + ], + ), + ); + } +} diff --git a/test/pantallas/ajustes/pantalla_ajustes_grabaciones_test.dart b/test/pantallas/ajustes/pantalla_ajustes_grabaciones_test.dart index 78bb121..721cd83 100644 --- a/test/pantallas/ajustes/pantalla_ajustes_grabaciones_test.dart +++ b/test/pantallas/ajustes/pantalla_ajustes_grabaciones_test.dart @@ -28,14 +28,6 @@ void _suppressListTileInkAssertion() { /// WU3b task 3b.1: the GRABACIONES Y MÚSICA detail screen for "Grabaciones" /// renders inside a [PluriPushScaffold] and its moved controls still respond /// exactly as they did inside the old `_SeccionGrabaciones`. -/// -/// This file deliberately does NOT interact with "Maximum recording size" -/// (`_editarTamanoMaximo`): that control has a pre-existing, out-of-scope -/// controller-dispose race (documented in `pantalla_ajustes_grabaciones.dart` -/// and, for the analogous `_editarGrupo` case, in -/// `pantalla_ajustes_grupos_favoritos_test.dart`) that this move does not -/// fix. "Restore default path" is exercised instead — a real, moved -/// capability with no such hazard. void main() { setUp(() { SharedPreferences.setMockInitialValues({}); @@ -100,4 +92,37 @@ void main() { await tester.pump(const Duration(seconds: 5)); await tester.pumpAndSettle(); }); + + testWidgets( + 'moved control still responds: editing the maximum recording size ' + 'persists it', + (tester) async { + _suppressListTileInkAssertion(); + final estado = EstadoGrabacion( + servicio: FakeServicioGrabacionRadioInactiva(), + ); + addTearDown(estado.dispose); + expect(estado.maxBytes, 500 * 1024 * 1024, reason: 'default, not 250'); + + await tester.pumpWidget(buildScreen(estado)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + await tester.tap(find.text('Maximum recording size')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), '250'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Save quick access')); + await tester.pumpAndSettle(); + + expect(estado.maxBytes, 250 * 1024 * 1024); + expect(find.text('Recording limit updated to 250 MB'), findsOneWidget); + + // SnackBar's own dismiss Timer is not frame-scheduled — let it resolve + // before teardown (WU3a batch discovery) instead of leaving a pending + // Timer behind. + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + }, + ); } diff --git a/test/pantallas/ajustes/pantalla_ajustes_grupos_favoritos_test.dart b/test/pantallas/ajustes/pantalla_ajustes_grupos_favoritos_test.dart index 8a7b667..4194690 100644 --- a/test/pantallas/ajustes/pantalla_ajustes_grupos_favoritos_test.dart +++ b/test/pantallas/ajustes/pantalla_ajustes_grupos_favoritos_test.dart @@ -33,39 +33,6 @@ void _suppressListTileInkAssertion() { addTearDown(() => FlutterError.onError = original); } -/// Pre-existing bug, out of scope for this move-only WU: `_editarGrupo` -/// (copied verbatim from the old `_SeccionGruposFavoritos`) disposes its -/// `TextEditingController` immediately after `showModalBottomSheet` resolves, -/// racing the sheet's own close animation, which still holds a `TextField` -/// bound to that controller for a couple more frames. It does not stop -/// `crearGrupoFavoritos` from running correctly, and reproduces identically -/// against the pre-WU3a combined screen (this widget's body is unmodified). -/// Fixing the dispose timing would be a logic edit, which this WU's -/// verbatim-move contract forbids; flagged for a future fix instead. -/// -/// The one race produces a cascade of framework-internal symptoms while the -/// sheet's close animation and the disposed controller fight over the same -/// frame (an overlay `_dependents.isEmpty` assertion, and a transient -/// RenderFlex overflow against this file's bottom-sheet Column). All are -/// suppressed together as one documented, narrowly-scoped exception. -void _suppressDisposedControllerCascade() { - final original = FlutterError.onError; - FlutterError.onError = (details) { - final message = details.exceptionAsString(); - final full = details.toString(); - final isKnownCascade = - message.contains( - 'A TextEditingController was used after being disposed', - ) || - message.contains("'_dependents.isEmpty': is not true") || - (message.contains('RenderFlex overflowed') && - full.contains('pantalla_ajustes_grupos_favoritos.dart')); - if (isKnownCascade) return; - original?.call(details); - }; - addTearDown(() => FlutterError.onError = original); -} - void main() { setUp(() { SharedPreferences.setMockInitialValues({}); @@ -124,7 +91,6 @@ void main() { tester, ) async { _suppressListTileInkAssertion(); - _suppressDisposedControllerCascade(); final estado = await crearEstado(); addTearDown(estado.dispose);