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 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 alternar(String ruta); Future detener(); Future dispose(); } class _ReproductorGrabacionesJustAudio implements ReproductorGrabaciones { final AudioPlayer _player = AudioPlayer(); String? _rutaActual; @override String? get rutaActual => _rutaActual; @override bool get reproduciendo => _player.playing; @override Future duracionDe(String ruta) async { final sonda = AudioPlayer(); try { return await sonda.setFilePath(ruta); } finally { await sonda.dispose(); } } @override Future 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 detener() async { await _player.stop(); _rutaActual = null; } @override Future 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 Function(String ruta)? compartir, }) : _reproductorInyectado = reproductor, _compartirInyectado = compartir; final ReproductorGrabaciones? _reproductorInyectado; final Future Function(String ruta)? _compartirInyectado; @override State createState() => _PantallaGrabacionesState(); } class _PantallaGrabacionesState extends State { late final ReproductorGrabaciones _reproductor = widget._reproductorInyectado ?? _ReproductorGrabacionesJustAudio(); late final Future Function(String ruta) _compartir = widget._compartirInyectado ?? (ruta) => Share.shareXFiles([XFile(ruta)]); late Future> _grabaciones; final Map> _duracionCache = {}; @override void initState() { super.initState(); _recargar(); } void _recargar() { _duracionCache.clear(); _grabaciones = context.read().listarGrabaciones(); } @override void dispose() { unawaited(_reproductor.dispose()); super.dispose(); } Future _duracionPara(String ruta) => _duracionCache.putIfAbsent(ruta, () => _reproductor.duracionDe(ruta)); Future _alternarReproduccion(String ruta) async { await _reproductor.alternar(ruta); if (!mounted) return; setState(() {}); } Future _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.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 _renombrar(ArchivoGrabacion archivo) async { final nuevoNombre = await showDialog( context: context, builder: (_) => _DialogoRenombrarGrabacion(nombreActual: archivo.nombre), ); if (nuevoNombre == null || nuevoNombre.trim().isEmpty) return; if (!mounted) return; await context.read().renombrarGrabacion( archivo.ruta, nuevoNombre.trim(), ); if (!mounted) return; setState(_recargar); } Future _eliminar(ArchivoGrabacion archivo) async { final l10n = AppLocalizations.of(context); final confirmar = await showDialog( 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().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>( future: _grabaciones, builder: (context, snap) { final archivos = snap.data ?? const []; 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 archivos; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final estado = context.watch(); final usadoBytes = archivos.fold(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 duracion; final String Function(Duration?) formatearDuracion; final String Function(DateTime) formatearFecha; final String Function(int) formatearBytes; final VoidCallback onAlternarReproduccion; final ValueChanged 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( future: duracion, builder: (context, snap) { return Text( '${formatearFecha(archivo.fecha)} · ' '${formatearDuracion(snap.data)} · ' '${formatearBytes(archivo.tamanoBytes)}', ); }, ), trailing: PopupMenuButton( 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), ), ], ); } }