import 'dart:async'; import 'package:flutter/material.dart'; import 'package:just_audio/just_audio.dart'; import 'package:provider/provider.dart'; import '../estado/estado_grabacion.dart'; import '../l10n/gen/app_localizations.dart'; import '../modelos/archivo_grabacion.dart'; import '../tema/pluriwave_tokens.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/Open-in-another-app/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)? abrirEnOtraApp, }) : _reproductorInyectado = reproductor, _abrirEnOtraAppInyectada = abrirEnOtraApp; final ReproductorGrabaciones? _reproductorInyectado; /// Seam for the local-open action. Was `compartir`, which handed the audio /// file to the system share sheet — redistribution of someone else's /// broadcast. It now opens the file in a player already installed on THIS /// device, and returns whether any app accepted it. final Future Function(String ruta)? _abrirEnOtraAppInyectada; @override State createState() => _PantallaGrabacionesState(); } class _PantallaGrabacionesState extends State { late final ReproductorGrabaciones _reproductor = widget._reproductorInyectado ?? _ReproductorGrabacionesJustAudio(); late final Future Function(String ruta) _abrirEnOtraApp = widget._abrirEnOtraAppInyectada ?? (ruta) => context.read().abrirGrabacion(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 == 'open') { await _abrirLocalmente(archivo); return; } if (accion == 'delete') { await _eliminar(archivo); } } /// Plays the user's own recording in another app on the same device. A /// device with no audio viewer installed (and the native side's own /// fallback to the containing folder failing too) returns `false` — the /// action then says so instead of looking like a dead menu entry. Future _abrirLocalmente(ArchivoGrabacion archivo) async { final messenger = ScaffoldMessenger.of(context); final l10n = AppLocalizations.of(context); final abierto = await _abrirEnOtraApp(archivo.ruta); if (!mounted || abierto) return; messenger.showSnackBar( SnackBar(content: Text(l10n.recordingOpenNoAppError)), ); } 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( // Audit 12.1 (t4:610): the header action is `folder_open` at // 22px, not a generic gear. icon: const Icon(Icons.folder_open_rounded, size: 22), 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), // Issue 3 (feedback-pruebas): t4:617 draws a 16px gap between // the storage card and the rows below it, not 12. const SizedBox( height: 16, key: ValueKey('grabaciones-storage-gap'), ), if (snap.connectionState == ConnectionState.done && archivos.isEmpty) PluriEmptyState( glyph: PluriIconGlyph.player, title: l10n.recordingsLibraryEmptyTitle, subtitle: l10n.recordingsLibraryEmptySubtitle, ) else // t4:618: `gap:2px` between rows. PluriPanelColumn( gap: 2, children: [ 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), ), ], ), // Production-readiness pass: recording a broadcast holds up as // a private copy, and stops holding up the moment the product // reads as a redistribution tool. The library had no such // statement at all, while the manifest already publishes the // recordings folder to the system file manager // (RecordingsDocumentsProvider). Deliberately factual and // low-key — a footnote, not a warning banner — and always // visible, empty library included. const SizedBox(height: 16), Padding( key: const ValueKey('grabaciones-aviso-uso-privado'), padding: const EdgeInsets.symmetric(horizontal: 4), child: Text( l10n.recordingsPrivateUseNotice, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of( context, ).textTheme.bodySmall?.color?.withValues(alpha: 0.7), ), ), ), ], ); }, ), ); } } 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: [ // Audit 12.2 (t4 line 613): the bold "used of total" headline // sits ABOVE the bar -- was the bar first, then this same string // rendered small below it as the only caption. Text( l10n.recordingsLibraryStorageCaption(usadoMb, totalMb), style: Theme.of( context, ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w800), ), const SizedBox(height: 8), // Audit 12.2: 6px bar, radius 3 (was minHeight 8, radius 8). ClipRRect( borderRadius: BorderRadius.circular(3), child: LinearProgressIndicator(value: fraccion, minHeight: 6), ), const SizedBox(height: 7), // Audit 12.3 (t4 line 613): the real caption names the folder // and the purge policy -- the generic "X of Y used" line moved // up to become the headline above, it never described either of // those. Text( l10n.recordingsLibraryStorageFolderCaption, 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); final theme = Theme.of(context); // Item 23 / audit 12.4 (t4:616-619): a flat, background-less row -- // 44x44/radius-12 art placeholder (recordings carry no per-station // favicon, so this is a themed fallback square, not invented artwork), // name, meta line, a 24px play/pause affordance, and the SAME "-" // menu (Rename/Open in another app/Delete) as before, just restyled. return Padding( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10), child: Row( children: [ ClipRRect( key: const ValueKey('fila-grabacion-arte'), borderRadius: BorderRadius.circular(12), child: Container( width: 44, height: 44, color: theme.colorScheme.primaryContainer, child: Icon( Icons.radio_rounded, size: 22, color: theme.colorScheme.onPrimaryContainer, ), ), ), const SizedBox(width: 12), Expanded( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ // t4:619: 14.5px/w700. Text( archivo.nombre, style: const TextStyle( fontSize: 14.5, fontWeight: FontWeight.w700, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), // t4:619: 12px/rgba(242,247,250,.55). FutureBuilder( future: duracion, builder: (context, snap) { return Text( '${formatearFecha(archivo.fecha)} · ' '${formatearDuracion(snap.data)} · ' '${formatearBytes(archivo.tamanoBytes)}', style: TextStyle( fontSize: 12, color: theme.colorScheme.onSurface.withValues( alpha: 0.55, ), ), maxLines: 1, overflow: TextOverflow.ellipsis, ); }, ), ], ), ), // t4:619: play_circle 24px brand teal in a 42x42 target. SizedBox( width: 42, height: 42, child: IconButton( padding: EdgeInsets.zero, tooltip: reproduciendo ? l10n.pauseAction : l10n.playAction, icon: Icon( reproduciendo ? Icons.pause_circle_filled_rounded : Icons.play_circle_fill_rounded, size: 24, color: PluriWaveTokens.brand, ), onPressed: onAlternarReproduccion, ), ), // t4:619: more_vert 20px/45% in a 38x42 target. SizedBox( width: 38, height: 42, child: PopupMenuButton( padding: EdgeInsets.zero, icon: Icon( Icons.more_vert_rounded, size: 20, color: theme.colorScheme.onSurface.withValues(alpha: 0.45), ), onSelected: onAccionMenu, itemBuilder: (context) => [ PopupMenuItem( value: 'rename', child: Text(l10n.recordingActionRename), ), PopupMenuItem( value: 'open', child: Text(l10n.recordingActionOpenIn), ), 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), ), ], ); } }