WU15 shipped PantallaGrabaciones (the recordings library: storage bar, recording rows, the "..." Rename/Share/Delete menu) fully tested but reachable from nowhere in the app - a gap flagged in WU15's own apply-progress notes, not fixed there since it needed a design decision rather than a guess. Coordinator ruling applied: the approved mockup's "Ajustes > Grabaciones" screen depicts the library, not the folder/size settings form. So the GRABACIONES Y MUSICA group's "Grabaciones" row in pantalla_ajustes.dart now opens PantallaGrabaciones instead of PantallaAjustesGrabaciones. The settings form is not dropped - it stays reachable, now from within the library via a settings icon in its PluriPushScaffold actions, matching the existing pantalla_ajustes_timer_sueno.dart "Add" action precedent for a real capability living in the header. One new ARB key (en/es only, per precedent): recordingsLibrarySettingsTooltip. Tests: 604 -> 605 (one scenario re-targeted in pantalla_ajustes_test.dart, one new scenario in pantalla_grabaciones_test.dart, which needed the same ListTile-ink-assertion suppression helper WU3a/WU3b established since it now pushes a ListTile-bearing settings screen). flutter analyze unchanged at 1 pre-existing info. Recorded in tasks.md as WU15b - not part of the original 18-unit plan, added here to close the gap WU15 flagged.
450 lines
14 KiB
Dart
450 lines
14 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:just_audio/just_audio.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:share_plus/share_plus.dart' show Share, XFile;
|
|
|
|
import '../estado/estado_grabacion.dart';
|
|
import '../l10n/gen/app_localizations.dart';
|
|
import '../modelos/archivo_grabacion.dart';
|
|
import '../widgets/pluri_glass_surface.dart';
|
|
import '../widgets/pluri_icon.dart';
|
|
import '../widgets/pluri_layout.dart';
|
|
import '../widgets/pluri_premium_widgets.dart';
|
|
import '../widgets/pluri_push_scaffold.dart';
|
|
import 'ajustes/pantalla_ajustes_grabaciones.dart';
|
|
|
|
/// Inline-preview playback + duration lookup for a single recording file at
|
|
/// a time (WU15, recordings-library spec — "Row playback starts and
|
|
/// stops"). Kept separate from `ServicioAudio` (never touched — that class
|
|
/// is coupled to live radio-stream transport/reconnect, unrelated to
|
|
/// previewing an already-finished local recording).
|
|
///
|
|
/// The real implementation ([_ReproductorGrabacionesJustAudio]) wraps
|
|
/// `just_audio.AudioPlayer`, which needs platform `MethodChannel`s this
|
|
/// suite does not mock — the same constraint `cola_local_test.dart`
|
|
/// documents for `PluriWaveAudioHandler` — so it is static-review-only.
|
|
/// Every test in `pantalla_grabaciones_test.dart` injects a fake instead.
|
|
abstract class ReproductorGrabaciones {
|
|
/// Path currently loaded/playing, or null.
|
|
String? get rutaActual;
|
|
|
|
/// True while [rutaActual] is actively playing (not just loaded/paused).
|
|
bool get reproduciendo;
|
|
|
|
/// Loads [ruta]'s metadata and returns its duration, without playing it.
|
|
Future<Duration?> duracionDe(String ruta);
|
|
|
|
/// Starts playback of [ruta]. If [ruta] is already the one playing, this
|
|
/// pauses it instead — the row's play/pause affordance is a toggle.
|
|
Future<void> alternar(String ruta);
|
|
|
|
Future<void> detener();
|
|
Future<void> dispose();
|
|
}
|
|
|
|
class _ReproductorGrabacionesJustAudio implements ReproductorGrabaciones {
|
|
final AudioPlayer _player = AudioPlayer();
|
|
String? _rutaActual;
|
|
|
|
@override
|
|
String? get rutaActual => _rutaActual;
|
|
|
|
@override
|
|
bool get reproduciendo => _player.playing;
|
|
|
|
@override
|
|
Future<Duration?> duracionDe(String ruta) async {
|
|
final sonda = AudioPlayer();
|
|
try {
|
|
return await sonda.setFilePath(ruta);
|
|
} finally {
|
|
await sonda.dispose();
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> alternar(String ruta) async {
|
|
if (_rutaActual == ruta && _player.playing) {
|
|
await _player.pause();
|
|
return;
|
|
}
|
|
if (_rutaActual != ruta) {
|
|
await _player.setFilePath(ruta);
|
|
_rutaActual = ruta;
|
|
}
|
|
await _player.play();
|
|
}
|
|
|
|
@override
|
|
Future<void> detener() async {
|
|
await _player.stop();
|
|
_rutaActual = null;
|
|
}
|
|
|
|
@override
|
|
Future<void> dispose() => _player.dispose();
|
|
}
|
|
|
|
/// WU15: the recordings library — storage usage, browsable rows with
|
|
/// inline playback, and a "⋮" menu constrained to exactly
|
|
/// Rename/Share/Delete (`recordings-library` spec). Distinct from
|
|
/// `PantallaAjustesGrabaciones` (WU3b), which is the folder/size-limit
|
|
/// SETTINGS screen, not this browsable file list.
|
|
class PantallaGrabaciones extends StatefulWidget {
|
|
const PantallaGrabaciones({
|
|
super.key,
|
|
ReproductorGrabaciones? reproductor,
|
|
Future<void> Function(String ruta)? compartir,
|
|
}) : _reproductorInyectado = reproductor,
|
|
_compartirInyectado = compartir;
|
|
|
|
final ReproductorGrabaciones? _reproductorInyectado;
|
|
final Future<void> Function(String ruta)? _compartirInyectado;
|
|
|
|
@override
|
|
State<PantallaGrabaciones> createState() => _PantallaGrabacionesState();
|
|
}
|
|
|
|
class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
|
|
late final ReproductorGrabaciones _reproductor =
|
|
widget._reproductorInyectado ?? _ReproductorGrabacionesJustAudio();
|
|
late final Future<void> Function(String ruta) _compartir =
|
|
widget._compartirInyectado ?? (ruta) => Share.shareXFiles([XFile(ruta)]);
|
|
|
|
late Future<List<ArchivoGrabacion>> _grabaciones;
|
|
final Map<String, Future<Duration?>> _duracionCache = {};
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_recargar();
|
|
}
|
|
|
|
void _recargar() {
|
|
_duracionCache.clear();
|
|
_grabaciones = context.read<EstadoGrabacion>().listarGrabaciones();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
unawaited(_reproductor.dispose());
|
|
super.dispose();
|
|
}
|
|
|
|
Future<Duration?> _duracionPara(String ruta) =>
|
|
_duracionCache.putIfAbsent(ruta, () => _reproductor.duracionDe(ruta));
|
|
|
|
Future<void> _alternarReproduccion(String ruta) async {
|
|
await _reproductor.alternar(ruta);
|
|
if (!mounted) return;
|
|
setState(() {});
|
|
}
|
|
|
|
Future<void> _manejarAccion(String accion, ArchivoGrabacion archivo) async {
|
|
// Yield one microtask before opening any dialog: `PopupMenuButton`'s own
|
|
// route is still popping off the Navigator at the moment `onSelected`
|
|
// fires, and pushing a new route (showDialog) synchronously against
|
|
// that in-flight pop can race its close transition.
|
|
await Future<void>.delayed(Duration.zero);
|
|
if (accion == 'rename') {
|
|
await _renombrar(archivo);
|
|
return;
|
|
}
|
|
if (accion == 'share') {
|
|
await _compartir(archivo.ruta);
|
|
return;
|
|
}
|
|
if (accion == 'delete') {
|
|
await _eliminar(archivo);
|
|
}
|
|
}
|
|
|
|
Future<void> _renombrar(ArchivoGrabacion archivo) async {
|
|
final nuevoNombre = await showDialog<String>(
|
|
context: context,
|
|
builder: (_) => _DialogoRenombrarGrabacion(nombreActual: archivo.nombre),
|
|
);
|
|
if (nuevoNombre == null || nuevoNombre.trim().isEmpty) return;
|
|
if (!mounted) return;
|
|
await context.read<EstadoGrabacion>().renombrarGrabacion(
|
|
archivo.ruta,
|
|
nuevoNombre.trim(),
|
|
);
|
|
if (!mounted) return;
|
|
setState(_recargar);
|
|
}
|
|
|
|
Future<void> _eliminar(ArchivoGrabacion archivo) async {
|
|
final l10n = AppLocalizations.of(context);
|
|
final confirmar = await showDialog<bool>(
|
|
context: context,
|
|
builder:
|
|
(ctx) => AlertDialog(
|
|
title: Text(l10n.recordingDeleteConfirmTitle),
|
|
content: Text(l10n.recordingDeleteConfirmMessage),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
child: Text(l10n.cancelAction),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(ctx, true),
|
|
child: Text(l10n.recordingActionDelete),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (confirmar != true) return;
|
|
if (!mounted) return;
|
|
await context.read<EstadoGrabacion>().eliminarGrabacion(archivo.ruta);
|
|
if (!mounted) return;
|
|
setState(_recargar);
|
|
}
|
|
|
|
String _formatearDuracion(Duration? d) {
|
|
if (d == null) return '--:--';
|
|
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
|
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
|
final h = d.inHours;
|
|
return h > 0 ? '$h:$m:$s' : '$m:$s';
|
|
}
|
|
|
|
String _formatearFecha(DateTime fecha) {
|
|
final dia = fecha.day.toString().padLeft(2, '0');
|
|
final mes = fecha.month.toString().padLeft(2, '0');
|
|
return '$dia/$mes/${fecha.year}';
|
|
}
|
|
|
|
String _formatearBytes(int bytes) {
|
|
if (bytes < 1024) return '$bytes B';
|
|
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
return PluriPushScaffold(
|
|
title: l10n.recordingsLibraryTitle,
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.settings_outlined),
|
|
tooltip: l10n.recordingsLibrarySettingsTooltip,
|
|
onPressed:
|
|
() => PluriPushScaffold.push(
|
|
context,
|
|
(_) => const PantallaAjustesGrabaciones(),
|
|
),
|
|
),
|
|
],
|
|
body: FutureBuilder<List<ArchivoGrabacion>>(
|
|
future: _grabaciones,
|
|
builder: (context, snap) {
|
|
final archivos = snap.data ?? const <ArchivoGrabacion>[];
|
|
return ListView(
|
|
padding: PluriLayout.pageContentPadding,
|
|
children: [
|
|
_BarraDeAlmacenamiento(archivos: archivos),
|
|
const SizedBox(height: 12),
|
|
if (snap.connectionState == ConnectionState.done &&
|
|
archivos.isEmpty)
|
|
PluriEmptyState(
|
|
glyph: PluriIconGlyph.player,
|
|
title: l10n.recordingsLibraryEmptyTitle,
|
|
subtitle: l10n.recordingsLibraryEmptySubtitle,
|
|
)
|
|
else
|
|
for (final archivo in archivos)
|
|
_FilaGrabacion(
|
|
archivo: archivo,
|
|
reproduciendo:
|
|
_reproductor.rutaActual == archivo.ruta &&
|
|
_reproductor.reproduciendo,
|
|
duracion: _duracionPara(archivo.ruta),
|
|
formatearDuracion: _formatearDuracion,
|
|
formatearFecha: _formatearFecha,
|
|
formatearBytes: _formatearBytes,
|
|
onAlternarReproduccion:
|
|
() => _alternarReproduccion(archivo.ruta),
|
|
onAccionMenu: (accion) => _manejarAccion(accion, archivo),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _BarraDeAlmacenamiento extends StatelessWidget {
|
|
const _BarraDeAlmacenamiento({required this.archivos});
|
|
|
|
final List<ArchivoGrabacion> archivos;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final estado = context.watch<EstadoGrabacion>();
|
|
final usadoBytes = archivos.fold<int>(0, (s, a) => s + a.tamanoBytes);
|
|
final totalBytes = estado.maxBytes <= 0 ? 1 : estado.maxBytes;
|
|
final fraccion = (usadoBytes / totalBytes).clamp(0.0, 1.0);
|
|
final usadoMb = (usadoBytes / (1024 * 1024)).round();
|
|
final totalMb = (totalBytes / (1024 * 1024)).round();
|
|
|
|
return PluriGlassSurface(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: LinearProgressIndicator(value: fraccion, minHeight: 8),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
l10n.recordingsLibraryStorageCaption(usadoMb, totalMb),
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _FilaGrabacion extends StatelessWidget {
|
|
const _FilaGrabacion({
|
|
required this.archivo,
|
|
required this.reproduciendo,
|
|
required this.duracion,
|
|
required this.formatearDuracion,
|
|
required this.formatearFecha,
|
|
required this.formatearBytes,
|
|
required this.onAlternarReproduccion,
|
|
required this.onAccionMenu,
|
|
});
|
|
|
|
final ArchivoGrabacion archivo;
|
|
final bool reproduciendo;
|
|
final Future<Duration?> duracion;
|
|
final String Function(Duration?) formatearDuracion;
|
|
final String Function(DateTime) formatearFecha;
|
|
final String Function(int) formatearBytes;
|
|
final VoidCallback onAlternarReproduccion;
|
|
final ValueChanged<String> onAccionMenu;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
return PluriGlassSurface(
|
|
child: ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: IconButton(
|
|
icon: Icon(
|
|
reproduciendo
|
|
? Icons.pause_circle_filled_rounded
|
|
: Icons.play_circle_fill_rounded,
|
|
),
|
|
onPressed: onAlternarReproduccion,
|
|
),
|
|
title: Text(archivo.nombre),
|
|
subtitle: FutureBuilder<Duration?>(
|
|
future: duracion,
|
|
builder: (context, snap) {
|
|
return Text(
|
|
'${formatearFecha(archivo.fecha)} · '
|
|
'${formatearDuracion(snap.data)} · '
|
|
'${formatearBytes(archivo.tamanoBytes)}',
|
|
);
|
|
},
|
|
),
|
|
trailing: PopupMenuButton<String>(
|
|
icon: const Icon(Icons.more_vert_rounded),
|
|
onSelected: onAccionMenu,
|
|
itemBuilder:
|
|
(context) => [
|
|
PopupMenuItem(
|
|
value: 'rename',
|
|
child: Text(l10n.recordingActionRename),
|
|
),
|
|
PopupMenuItem(
|
|
value: 'share',
|
|
child: Text(l10n.recordingActionShare),
|
|
),
|
|
PopupMenuItem(
|
|
value: 'delete',
|
|
child: Text(l10n.recordingActionDelete),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Owns its own [TextEditingController] and disposes it in its own
|
|
/// [State.dispose] — the SAFE pattern documented for this codebase (see
|
|
/// `_DialogoEdicionDispositivo` in `pantalla_ajustes_salida_audio.dart`),
|
|
/// deliberately NOT the pre-existing anti-pattern (dispose right after the
|
|
/// sheet/dialog Future resolves, racing the close animation) already
|
|
/// tracked elsewhere in this codebase as a separate, un-fixed defect.
|
|
class _DialogoRenombrarGrabacion extends StatefulWidget {
|
|
const _DialogoRenombrarGrabacion({required this.nombreActual});
|
|
|
|
final String nombreActual;
|
|
|
|
@override
|
|
State<_DialogoRenombrarGrabacion> createState() =>
|
|
_DialogoRenombrarGrabacionState();
|
|
}
|
|
|
|
class _DialogoRenombrarGrabacionState
|
|
extends State<_DialogoRenombrarGrabacion> {
|
|
late final _controller = TextEditingController(text: widget.nombreActual);
|
|
String? _error;
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _confirmar(AppLocalizations l10n) {
|
|
final valor = _controller.text.trim();
|
|
if (valor.isEmpty) {
|
|
setState(() => _error = l10n.recordingRenameEmptyError);
|
|
return;
|
|
}
|
|
Navigator.pop(context, valor);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
return AlertDialog(
|
|
title: Text(l10n.recordingRenameDialogTitle),
|
|
content: TextField(
|
|
controller: _controller,
|
|
autofocus: true,
|
|
decoration: InputDecoration(
|
|
labelText: l10n.recordingRenameLabel,
|
|
errorText: _error,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: Text(l10n.cancelAction),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => _confirmar(l10n),
|
|
child: Text(l10n.recordingActionRename),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|