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
@@ -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<void> _editarTamanoMaximo(BuildContext context) async {
final estado = context.read<EstadoGrabacion>();
final l10n = AppLocalizations.of(context);
final actualMb = _bytesAMegabytes(estado.maxBytes);
final controller = TextEditingController(text: actualMb.toString());
final nuevoMb = await showModalBottomSheet<int>(
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),
),
],
),
);
}
}
@@ -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<String>(
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<EstadoRadio>();
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,
),
],
),
);
}
}