Files
pluriwave/lib/pantallas/pantalla_grabaciones.dart
T
FreeTLab e036f99a61 fix(bienvenida,grabaciones): headline size, CTA radius, storage caption
Screen 14 / audit 14.3+14.7: welcome headline now 34px/ls-1.2 (was
headlineMedium's 28/ls-1.0), CTA restyled to a radius-18 rounded
rectangle instead of Material 3's default StadiumBorder (t4:696,715).

Screen 12 / audit 12.2+12.3: the recordings storage card now shows the
bold "X of Y used" headline ABOVE a 6px/radius-3 bar, followed by a
real folder-path + purge-policy caption below it -- was the bar first
with the "used" string as its only (small, generic) caption (t4:613).
New ARB key recordingsLibraryStorageFolderCaption, translated to all
13 locales.

Item 12.5 (folder/max-size settings living behind the header action
instead of inline on this screen) is a deliberate structural split
from WU15b; the audit itself scopes it out of this pass.
2026-07-30 16:00:30 +02:00

543 lines
18 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 '../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<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(
// 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<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
// 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),
),
],
),
],
);
},
),
);
}
}
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: [
// 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<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);
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/Share/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<Duration?>(
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<String>(
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: '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),
),
],
);
}
}