Revision previa al envio a produccion. Cada punto se verifico en el codigo antes de tocarlo; lo que ya estaba bien se dejo como estaba. Ubicacion: se declaraba precision fina sin usarla El unico consumidor de ubicacion pide `LocationAccuracy.low` y se queda solo con el codigo ISO del pais, asi que `ACCESS_FINE_LOCATION` no aportaba nada. Y contradecia la declaracion de Seguridad de los datos ya aprobada en Play, que dice ubicacion APROXIMADA: declarar una cosa y pedir otra es precisamente lo que se penaliza en revision. Verificado que los manifiestos de geolocator_android y geocoding_android no declaran permisos propios, asi que el merge no lo reinyecta y no hace falta `tools:node="remove"`. El plugin construye su peticion en tiempo de ejecucion a partir de lo declarado, de modo que con COARSE pide COARSE. Sin cambio funcional: la deteccion de pais sigue igual. El paywall vendia Android Auto como exclusivo, y ya no lo es La etiqueta era literalmente "Android Auto", a secas. Pero el tier gratuito recibio una carpeta navegable con emisoras reproducibles cuando hubo que cumplir las guias del coche, asi que esa frase dejo de ser cierta. Ahora dice que PRO añade el catalogo completo, favoritos, mis emisoras y musica local, y aclara que gratis tiene las destacadas. Un paywall que promete lo que el tier gratuito ya tiene expone a reclamacion y a que se cite en revision. Microfono: se pide al activar el visualizador, no antes Con una explicacion previa en los 13 idiomas, en vez de aparecer sin contexto. Grabacion: uso privado de verdad, no solo en el aviso La pantalla de grabaciones entregaba el fichero a cualquier aplicacion con `Share.shareXFiles`. La intencion era abrirlo en un reproductor del propio telefono, no redistribuirlo, y una cosa es copia privada y la otra no. Ahora usa el `openFile` que ya existia -- FileProvider + ACTION_VIEW -- y avisa cuando ningun reproductor del dispositivo puede abrirla, en vez de fallar en silencio. Se añade ademas el aviso de uso privado en esa pantalla. `recordingActionShare` la usaban DOS botones con significados distintos: el de grabaciones, que mandaba el audio, y el del reproductor, que comparte el nombre y la url de la emisora. Una clave, dos sentidos, y esa ambiguedad basto para que al leer el codigo pareciera que solo se compartian enlaces. Separadas en `stationActionShare` y `recordingActionOpenIn`. La grabacion sigue siendo PRO. Lo que reduce el riesgo es que la copia no salga del dispositivo, no regalar la funcion: los anuncios tambien son monetizacion. Suite completa: 1587 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos preexistentes.
588 lines
20 KiB
Dart
588 lines
20 KiB
Dart
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<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/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<bool> 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<bool> Function(String ruta)? _abrirEnOtraAppInyectada;
|
|
|
|
@override
|
|
State<PantallaGrabaciones> createState() => _PantallaGrabacionesState();
|
|
}
|
|
|
|
class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
|
|
late final ReproductorGrabaciones _reproductor =
|
|
widget._reproductorInyectado ?? _ReproductorGrabacionesJustAudio();
|
|
late final Future<bool> Function(String ruta) _abrirEnOtraApp =
|
|
widget._abrirEnOtraAppInyectada ??
|
|
(ruta) => context.read<EstadoGrabacion>().abrirGrabacion(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 == '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<void> _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<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),
|
|
// 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<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/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<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: '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),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|