fix(ajustes): dispose sheet text controllers after the close animation

Pre-existing bug, reproduces identically before this branch's changes:
_editarGrupo (pantalla_ajustes_grupos_favoritos.dart) and
_editarTamanoMaximo (pantalla_ajustes_grabaciones.dart) each created a
TextEditingController, awaited showModalBottomSheet, then disposed the
controller immediately on resolve - racing the sheet's own close
animation, which still holds a bound TextField for a couple more
frames. Manifests as "A TextEditingController was used after being
disposed" plus a couple of cascading framework-internal symptoms.

Fix: extract each sheet's content into its own StatefulWidget
(_HojaEditarGrupo, _HojaTamanoMaximo) that owns the controller in its
own State. Flutter only calls State.dispose() once the 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.

_editarGrupo's own test previously suppressed the crash via a
FlutterError.onError override instead of fixing it; that suppression
is removed here. _editarTamanoMaximo had no coverage at all for this
interaction; added it. Strict TDD: confirmed both sites fail without
the fix (RED) before applying it (GREEN).
This commit is contained in:
2026-07-29 15:22:15 +02:00
parent 9b415e73b0
commit f3d744aeed
4 changed files with 182 additions and 129 deletions
@@ -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();
},
);
}
@@ -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);