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).
246 lines
8.3 KiB
Dart
246 lines
8.3 KiB
Dart
import 'package:file_picker/file_picker.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../../estado/estado_grabacion.dart';
|
|
import '../../l10n/gen/app_localizations.dart';
|
|
import '../../widgets/pluri_glass_surface.dart';
|
|
import '../../widgets/pluri_layout.dart';
|
|
import '../../widgets/pluri_push_scaffold.dart';
|
|
|
|
/// GRABACIONES Y MÚSICA group · "Grabaciones" (design ADR-3). Body moved
|
|
/// 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.
|
|
class PantallaAjustesGrabaciones extends StatelessWidget {
|
|
const PantallaAjustesGrabaciones({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
return PluriPushScaffold(
|
|
title: l10n.recordingsSectionTitle,
|
|
body: ListView(
|
|
padding: PluriLayout.pageContentPadding,
|
|
children: const [_CuerpoGrabaciones()],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CuerpoGrabaciones extends StatelessWidget {
|
|
const _CuerpoGrabaciones();
|
|
|
|
Future<void> _seleccionarRuta(BuildContext context) async {
|
|
final estado = context.read<EstadoGrabacion>();
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
final l10n = AppLocalizations.of(context);
|
|
final ruta = await FilePicker.platform.getDirectoryPath(
|
|
dialogTitle: l10n.recordingsFolderDialogTitle,
|
|
);
|
|
if (ruta == null) return;
|
|
try {
|
|
await estado.cambiarDirectorio(ruta);
|
|
if (!context.mounted) return;
|
|
messenger.showSnackBar(
|
|
SnackBar(content: Text(l10n.recordingsPathUpdated)),
|
|
);
|
|
} catch (e) {
|
|
if (!context.mounted) return;
|
|
messenger.showSnackBar(
|
|
SnackBar(content: Text(l10n.recordingsPathSaveError(e.toString()))),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _restaurarRuta(BuildContext context) async {
|
|
final estado = context.read<EstadoGrabacion>();
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
final l10n = AppLocalizations.of(context);
|
|
await estado.restaurarDirectorio();
|
|
if (!context.mounted) return;
|
|
messenger.showSnackBar(
|
|
SnackBar(content: Text(l10n.recordingsDefaultFolderRestored)),
|
|
);
|
|
}
|
|
|
|
Future<void> _abrirCarpeta(BuildContext context) async {
|
|
final estado = context.read<EstadoGrabacion>();
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
final l10n = AppLocalizations.of(context);
|
|
try {
|
|
final abierto = await estado.abrirDirectorio();
|
|
if (!context.mounted) return;
|
|
if (!abierto) {
|
|
messenger.showSnackBar(
|
|
SnackBar(content: Text(l10n.recordingsOpenFolderError(l10n.dash))),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
if (!context.mounted) return;
|
|
messenger.showSnackBar(
|
|
SnackBar(content: Text(l10n.recordingsOpenFolderError(e.toString()))),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _editarTamanoMaximo(BuildContext context) async {
|
|
final estado = context.read<EstadoGrabacion>();
|
|
final actualMb = _bytesAMegabytes(estado.maxBytes);
|
|
|
|
final nuevoMb = await showModalBottomSheet<int>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
showDragHandle: true,
|
|
builder: (ctx) => _HojaTamanoMaximo(actualMb: actualMb),
|
|
);
|
|
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(
|
|
SnackBar(content: Text(l10n.recordingsMaxSizeSaved(nuevoMb))),
|
|
);
|
|
}
|
|
|
|
int _bytesAMegabytes(int bytes) =>
|
|
(bytes / (1024 * 1024)).round().clamp(1, 1048576);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Recording state lives in EstadoGrabacion (S4-R2): this section only
|
|
// rebuilds on recording changes, never on playback notifications.
|
|
final estado = context.watch<EstadoGrabacion>();
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
return PluriGlassSurface(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
FutureBuilder<String>(
|
|
future: estado.directorioEfectivo(),
|
|
builder:
|
|
(ctx, snap) => ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const Icon(Icons.folder_outlined),
|
|
title: Text(l10n.recordingsFolderTitle),
|
|
subtitle: Text(
|
|
snap.data ?? l10n.recordingsPathCalculating,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
OutlinedButton.icon(
|
|
icon: const Icon(Icons.folder_open_rounded),
|
|
label: Text(l10n.recordingsChangePath),
|
|
onPressed: () => _seleccionarRuta(context),
|
|
),
|
|
FilledButton.tonalIcon(
|
|
icon: const Icon(Icons.folder_copy_rounded),
|
|
label: Text(l10n.recordingsOpenFolder),
|
|
onPressed: () => _abrirCarpeta(context),
|
|
),
|
|
IconButton.filledTonal(
|
|
tooltip: l10n.recordingsUseDefaultPath,
|
|
icon: const Icon(Icons.restore_rounded),
|
|
onPressed: () => _restaurarRuta(context),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const Icon(Icons.sd_storage_rounded),
|
|
title: Text(l10n.recordingsMaxSizeTitle),
|
|
subtitle: Text(
|
|
l10n.recordingsMaxSizeSubtitle(_bytesAMegabytes(estado.maxBytes)),
|
|
),
|
|
onTap: () => _editarTamanoMaximo(context),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
l10n.recordingsOriginalStreamHint,
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|