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:
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user