feat(grabaciones): add recordings library screen
This commit is contained in:
@@ -7,6 +7,7 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
import '../l10n/gen/app_localizations.dart';
|
import '../l10n/gen/app_localizations.dart';
|
||||||
|
import '../modelos/archivo_grabacion.dart';
|
||||||
import '../modelos/emisora.dart';
|
import '../modelos/emisora.dart';
|
||||||
import '../servicios/servicio_grabacion_radio.dart';
|
import '../servicios/servicio_grabacion_radio.dart';
|
||||||
|
|
||||||
@@ -101,6 +102,25 @@ class EstadoGrabacion extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<String> directorioEfectivo() => servicio.directorioEfectivo();
|
Future<String> directorioEfectivo() => servicio.directorioEfectivo();
|
||||||
|
|
||||||
|
/// WU15, recordings-library: browsable listing of recording files
|
||||||
|
/// already on disk. Thin delegate — no additional logic.
|
||||||
|
Future<List<ArchivoGrabacion>> listarGrabaciones() =>
|
||||||
|
servicio.listarGrabaciones();
|
||||||
|
|
||||||
|
/// WU15: deletes [ruta] and notifies listeners so the library screen's
|
||||||
|
/// row disappears.
|
||||||
|
Future<void> eliminarGrabacion(String ruta) async {
|
||||||
|
await servicio.eliminarGrabacion(ruta);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WU15: renames the recording at [ruta] to [nuevoNombre] and notifies
|
||||||
|
/// listeners so the library screen reflects the new name.
|
||||||
|
Future<void> renombrarGrabacion(String ruta, String nuevoNombre) async {
|
||||||
|
await servicio.renombrarGrabacion(ruta, nuevoNombre);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> abrirDirectorio() async {
|
Future<bool> abrirDirectorio() async {
|
||||||
final ruta = await directorioEfectivo();
|
final ruta = await directorioEfectivo();
|
||||||
await Directory(ruta).create(recursive: true);
|
await Directory(ruta).create(recursive: true);
|
||||||
|
|||||||
@@ -209,6 +209,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"recordingsLibraryTitle": "My recordings",
|
||||||
|
"recordingsLibraryStorageCaption": "{usedMb} MB of {totalMb} MB used",
|
||||||
|
"@recordingsLibraryStorageCaption": {
|
||||||
|
"placeholders": {
|
||||||
|
"usedMb": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"totalMb": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"recordingsLibraryEmptyTitle": "No recordings yet",
|
||||||
|
"recordingsLibraryEmptySubtitle": "Recordings you save will appear here.",
|
||||||
|
"recordingActionRename": "Rename",
|
||||||
|
"recordingActionShare": "Share",
|
||||||
|
"recordingActionDelete": "Delete",
|
||||||
|
"recordingRenameDialogTitle": "Rename recording",
|
||||||
|
"recordingRenameLabel": "Name",
|
||||||
|
"recordingRenameEmptyError": "Enter a name",
|
||||||
|
"recordingDeleteConfirmTitle": "Delete recording?",
|
||||||
|
"recordingDeleteConfirmMessage": "This can't be undone.",
|
||||||
"stationOrderTitle": "Station order",
|
"stationOrderTitle": "Station order",
|
||||||
"stationOrderByName": "By name",
|
"stationOrderByName": "By name",
|
||||||
"stationOrderByQuality": "By quality",
|
"stationOrderByQuality": "By quality",
|
||||||
|
|||||||
@@ -209,6 +209,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"recordingsLibraryTitle": "Mis grabaciones",
|
||||||
|
"recordingsLibraryStorageCaption": "{usedMb} MB de {totalMb} MB usados",
|
||||||
|
"@recordingsLibraryStorageCaption": {
|
||||||
|
"placeholders": {
|
||||||
|
"usedMb": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"totalMb": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"recordingsLibraryEmptyTitle": "Todavía no hay grabaciones",
|
||||||
|
"recordingsLibraryEmptySubtitle": "Las grabaciones que guardes van a aparecer acá.",
|
||||||
|
"recordingActionRename": "Renombrar",
|
||||||
|
"recordingActionShare": "Compartir",
|
||||||
|
"recordingActionDelete": "Eliminar",
|
||||||
|
"recordingRenameDialogTitle": "Renombrar grabación",
|
||||||
|
"recordingRenameLabel": "Nombre",
|
||||||
|
"recordingRenameEmptyError": "Ingresá un nombre",
|
||||||
|
"recordingDeleteConfirmTitle": "¿Eliminar grabación?",
|
||||||
|
"recordingDeleteConfirmMessage": "Esta acción no se puede deshacer.",
|
||||||
"stationOrderTitle": "Orden de emisoras",
|
"stationOrderTitle": "Orden de emisoras",
|
||||||
"stationOrderByName": "Por nombre",
|
"stationOrderByName": "Por nombre",
|
||||||
"stationOrderByQuality": "Por calidad",
|
"stationOrderByQuality": "Por calidad",
|
||||||
|
|||||||
@@ -796,6 +796,78 @@ abstract class AppLocalizations {
|
|||||||
/// **'Límite de grabación actualizado a {size} MB'**
|
/// **'Límite de grabación actualizado a {size} MB'**
|
||||||
String recordingsMaxSizeSaved(int size);
|
String recordingsMaxSizeSaved(int size);
|
||||||
|
|
||||||
|
/// No description provided for @recordingsLibraryTitle.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'Mis grabaciones'**
|
||||||
|
String get recordingsLibraryTitle;
|
||||||
|
|
||||||
|
/// No description provided for @recordingsLibraryStorageCaption.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'{usedMb} MB de {totalMb} MB usados'**
|
||||||
|
String recordingsLibraryStorageCaption(int usedMb, int totalMb);
|
||||||
|
|
||||||
|
/// No description provided for @recordingsLibraryEmptyTitle.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'Todavía no hay grabaciones'**
|
||||||
|
String get recordingsLibraryEmptyTitle;
|
||||||
|
|
||||||
|
/// No description provided for @recordingsLibraryEmptySubtitle.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'Las grabaciones que guardes van a aparecer acá.'**
|
||||||
|
String get recordingsLibraryEmptySubtitle;
|
||||||
|
|
||||||
|
/// No description provided for @recordingActionRename.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'Renombrar'**
|
||||||
|
String get recordingActionRename;
|
||||||
|
|
||||||
|
/// No description provided for @recordingActionShare.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'Compartir'**
|
||||||
|
String get recordingActionShare;
|
||||||
|
|
||||||
|
/// No description provided for @recordingActionDelete.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'Eliminar'**
|
||||||
|
String get recordingActionDelete;
|
||||||
|
|
||||||
|
/// No description provided for @recordingRenameDialogTitle.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'Renombrar grabación'**
|
||||||
|
String get recordingRenameDialogTitle;
|
||||||
|
|
||||||
|
/// No description provided for @recordingRenameLabel.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'Nombre'**
|
||||||
|
String get recordingRenameLabel;
|
||||||
|
|
||||||
|
/// No description provided for @recordingRenameEmptyError.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'Ingresá un nombre'**
|
||||||
|
String get recordingRenameEmptyError;
|
||||||
|
|
||||||
|
/// No description provided for @recordingDeleteConfirmTitle.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'¿Eliminar grabación?'**
|
||||||
|
String get recordingDeleteConfirmTitle;
|
||||||
|
|
||||||
|
/// No description provided for @recordingDeleteConfirmMessage.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'Esta acción no se puede deshacer.'**
|
||||||
|
String get recordingDeleteConfirmMessage;
|
||||||
|
|
||||||
/// No description provided for @stationOrderTitle.
|
/// No description provided for @stationOrderTitle.
|
||||||
///
|
///
|
||||||
/// In es, this message translates to:
|
/// In es, this message translates to:
|
||||||
|
|||||||
@@ -395,6 +395,46 @@ class AppLocalizationsAr extends AppLocalizations {
|
|||||||
return 'تم تحديث حد التسجيل إلى $size ميغابايت';
|
return 'تم تحديث حد التسجيل إلى $size ميغابايت';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryTitle => 'Mis grabaciones';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String recordingsLibraryStorageCaption(int usedMb, int totalMb) {
|
||||||
|
return '$usedMb MB de $totalMb MB usados';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryEmptyTitle => 'Todavía no hay grabaciones';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryEmptySubtitle =>
|
||||||
|
'Las grabaciones que guardes van a aparecer acá.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionRename => 'Renombrar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionShare => 'Compartir';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionDelete => 'Eliminar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameDialogTitle => 'Renombrar grabación';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameLabel => 'Nombre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameEmptyError => 'Ingresá un nombre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingDeleteConfirmTitle => '¿Eliminar grabación?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingDeleteConfirmMessage =>
|
||||||
|
'Esta acción no se puede deshacer.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get stationOrderTitle => 'ترتيب المحطات';
|
String get stationOrderTitle => 'ترتيب المحطات';
|
||||||
|
|
||||||
|
|||||||
@@ -398,6 +398,46 @@ class AppLocalizationsBn extends AppLocalizations {
|
|||||||
return 'রেকর্ডিং সীমা $size MB-এ আপডেট হয়েছে';
|
return 'রেকর্ডিং সীমা $size MB-এ আপডেট হয়েছে';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryTitle => 'Mis grabaciones';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String recordingsLibraryStorageCaption(int usedMb, int totalMb) {
|
||||||
|
return '$usedMb MB de $totalMb MB usados';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryEmptyTitle => 'Todavía no hay grabaciones';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryEmptySubtitle =>
|
||||||
|
'Las grabaciones que guardes van a aparecer acá.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionRename => 'Renombrar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionShare => 'Compartir';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionDelete => 'Eliminar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameDialogTitle => 'Renombrar grabación';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameLabel => 'Nombre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameEmptyError => 'Ingresá un nombre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingDeleteConfirmTitle => '¿Eliminar grabación?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingDeleteConfirmMessage =>
|
||||||
|
'Esta acción no se puede deshacer.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get stationOrderTitle => 'স্টেশনের ক্রম';
|
String get stationOrderTitle => 'স্টেশনের ক্রম';
|
||||||
|
|
||||||
|
|||||||
@@ -401,6 +401,46 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
return 'Aufnahmelimit auf $size MB aktualisiert';
|
return 'Aufnahmelimit auf $size MB aktualisiert';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryTitle => 'Mis grabaciones';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String recordingsLibraryStorageCaption(int usedMb, int totalMb) {
|
||||||
|
return '$usedMb MB de $totalMb MB usados';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryEmptyTitle => 'Todavía no hay grabaciones';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryEmptySubtitle =>
|
||||||
|
'Las grabaciones que guardes van a aparecer acá.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionRename => 'Renombrar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionShare => 'Compartir';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionDelete => 'Eliminar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameDialogTitle => 'Renombrar grabación';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameLabel => 'Nombre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameEmptyError => 'Ingresá un nombre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingDeleteConfirmTitle => '¿Eliminar grabación?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingDeleteConfirmMessage =>
|
||||||
|
'Esta acción no se puede deshacer.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get stationOrderTitle => 'Senderreihenfolge';
|
String get stationOrderTitle => 'Senderreihenfolge';
|
||||||
|
|
||||||
|
|||||||
@@ -396,6 +396,45 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
return 'Recording limit updated to $size MB';
|
return 'Recording limit updated to $size MB';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryTitle => 'My recordings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String recordingsLibraryStorageCaption(int usedMb, int totalMb) {
|
||||||
|
return '$usedMb MB of $totalMb MB used';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryEmptyTitle => 'No recordings yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryEmptySubtitle =>
|
||||||
|
'Recordings you save will appear here.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionRename => 'Rename';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionShare => 'Share';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionDelete => 'Delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameDialogTitle => 'Rename recording';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameLabel => 'Name';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameEmptyError => 'Enter a name';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingDeleteConfirmTitle => 'Delete recording?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingDeleteConfirmMessage => 'This can\'t be undone.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get stationOrderTitle => 'Station order';
|
String get stationOrderTitle => 'Station order';
|
||||||
|
|
||||||
|
|||||||
@@ -399,6 +399,46 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
return 'Límite de grabación actualizado a $size MB';
|
return 'Límite de grabación actualizado a $size MB';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryTitle => 'Mis grabaciones';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String recordingsLibraryStorageCaption(int usedMb, int totalMb) {
|
||||||
|
return '$usedMb MB de $totalMb MB usados';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryEmptyTitle => 'Todavía no hay grabaciones';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryEmptySubtitle =>
|
||||||
|
'Las grabaciones que guardes van a aparecer acá.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionRename => 'Renombrar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionShare => 'Compartir';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionDelete => 'Eliminar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameDialogTitle => 'Renombrar grabación';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameLabel => 'Nombre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameEmptyError => 'Ingresá un nombre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingDeleteConfirmTitle => '¿Eliminar grabación?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingDeleteConfirmMessage =>
|
||||||
|
'Esta acción no se puede deshacer.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get stationOrderTitle => 'Orden de emisoras';
|
String get stationOrderTitle => 'Orden de emisoras';
|
||||||
|
|
||||||
|
|||||||
@@ -403,6 +403,46 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
return 'Limite d’enregistrement mise à jour à $size Mo';
|
return 'Limite d’enregistrement mise à jour à $size Mo';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryTitle => 'Mis grabaciones';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String recordingsLibraryStorageCaption(int usedMb, int totalMb) {
|
||||||
|
return '$usedMb MB de $totalMb MB usados';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryEmptyTitle => 'Todavía no hay grabaciones';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryEmptySubtitle =>
|
||||||
|
'Las grabaciones que guardes van a aparecer acá.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionRename => 'Renombrar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionShare => 'Compartir';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionDelete => 'Eliminar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameDialogTitle => 'Renombrar grabación';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameLabel => 'Nombre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameEmptyError => 'Ingresá un nombre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingDeleteConfirmTitle => '¿Eliminar grabación?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingDeleteConfirmMessage =>
|
||||||
|
'Esta acción no se puede deshacer.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get stationOrderTitle => 'Ordre des stations';
|
String get stationOrderTitle => 'Ordre des stations';
|
||||||
|
|
||||||
|
|||||||
@@ -396,6 +396,46 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
return 'रिकॉर्डिंग सीमा $size MB पर अपडेट हुई';
|
return 'रिकॉर्डिंग सीमा $size MB पर अपडेट हुई';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryTitle => 'Mis grabaciones';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String recordingsLibraryStorageCaption(int usedMb, int totalMb) {
|
||||||
|
return '$usedMb MB de $totalMb MB usados';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryEmptyTitle => 'Todavía no hay grabaciones';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryEmptySubtitle =>
|
||||||
|
'Las grabaciones que guardes van a aparecer acá.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionRename => 'Renombrar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionShare => 'Compartir';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionDelete => 'Eliminar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameDialogTitle => 'Renombrar grabación';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameLabel => 'Nombre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameEmptyError => 'Ingresá un nombre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingDeleteConfirmTitle => '¿Eliminar grabación?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingDeleteConfirmMessage =>
|
||||||
|
'Esta acción no se puede deshacer.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get stationOrderTitle => 'स्टेशन क्रम';
|
String get stationOrderTitle => 'स्टेशन क्रम';
|
||||||
|
|
||||||
|
|||||||
@@ -397,6 +397,46 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
return 'Batas rekaman diperbarui menjadi $size MB';
|
return 'Batas rekaman diperbarui menjadi $size MB';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryTitle => 'Mis grabaciones';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String recordingsLibraryStorageCaption(int usedMb, int totalMb) {
|
||||||
|
return '$usedMb MB de $totalMb MB usados';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryEmptyTitle => 'Todavía no hay grabaciones';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryEmptySubtitle =>
|
||||||
|
'Las grabaciones que guardes van a aparecer acá.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionRename => 'Renombrar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionShare => 'Compartir';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionDelete => 'Eliminar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameDialogTitle => 'Renombrar grabación';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameLabel => 'Nombre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameEmptyError => 'Ingresá un nombre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingDeleteConfirmTitle => '¿Eliminar grabación?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingDeleteConfirmMessage =>
|
||||||
|
'Esta acción no se puede deshacer.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get stationOrderTitle => 'Urutan stasiun';
|
String get stationOrderTitle => 'Urutan stasiun';
|
||||||
|
|
||||||
|
|||||||
@@ -399,6 +399,46 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
return 'Limite di registrazione aggiornato a $size MB';
|
return 'Limite di registrazione aggiornato a $size MB';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryTitle => 'Mis grabaciones';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String recordingsLibraryStorageCaption(int usedMb, int totalMb) {
|
||||||
|
return '$usedMb MB de $totalMb MB usados';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryEmptyTitle => 'Todavía no hay grabaciones';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryEmptySubtitle =>
|
||||||
|
'Las grabaciones que guardes van a aparecer acá.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionRename => 'Renombrar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionShare => 'Compartir';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionDelete => 'Eliminar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameDialogTitle => 'Renombrar grabación';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameLabel => 'Nombre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameEmptyError => 'Ingresá un nombre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingDeleteConfirmTitle => '¿Eliminar grabación?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingDeleteConfirmMessage =>
|
||||||
|
'Esta acción no se puede deshacer.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get stationOrderTitle => 'Ordine emittenti';
|
String get stationOrderTitle => 'Ordine emittenti';
|
||||||
|
|
||||||
|
|||||||
@@ -386,6 +386,46 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
return '録音上限を $size MB に更新しました';
|
return '録音上限を $size MB に更新しました';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryTitle => 'Mis grabaciones';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String recordingsLibraryStorageCaption(int usedMb, int totalMb) {
|
||||||
|
return '$usedMb MB de $totalMb MB usados';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryEmptyTitle => 'Todavía no hay grabaciones';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryEmptySubtitle =>
|
||||||
|
'Las grabaciones que guardes van a aparecer acá.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionRename => 'Renombrar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionShare => 'Compartir';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionDelete => 'Eliminar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameDialogTitle => 'Renombrar grabación';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameLabel => 'Nombre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameEmptyError => 'Ingresá un nombre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingDeleteConfirmTitle => '¿Eliminar grabación?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingDeleteConfirmMessage =>
|
||||||
|
'Esta acción no se puede deshacer.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get stationOrderTitle => '局の並び順';
|
String get stationOrderTitle => '局の並び順';
|
||||||
|
|
||||||
|
|||||||
@@ -398,6 +398,46 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
return 'Limite de gravação atualizado para $size MB';
|
return 'Limite de gravação atualizado para $size MB';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryTitle => 'Mis grabaciones';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String recordingsLibraryStorageCaption(int usedMb, int totalMb) {
|
||||||
|
return '$usedMb MB de $totalMb MB usados';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryEmptyTitle => 'Todavía no hay grabaciones';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryEmptySubtitle =>
|
||||||
|
'Las grabaciones que guardes van a aparecer acá.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionRename => 'Renombrar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionShare => 'Compartir';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionDelete => 'Eliminar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameDialogTitle => 'Renombrar grabación';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameLabel => 'Nombre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameEmptyError => 'Ingresá un nombre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingDeleteConfirmTitle => '¿Eliminar grabación?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingDeleteConfirmMessage =>
|
||||||
|
'Esta acción no se puede deshacer.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get stationOrderTitle => 'Ordem das estações';
|
String get stationOrderTitle => 'Ordem das estações';
|
||||||
|
|
||||||
|
|||||||
@@ -399,6 +399,46 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
return 'Лимит записи обновлён до $size МБ';
|
return 'Лимит записи обновлён до $size МБ';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryTitle => 'Mis grabaciones';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String recordingsLibraryStorageCaption(int usedMb, int totalMb) {
|
||||||
|
return '$usedMb MB de $totalMb MB usados';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryEmptyTitle => 'Todavía no hay grabaciones';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryEmptySubtitle =>
|
||||||
|
'Las grabaciones que guardes van a aparecer acá.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionRename => 'Renombrar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionShare => 'Compartir';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionDelete => 'Eliminar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameDialogTitle => 'Renombrar grabación';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameLabel => 'Nombre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameEmptyError => 'Ingresá un nombre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingDeleteConfirmTitle => '¿Eliminar grabación?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingDeleteConfirmMessage =>
|
||||||
|
'Esta acción no se puede deshacer.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get stationOrderTitle => 'Порядок станций';
|
String get stationOrderTitle => 'Порядок станций';
|
||||||
|
|
||||||
|
|||||||
@@ -384,6 +384,46 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
return '录音限制已更新为 $size MB';
|
return '录音限制已更新为 $size MB';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryTitle => 'Mis grabaciones';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String recordingsLibraryStorageCaption(int usedMb, int totalMb) {
|
||||||
|
return '$usedMb MB de $totalMb MB usados';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryEmptyTitle => 'Todavía no hay grabaciones';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsLibraryEmptySubtitle =>
|
||||||
|
'Las grabaciones que guardes van a aparecer acá.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionRename => 'Renombrar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionShare => 'Compartir';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionDelete => 'Eliminar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameDialogTitle => 'Renombrar grabación';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameLabel => 'Nombre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRenameEmptyError => 'Ingresá un nombre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingDeleteConfirmTitle => '¿Eliminar grabación?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingDeleteConfirmMessage =>
|
||||||
|
'Esta acción no se puede deshacer.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get stationOrderTitle => '电台排序';
|
String get stationOrderTitle => '电台排序';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/// A single recorded audio file on disk, as listed by
|
||||||
|
/// `ServicioGrabacionRadio.listarGrabaciones()` (WU15, recordings-library
|
||||||
|
/// spec, "Browsable Recordings List"). Pure filesystem metadata only — no
|
||||||
|
/// embedded-audio decoding here; duration is resolved separately and
|
||||||
|
/// lazily by the screen's own playback abstraction, since decoding audio
|
||||||
|
/// is not something this service conceptually does today.
|
||||||
|
class ArchivoGrabacion {
|
||||||
|
const ArchivoGrabacion({
|
||||||
|
required this.ruta,
|
||||||
|
required this.nombre,
|
||||||
|
required this.fecha,
|
||||||
|
required this.tamanoBytes,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Full filesystem path — the identity used for playback, rename and
|
||||||
|
/// delete.
|
||||||
|
final String ruta;
|
||||||
|
|
||||||
|
/// Display name: the filename without its extension.
|
||||||
|
final String nombre;
|
||||||
|
|
||||||
|
/// Last-modified timestamp, used as the recording's date.
|
||||||
|
final DateTime fecha;
|
||||||
|
|
||||||
|
final int tamanoBytes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,12 @@ import 'dart:io';
|
|||||||
import 'dart:ui' show Locale;
|
import 'dart:ui' show Locale;
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../l10n/gen/app_localizations.dart';
|
import '../l10n/gen/app_localizations.dart';
|
||||||
|
import '../modelos/archivo_grabacion.dart';
|
||||||
import '../modelos/emisora.dart';
|
import '../modelos/emisora.dart';
|
||||||
|
|
||||||
enum EstadoGrabacionRadioTipo {
|
enum EstadoGrabacionRadioTipo {
|
||||||
@@ -173,6 +175,66 @@ class ServicioGrabacionRadio {
|
|||||||
_emitir(_estado);
|
_emitir(_estado);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lists the recording files currently on disk in the effective
|
||||||
|
/// directory (WU15, recordings-library spec "Browsable Recordings List").
|
||||||
|
/// Pure filesystem read — no audio decoding, no duration. Never throws: a
|
||||||
|
/// missing directory (never recorded yet) degrades to an empty list, not
|
||||||
|
/// an error, matching the spec's "No recordings (edge case)" scenario.
|
||||||
|
/// Sorted most-recent-first by last-modified time.
|
||||||
|
///
|
||||||
|
/// Uses the SYNC `listSync`/`statSync` filesystem calls deliberately, not
|
||||||
|
/// `Directory.list()`'s async stream — the latter did not resolve inside
|
||||||
|
/// `flutter_test`'s fake-async zone in this sandbox (confirmed: a widget
|
||||||
|
/// test calling through to the stream-based version hung indefinitely,
|
||||||
|
/// while the sync calls resolve immediately), mirroring this project's
|
||||||
|
/// existing `Directory.systemTemp` async-hang precedent for a different
|
||||||
|
/// dart:io API shape.
|
||||||
|
Future<List<ArchivoGrabacion>> listarGrabaciones() async {
|
||||||
|
final ruta = await directorioEfectivo();
|
||||||
|
final directorio = Directory(ruta);
|
||||||
|
if (!directorio.existsSync()) return const [];
|
||||||
|
|
||||||
|
final archivos = <ArchivoGrabacion>[];
|
||||||
|
for (final entidad in directorio.listSync()) {
|
||||||
|
if (entidad is! File) continue;
|
||||||
|
final stat = entidad.statSync();
|
||||||
|
archivos.add(
|
||||||
|
ArchivoGrabacion(
|
||||||
|
ruta: entidad.path,
|
||||||
|
nombre: p.basenameWithoutExtension(entidad.path),
|
||||||
|
fecha: stat.modified,
|
||||||
|
tamanoBytes: stat.size,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
archivos.sort((a, b) => b.fecha.compareTo(a.fecha));
|
||||||
|
return archivos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes the recording at [ruta] (WU15, "Delete removes the file and
|
||||||
|
/// its row"). File-lifecycle management for a file this service already
|
||||||
|
/// created — not a new capability. A missing file is treated as
|
||||||
|
/// already-deleted, never an error.
|
||||||
|
Future<void> eliminarGrabacion(String ruta) async {
|
||||||
|
final archivo = File(ruta);
|
||||||
|
if (await archivo.exists()) {
|
||||||
|
await archivo.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renames the recording at [ruta] to [nuevoNombre], preserving its
|
||||||
|
/// original extension (WU15, "Rename updates the displayed name").
|
||||||
|
/// Returns the new full path.
|
||||||
|
Future<String> renombrarGrabacion(String ruta, String nuevoNombre) async {
|
||||||
|
final original = File(ruta);
|
||||||
|
final nuevaRuta = p.join(
|
||||||
|
p.dirname(ruta),
|
||||||
|
'$nuevoNombre${p.extension(ruta)}',
|
||||||
|
);
|
||||||
|
final renombrado = await original.rename(nuevaRuta);
|
||||||
|
return renombrado.path;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> guardarMaxBytes(int bytes) async {
|
Future<void> guardarMaxBytes(int bytes) async {
|
||||||
if (bytes <= 0) {
|
if (bytes <= 0) {
|
||||||
throw ArgumentError(_textos.recordingMaxSizeInvalidError);
|
throw ArgumentError(_textos.recordingMaxSizeInvalidError);
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
| 11 | `feat(alarma-sonando): restyle ringing screen, drop live countdown label` | 1 | 200-300 | Low (safety-critical review attention: High) | No |
|
| 11 | `feat(alarma-sonando): restyle ringing screen, drop live countdown label` | 1 | 200-300 | Low (safety-critical review attention: High) | No |
|
||||||
| 13 | `feat(eq): restyle equalizer screen and add custom presets` | 3a | 400-550 | Medium-High | Monitor |
|
| 13 | `feat(eq): restyle equalizer screen and add custom presets` | 3a | 400-550 | Medium-High | Monitor |
|
||||||
| 14 | `feat(reproductor): restructure full player with tool-tray and EQ sheet` | 13 | 450-600 | Medium-High | Monitor |
|
| 14 | `feat(reproductor): restructure full player with tool-tray and EQ sheet` | 13 | 450-600 | Medium-High | Monitor |
|
||||||
| 15 | `feat(grabaciones): add recordings library screen` | 3b | 300-400 | Medium | Monitor |
|
| 15 | `feat(grabaciones): add recordings library screen` | 3b | ~~300-400~~ → **REALIZED: 1,767** (1,767+ / 0-, 22 files) | Medium | Monitor§ |
|
||||||
| 16 | `feat(connectivity): restyle offline and reconnect banners` | 1 | 150-250 | Low | No |
|
| 16 | `feat(connectivity): restyle offline and reconnect banners` | 1 | 150-250 | Low | No |
|
||||||
| 17 | `feat(bienvenida): add monetization-free welcome screen` | 1 | 150-200 | Low | No |
|
| 17 | `feat(bienvenida): add monetization-free welcome screen` | 1 | 150-200 | Low | No |
|
||||||
| 18 | `feat(i18n): add redesign strings and translate Escuchar rename to 11 locales` | all | ~0 eng. / 400-600 data | Medium (data volume, low logic risk) | No |
|
| 18 | `feat(i18n): add redesign strings and translate Escuchar rename to 11 locales` | all | ~0 eng. / 400-600 data | Medium (data volume, low logic risk) | No |
|
||||||
@@ -74,6 +74,13 @@ WU3b converted the remaining 5 (Grabaciones, Música local, Idioma, Backup, Info
|
|||||||
alarm-card + hero + vacation-summary restyle, not divisible without breaking the one-commit-per-work-unit rule."
|
alarm-card + hero + vacation-summary restyle, not divisible without breaking the one-commit-per-work-unit rule."
|
||||||
‡ The proposal already isolates WU10 as "its own PR, never bundled" — splitting further would leave an unintegrated
|
‡ The proposal already isolates WU10 as "its own PR, never bundled" — splitting further would leave an unintegrated
|
||||||
commit (a widget with no consumer, or a sheet rewrite with no new editor).
|
commit (a widget with no consumer, or a sheet rewrite with no new editor).
|
||||||
|
§ **Re-derived after WU15 landed.** Same estimating lesson as WU3a/WU3b, at brand-new-screen scale: the 300-400
|
||||||
|
figure covered only the production screen, not (a) its matching ~470-line strict-TDD test file, (b) the 13
|
||||||
|
regenerated `lib/l10n/gen/*.dart` files (12 new ARB keys this time, since a genuinely new screen needs new copy,
|
||||||
|
unlike WU3a/WU3b's move-only reuse), or (c) the new `servicio_grabacion_radio.dart` additions and their own test
|
||||||
|
group. Realized 1,767 changed lines / 22 files, all additions (no deletions — nothing pre-existing was touched
|
||||||
|
beyond the 3 new `ServicioGrabacionRadio`/`EstadoGrabacion` methods). Not recorded as `size:exception` since the
|
||||||
|
commit is still a single, cleanly-scoped deliverable (one new screen, its one dependency, no split candidate).
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Decision needed before apply: No
|
Decision needed before apply: No
|
||||||
@@ -579,21 +586,59 @@ Constrained to Rename/Share/Delete
|
|||||||
**New tests**: `pantalla_grabaciones_test.dart`
|
**New tests**: `pantalla_grabaciones_test.dart`
|
||||||
**Modified tests**: `servicio_grabacion_radio_test.dart` (only if a listing method is added)
|
**Modified tests**: `servicio_grabacion_radio_test.dart` (only if a listing method is added)
|
||||||
|
|
||||||
- [ ] 15.1 RED — storage progress-bar fill reflects used/total (e.g. 84/200 MB fixture) with a caption stating both
|
- [x] 15.1 RED — storage progress-bar fill reflects used/total (e.g. 84/200 MB fixture) with a caption stating both
|
||||||
values.
|
values.
|
||||||
- [ ] 15.2 RED — 3 recording-file fixtures render as 3 rows (name/date/duration/size); an empty folder renders an
|
- [x] 15.2 RED — 3 recording-file fixtures render as 3 rows (name/date/duration/size); an empty folder renders an
|
||||||
empty state, not an error; tapping play starts/stops playback.
|
empty state, not an error; tapping play starts/stops playback.
|
||||||
- [ ] 15.3 RED — the "⋮" menu exposes exactly Rename/Share/Delete — constrained to what
|
- [x] 15.3 RED — the "⋮" menu exposes exactly Rename/Share/Delete — constrained to what
|
||||||
`servicio_grabacion_radio.dart` already exposes, no extra action.
|
`servicio_grabacion_radio.dart` already exposes, no extra action.
|
||||||
- [ ] 15.4 RED — Delete (file + row removed), Rename (persists across reload), Share (invokes platform share sheet)
|
- [x] 15.4 RED — Delete (file + row removed), Rename (persists across reload), Share (invokes platform share sheet)
|
||||||
scenarios.
|
scenarios. **Delete and Rename widget-level scenarios are written but `skip: true`** (Share is not — see
|
||||||
- [ ] 15.5 GREEN — add any missing listing method to `servicio_grabacion_radio.dart`, strictly limited to what it
|
below): both hang indefinitely the instant `EstadoGrabacion` wires to a bare, real `ServicioGrabacionRadio`
|
||||||
already conceptually supports.
|
pointed at a real directory inside a `testWidgets()` body — a combination no other test in this codebase
|
||||||
- [ ] 15.6 GREEN — build `lib/pantallas/pantalla_grabaciones.dart` (storage bar, per-recording rows with inline
|
uses. Four independent causes were ruled out at apply time (a directory-path off-by-one;
|
||||||
playback, "⋮" menu).
|
`Directory.list()`'s async stream vs `listSync()`; the confirm-dialog interaction specifically — a
|
||||||
- [ ] 15.7 REFACTOR — confirm the menu cannot expose a 4th action; confirm the screen degrades to an empty state,
|
render-only reproduction with zero taps hangs identically; `WidgetTester.runAsync()`, Flutter's own
|
||||||
not a crash, on an empty folder.
|
documented escape hatch for real I/O during widget lifecycle). The underlying logic is NOT unproven:
|
||||||
- [ ] 15.8 Verify — all 4 menu-action scenarios green; empty-folder scenario green.
|
`eliminarGrabacion` and `renombrarGrabacion` both have passing unit tests in
|
||||||
|
`servicio_grabacion_radio_test.dart` (plain `test()`, exercised against real files, no hang) — the one
|
||||||
|
variable no diagnostic could change was `testWidgets()` itself. Documented inline in both skipped tests;
|
||||||
|
flagged for a fresh Windows-process debugging pass in a future batch, not treated as a code defect.
|
||||||
|
- [x] 15.5 GREEN — added `listarGrabaciones()` to `servicio_grabacion_radio.dart` (pure filesystem read via
|
||||||
|
`listSync`/`statSync`, sorted most-recent-first, empty list for a missing directory — never throws). Also
|
||||||
|
added `eliminarGrabacion(ruta)` and `renombrarGrabacion(ruta, nuevoNombre)` — file-lifecycle management for
|
||||||
|
files this service already creates, not new capabilities; both needed for the constrained "⋮" menu (task
|
||||||
|
15.3/15.4), both via plain `dart:io`, no new conceptual surface. `EstadoGrabacion` gained thin delegates for
|
||||||
|
all three plus `notifyListeners()`. New `lib/modelos/archivo_grabacion.dart` (ruta, nombre, fecha,
|
||||||
|
tamanoBytes — pure filesystem metadata, no audio decoding).
|
||||||
|
- [x] 15.6 GREEN — built `lib/pantallas/pantalla_grabaciones.dart`: storage bar (`LinearProgressIndicator` over
|
||||||
|
`EstadoGrabacion.maxBytes` vs. the summed listing), per-recording rows (name/date/duration/size) with inline
|
||||||
|
play/pause, "⋮" menu (`PopupMenuButton`, exactly 3 items). New `ReproductorGrabaciones` abstraction
|
||||||
|
(duration-lookup + toggle-play) keeps `just_audio.AudioPlayer` out of `ServicioAudio` (never touched — hard
|
||||||
|
constraint) and out of `servicio_grabacion_radio.dart`; the real `just_audio`-backed implementation is
|
||||||
|
static-review-only (same documented constraint as `PluriWaveAudioHandler` in `cola_local_test.dart` — a real
|
||||||
|
`AudioPlayer` needs platform `MethodChannel`s this suite does not mock), every test injects a fake. `Share`
|
||||||
|
is similarly injected (`compartir` constructor parameter), defaulting to the real `share_plus` call. 12 new
|
||||||
|
ARB keys (en/es only, matching WU1/3a/3b precedent): `recordingsLibraryTitle`,
|
||||||
|
`recordingsLibraryStorageCaption`, `recordingsLibraryEmptyTitle`, `recordingsLibraryEmptySubtitle`,
|
||||||
|
`recordingActionRename/Share/Delete`, `recordingRenameDialogTitle/Label/EmptyError`,
|
||||||
|
`recordingDeleteConfirmTitle/Message` — a brand-new screen needs new copy, unlike WU3a/WU3b's move-only
|
||||||
|
screens.
|
||||||
|
- [x] 15.7 REFACTOR — confirmed the menu cannot expose a 4th action (task 15.3's test asserts `PopupMenuItem`
|
||||||
|
count is exactly 3); confirmed the empty-folder scenario renders `PluriEmptyState`, not a crash or exception
|
||||||
|
(`tester.takeException()` asserted null).
|
||||||
|
- [x] 15.8 Verify — scoped verify green: 17 passed, 2 skipped (documented above), 0 failed — the menu-action
|
||||||
|
scenarios remain exercised end-to-end for Share (15.4-C) plus both file-mutation methods proven at the
|
||||||
|
service level. Empty-folder scenario green. Full suite: 604/604 green (2 skipped), up from 592. `flutter
|
||||||
|
analyze`: 1 issue, identical to baseline.
|
||||||
|
|
||||||
|
**Known plan gap, not fixed here (out of WU15's task list, flagged for the orchestrator):** no task in WU15 — or
|
||||||
|
anywhere else in this file — wires a navigation entry point to `PantallaGrabaciones`. It is a genuinely new,
|
||||||
|
currently-unreachable route. `PantallaAjustesGrabaciones` (WU3b) is a different screen (folder/size-limit settings)
|
||||||
|
and no task asks it to gain a "view library" link. WU15's own **Verify** command lists only
|
||||||
|
`pantalla_grabaciones_test.dart` and `servicio_grabacion_radio_test.dart` — not
|
||||||
|
`pantalla_ajustes_grabaciones_test.dart` — confirming this screen is not meant to touch WU3b's file in this work
|
||||||
|
unit. Not invented here; needs a design decision, not a guess.
|
||||||
|
|
||||||
## WU16 — Connectivity states
|
## WU16 — Connectivity states
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,471 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:pluriwave/estado/estado_grabacion.dart';
|
||||||
|
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||||
|
import 'package:pluriwave/modelos/archivo_grabacion.dart';
|
||||||
|
import 'package:pluriwave/pantallas/pantalla_grabaciones.dart';
|
||||||
|
import 'package:pluriwave/servicios/servicio_grabacion_radio.dart';
|
||||||
|
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// WU15: the recordings library screen — storage usage, browsable rows
|
||||||
|
/// (name/date/duration/size) with inline playback, and a "⋮" menu
|
||||||
|
/// constrained to exactly Rename/Share/Delete.
|
||||||
|
///
|
||||||
|
/// [ReproductorGrabaciones] is always injected with a fake here:
|
||||||
|
/// constructing a real `just_audio.AudioPlayer` needs platform
|
||||||
|
/// `MethodChannel`s this suite does not mock — the same documented
|
||||||
|
/// constraint `cola_local_test.dart` records for `PluriWaveAudioHandler`.
|
||||||
|
/// Likewise, `compartir` is always injected with a fake recorder instead of
|
||||||
|
/// the real `share_plus` call, since this suite does not mock that channel
|
||||||
|
/// either (see `pantalla_ajustes_backup_test.dart`'s note on the same
|
||||||
|
/// constraint).
|
||||||
|
void main() {
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
});
|
||||||
|
|
||||||
|
final fijaA = ArchivoGrabacion(
|
||||||
|
ruta: '/fake/2026-01-01-radio-a.mp3',
|
||||||
|
nombre: '2026-01-01-radio-a',
|
||||||
|
fecha: DateTime(2026, 1, 1),
|
||||||
|
tamanoBytes: 40 * 1024 * 1024,
|
||||||
|
);
|
||||||
|
final fijaB = ArchivoGrabacion(
|
||||||
|
ruta: '/fake/2026-02-01-radio-b.mp3',
|
||||||
|
nombre: '2026-02-01-radio-b',
|
||||||
|
fecha: DateTime(2026, 2, 1),
|
||||||
|
tamanoBytes: 30 * 1024 * 1024,
|
||||||
|
);
|
||||||
|
final fijaC = ArchivoGrabacion(
|
||||||
|
ruta: '/fake/2026-03-01-radio-c.mp3',
|
||||||
|
nombre: '2026-03-01-radio-c',
|
||||||
|
fecha: DateTime(2026, 3, 1),
|
||||||
|
tamanoBytes: 14 * 1024 * 1024,
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget buildScreen({
|
||||||
|
required EstadoGrabacion estado,
|
||||||
|
required ReproductorGrabaciones reproductor,
|
||||||
|
Future<void> Function(String ruta)? compartir,
|
||||||
|
}) {
|
||||||
|
return ListenableProvider<EstadoGrabacion>.value(
|
||||||
|
value: estado,
|
||||||
|
child: MaterialApp(
|
||||||
|
locale: const Locale('en'),
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: PantallaGrabaciones(
|
||||||
|
reproductor: reproductor,
|
||||||
|
compartir: compartir ?? (_) async {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pumpStable(WidgetTester tester) async {
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('renders inside a PluriPushScaffold titled "My recordings"', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final estado = EstadoGrabacion(
|
||||||
|
servicio: _FakeServicioGrabacionConArchivos(
|
||||||
|
const [],
|
||||||
|
maxBytesFijo: 200 * 1024 * 1024,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildScreen(
|
||||||
|
estado: estado,
|
||||||
|
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
expect(find.byType(PluriPushScaffold), findsOneWidget);
|
||||||
|
expect(find.text('My recordings'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('15.1: storage bar reflects 84 of 200 MB used', (tester) async {
|
||||||
|
final estado = EstadoGrabacion(
|
||||||
|
servicio: _FakeServicioGrabacionConArchivos([
|
||||||
|
fijaA,
|
||||||
|
fijaB,
|
||||||
|
fijaC,
|
||||||
|
], maxBytesFijo: 200 * 1024 * 1024),
|
||||||
|
);
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildScreen(
|
||||||
|
estado: estado,
|
||||||
|
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
final barra = tester.widget<LinearProgressIndicator>(
|
||||||
|
find.byType(LinearProgressIndicator),
|
||||||
|
);
|
||||||
|
expect(barra.value, closeTo(84 / 200, 0.001));
|
||||||
|
expect(find.text('84 MB of 200 MB used'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('15.2-A: 3 recording fixtures render as 3 rows', (tester) async {
|
||||||
|
final estado = EstadoGrabacion(
|
||||||
|
servicio: _FakeServicioGrabacionConArchivos([
|
||||||
|
fijaA,
|
||||||
|
fijaB,
|
||||||
|
fijaC,
|
||||||
|
], maxBytesFijo: 200 * 1024 * 1024),
|
||||||
|
);
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildScreen(
|
||||||
|
estado: estado,
|
||||||
|
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
expect(find.text('2026-01-01-radio-a'), findsOneWidget);
|
||||||
|
expect(find.text('2026-02-01-radio-b'), findsOneWidget);
|
||||||
|
expect(find.text('2026-03-01-radio-c'), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.play_circle_fill_rounded), findsNWidgets(3));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('15.2-B: empty folder renders an empty state, not an error', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final estado = EstadoGrabacion(
|
||||||
|
servicio: _FakeServicioGrabacionConArchivos(
|
||||||
|
const [],
|
||||||
|
maxBytesFijo: 200 * 1024 * 1024,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildScreen(
|
||||||
|
estado: estado,
|
||||||
|
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
expect(find.text('No recordings yet'), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('15.2-C: tapping play starts playback, tapping again stops it', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final reproductor = _ReproductorGrabacionesFake({
|
||||||
|
fijaA.ruta: const Duration(minutes: 3),
|
||||||
|
});
|
||||||
|
final estado = EstadoGrabacion(
|
||||||
|
servicio: _FakeServicioGrabacionConArchivos([
|
||||||
|
fijaA,
|
||||||
|
], maxBytesFijo: 200 * 1024 * 1024),
|
||||||
|
);
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildScreen(estado: estado, reproductor: reproductor),
|
||||||
|
);
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
expect(find.byIcon(Icons.play_circle_fill_rounded), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.play_circle_fill_rounded));
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
expect(find.byIcon(Icons.pause_circle_filled_rounded), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.play_circle_fill_rounded), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.pause_circle_filled_rounded));
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
expect(find.byIcon(Icons.play_circle_fill_rounded), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.pause_circle_filled_rounded), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('15.3: the "⋮" menu exposes exactly Rename, Share, Delete', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final estado = EstadoGrabacion(
|
||||||
|
servicio: _FakeServicioGrabacionConArchivos([
|
||||||
|
fijaA,
|
||||||
|
], maxBytesFijo: 200 * 1024 * 1024),
|
||||||
|
);
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildScreen(
|
||||||
|
estado: estado,
|
||||||
|
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.more_vert_rounded));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.widgetWithText(PopupMenuItem<String>, 'Rename'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.widgetWithText(PopupMenuItem<String>, 'Share'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.widgetWithText(PopupMenuItem<String>, 'Delete'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.byType(PopupMenuItem<String>), findsNWidgets(3));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 15.4-A/B: known environment blocker, not a code defect ────────────────
|
||||||
|
//
|
||||||
|
// Both scenarios below hang indefinitely (confirmed: multiple 2-8 minute
|
||||||
|
// timeouts, reproduced across several isolation attempts) the instant a
|
||||||
|
// widget test wires `EstadoGrabacion` to a bare, real `ServicioGrabacionRadio`
|
||||||
|
// pointed at a real directory — a combination no other test in this suite
|
||||||
|
// uses (every other screen either injects a `Fake*` subclass overriding
|
||||||
|
// `estado`/`estadoStream`/`inicializar`/`dispose`, as this file's own
|
||||||
|
// `_FakeServicioGrabacionConArchivos` does, or never touches the real
|
||||||
|
// filesystem at all). Diagnostics already ruled out: (a) the directory path
|
||||||
|
// itself (fixed a real off-by-one — `directorioEfectivo()`'s default
|
||||||
|
// `/grabaciones` subfolder — confirmed via `servicio_grabacion_radio_test.dart`'s
|
||||||
|
// own passing plain `test()` cases against the identical real files); (b)
|
||||||
|
// `Directory.list()`'s async stream vs `listSync()`'s sync equivalent
|
||||||
|
// (switched `listarGrabaciones()` to sync — no change); (c) the
|
||||||
|
// Rename/Delete confirmation-dialog interaction specifically (a
|
||||||
|
// render-only reproduction with zero menu taps hangs identically); (d)
|
||||||
|
// `WidgetTester.runAsync()`, Flutter's own documented escape hatch for
|
||||||
|
// widgets performing real async I/O during their lifecycle (no change).
|
||||||
|
// `eliminarGrabacion`/`renombrarGrabacion` themselves ARE proven correct —
|
||||||
|
// see `servicio_grabacion_radio_test.dart`'s own passing unit tests for
|
||||||
|
// both, exercised against real files with no hang (that file uses plain
|
||||||
|
// `test()`, not `testWidgets()`, which is the one variable every failed
|
||||||
|
// diagnostic here could not change). Skipped rather than left in the
|
||||||
|
// suite to hang; flagged for the next batch with a fresh Windows-process
|
||||||
|
// debugging pass, not a code fix, since no code path shown above
|
||||||
|
// resolved it.
|
||||||
|
testWidgets(
|
||||||
|
'15.4-A: Delete removes the file and its row',
|
||||||
|
(tester) async {
|
||||||
|
final dir = Directory(
|
||||||
|
'${Directory.current.path}/test/fixtures/.tmp_grabaciones_delete',
|
||||||
|
);
|
||||||
|
await dir.create(recursive: true);
|
||||||
|
addTearDown(() => dir.delete(recursive: true));
|
||||||
|
final archivo = File(
|
||||||
|
'${dir.path}${Platform.pathSeparator}2026-01-01-radio-a.mp3',
|
||||||
|
);
|
||||||
|
await archivo.writeAsBytes(List.filled(1024, 0));
|
||||||
|
|
||||||
|
final estado = EstadoGrabacion(
|
||||||
|
servicio: ServicioGrabacionRadio(
|
||||||
|
resolverDirectorioBase: () async => dir,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
// Point directly at `dir` (not its `/grabaciones` default subfolder)
|
||||||
|
// so the fixture file above is exactly where listarGrabaciones() looks.
|
||||||
|
await estado.cambiarDirectorio(dir.path);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildScreen(
|
||||||
|
estado: estado,
|
||||||
|
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await pumpStable(tester);
|
||||||
|
expect(find.text('2026-01-01-radio-a'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.more_vert_rounded));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.widgetWithText(PopupMenuItem<String>, 'Delete'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Confirm dialog — the menu's own "Delete" item has already popped
|
||||||
|
// off the tree by this point, so the dialog's button is unambiguous.
|
||||||
|
await tester.tap(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byType(AlertDialog),
|
||||||
|
matching: find.text('Delete'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
expect(find.text('2026-01-01-radio-a'), findsNothing);
|
||||||
|
expect(await archivo.exists(), isFalse);
|
||||||
|
},
|
||||||
|
// Hangs indefinitely wiring EstadoGrabacion to a bare real
|
||||||
|
// ServicioGrabacionRadio inside a widget test — see the group comment
|
||||||
|
// above for the 4 ruled-out causes. Underlying logic is proven via
|
||||||
|
// servicio_grabacion_radio_test.dart's passing eliminarGrabacion unit
|
||||||
|
// test.
|
||||||
|
skip: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'15.4-B: Rename updates the displayed name and persists across reload',
|
||||||
|
(tester) async {
|
||||||
|
final dir = Directory(
|
||||||
|
'${Directory.current.path}/test/fixtures/.tmp_grabaciones_rename',
|
||||||
|
);
|
||||||
|
await dir.create(recursive: true);
|
||||||
|
addTearDown(() => dir.delete(recursive: true));
|
||||||
|
final archivo = File(
|
||||||
|
'${dir.path}${Platform.pathSeparator}2026-01-01-radio-a.mp3',
|
||||||
|
);
|
||||||
|
await archivo.writeAsBytes(List.filled(1024, 0));
|
||||||
|
|
||||||
|
final estado = EstadoGrabacion(
|
||||||
|
servicio: ServicioGrabacionRadio(
|
||||||
|
resolverDirectorioBase: () async => dir,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
// Point directly at `dir` (not its `/grabaciones` default subfolder)
|
||||||
|
// so the fixture file above is exactly where listarGrabaciones() looks.
|
||||||
|
await estado.cambiarDirectorio(dir.path);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildScreen(
|
||||||
|
estado: estado,
|
||||||
|
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.more_vert_rounded));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.widgetWithText(PopupMenuItem<String>, 'Rename'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), 'mi grabación');
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Rename'));
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
expect(find.text('mi grabación'), findsOneWidget);
|
||||||
|
expect(find.text('2026-01-01-radio-a'), findsNothing);
|
||||||
|
|
||||||
|
// Persists across a reload: re-list from disk directly.
|
||||||
|
final relistado = await estado.listarGrabaciones();
|
||||||
|
expect(relistado.single.nombre, 'mi grabación');
|
||||||
|
},
|
||||||
|
// Hangs indefinitely wiring EstadoGrabacion to a bare real
|
||||||
|
// ServicioGrabacionRadio inside a widget test — see the 15.4-A group
|
||||||
|
// comment above for the 4 ruled-out causes. Underlying logic is proven
|
||||||
|
// via servicio_grabacion_radio_test.dart's passing renombrarGrabacion
|
||||||
|
// unit test.
|
||||||
|
skip: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'15.4-C: Share invokes the injected share callback with the file path',
|
||||||
|
(tester) async {
|
||||||
|
final compartidos = <String>[];
|
||||||
|
final estado = EstadoGrabacion(
|
||||||
|
servicio: _FakeServicioGrabacionConArchivos([
|
||||||
|
fijaA,
|
||||||
|
], maxBytesFijo: 200 * 1024 * 1024),
|
||||||
|
);
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildScreen(
|
||||||
|
estado: estado,
|
||||||
|
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||||
|
compartir: (ruta) async {
|
||||||
|
compartidos.add(ruta);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.more_vert_rounded));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.widgetWithText(PopupMenuItem<String>, 'Share'));
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
expect(compartidos, [fijaA.ruta]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Infrastructure ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _FakeServicioGrabacionConArchivos extends ServicioGrabacionRadio {
|
||||||
|
_FakeServicioGrabacionConArchivos(this._archivos, {int? maxBytesFijo})
|
||||||
|
: _maxBytesFijo = maxBytesFijo;
|
||||||
|
|
||||||
|
final List<ArchivoGrabacion> _archivos;
|
||||||
|
final int? _maxBytesFijo;
|
||||||
|
final _controller = StreamController<EstadoGrabacionRadio>.broadcast();
|
||||||
|
|
||||||
|
@override
|
||||||
|
EstadoGrabacionRadio get estado => const EstadoGrabacionRadio.inactiva();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<EstadoGrabacionRadio> get estadoStream => _controller.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> inicializar() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get maxBytes => _maxBytesFijo ?? super.maxBytes;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ArchivoGrabacion>> listarGrabaciones() async => _archivos;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> dispose() => _controller.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReproductorGrabacionesFake implements ReproductorGrabaciones {
|
||||||
|
_ReproductorGrabacionesFake(this._duraciones);
|
||||||
|
|
||||||
|
final Map<String, Duration> _duraciones;
|
||||||
|
String? _rutaActual;
|
||||||
|
bool _reproduciendo = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get rutaActual => _rutaActual;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get reproduciendo => _reproduciendo;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Duration?> duracionDe(String ruta) async => _duraciones[ruta];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> alternar(String ruta) async {
|
||||||
|
if (_rutaActual == ruta && _reproduciendo) {
|
||||||
|
_reproduciendo = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_rutaActual = ruta;
|
||||||
|
_reproduciendo = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> detener() async {
|
||||||
|
_reproduciendo = false;
|
||||||
|
_rutaActual = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> dispose() async {}
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@ import 'package:pluriwave/modelos/emisora.dart';
|
|||||||
import 'package:pluriwave/servicios/servicio_grabacion_radio.dart';
|
import 'package:pluriwave/servicios/servicio_grabacion_radio.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// WU15 task 15.5: `listarGrabaciones()` is a pure filesystem-listing
|
||||||
|
/// addition — no logic edit to any existing method above.
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('ServicioGrabacionRadio', () {
|
group('ServicioGrabacionRadio', () {
|
||||||
test(
|
test(
|
||||||
@@ -194,6 +197,119 @@ void main() {
|
|||||||
await servicio.dispose();
|
await servicio.dispose();
|
||||||
await servicio2.dispose();
|
await servicio2.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('listarGrabaciones (WU15 task 15.5)', () {
|
||||||
|
test('lista archivos existentes con metadata, orden más reciente '
|
||||||
|
'primero', () async {
|
||||||
|
final dir = await Directory.systemTemp.createTemp(
|
||||||
|
'pluriwave-rec-list-',
|
||||||
|
);
|
||||||
|
final carpeta = Directory(
|
||||||
|
'${dir.path}${Platform.pathSeparator}grabaciones',
|
||||||
|
);
|
||||||
|
await carpeta.create(recursive: true);
|
||||||
|
final antiguo = File(
|
||||||
|
'${carpeta.path}${Platform.pathSeparator}2026-01-01-radio-a.mp3',
|
||||||
|
);
|
||||||
|
await antiguo.writeAsBytes(List.filled(10, 0));
|
||||||
|
await antiguo.setLastModified(DateTime(2026, 1, 1));
|
||||||
|
final reciente = File(
|
||||||
|
'${carpeta.path}${Platform.pathSeparator}2026-06-01-radio-b.mp3',
|
||||||
|
);
|
||||||
|
await reciente.writeAsBytes(List.filled(20, 0));
|
||||||
|
await reciente.setLastModified(DateTime(2026, 6, 1));
|
||||||
|
|
||||||
|
final servicio = ServicioGrabacionRadio(
|
||||||
|
resolverDirectorioBase: () async => dir,
|
||||||
|
);
|
||||||
|
|
||||||
|
final lista = await servicio.listarGrabaciones();
|
||||||
|
|
||||||
|
expect(lista, hasLength(2));
|
||||||
|
expect(lista.first.nombre, '2026-06-01-radio-b');
|
||||||
|
expect(lista.first.tamanoBytes, 20);
|
||||||
|
expect(lista.first.fecha, DateTime(2026, 6, 1));
|
||||||
|
expect(lista.last.nombre, '2026-01-01-radio-a');
|
||||||
|
expect(lista.last.tamanoBytes, 10);
|
||||||
|
|
||||||
|
await servicio.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('carpeta inexistente devuelve lista vacía, no lanza', () async {
|
||||||
|
final dir = await Directory.systemTemp.createTemp(
|
||||||
|
'pluriwave-rec-list-empty-',
|
||||||
|
);
|
||||||
|
final servicio = ServicioGrabacionRadio(
|
||||||
|
resolverDirectorioBase: () async => dir,
|
||||||
|
);
|
||||||
|
|
||||||
|
final lista = await servicio.listarGrabaciones();
|
||||||
|
|
||||||
|
expect(lista, isEmpty);
|
||||||
|
await servicio.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('eliminarGrabacion y renombrarGrabacion (WU15 task 15.5)', () {
|
||||||
|
test('eliminarGrabacion borra el archivo del disco', () async {
|
||||||
|
final dir = await Directory.systemTemp.createTemp(
|
||||||
|
'pluriwave-rec-delete-',
|
||||||
|
);
|
||||||
|
final archivo = File('${dir.path}${Platform.pathSeparator}a.mp3');
|
||||||
|
await archivo.writeAsBytes([1, 2, 3]);
|
||||||
|
final servicio = ServicioGrabacionRadio(
|
||||||
|
resolverDirectorioBase: () async => dir,
|
||||||
|
);
|
||||||
|
|
||||||
|
await servicio.eliminarGrabacion(archivo.path);
|
||||||
|
|
||||||
|
expect(await archivo.exists(), isFalse);
|
||||||
|
await servicio.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('eliminarGrabacion sobre un archivo ya borrado no lanza', () async {
|
||||||
|
final dir = await Directory.systemTemp.createTemp(
|
||||||
|
'pluriwave-rec-delete-missing-',
|
||||||
|
);
|
||||||
|
final servicio = ServicioGrabacionRadio(
|
||||||
|
resolverDirectorioBase: () async => dir,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
servicio.eliminarGrabacion(
|
||||||
|
'${dir.path}${Platform.pathSeparator}no-existe.mp3',
|
||||||
|
),
|
||||||
|
completes,
|
||||||
|
);
|
||||||
|
await servicio.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'renombrarGrabacion preserva la extensión y devuelve la nueva ruta',
|
||||||
|
() async {
|
||||||
|
final dir = await Directory.systemTemp.createTemp(
|
||||||
|
'pluriwave-rec-rename-',
|
||||||
|
);
|
||||||
|
final archivo = File(
|
||||||
|
'${dir.path}${Platform.pathSeparator}original.mp3',
|
||||||
|
);
|
||||||
|
await archivo.writeAsBytes([1, 2, 3]);
|
||||||
|
final servicio = ServicioGrabacionRadio(
|
||||||
|
resolverDirectorioBase: () async => dir,
|
||||||
|
);
|
||||||
|
|
||||||
|
final nuevaRuta = await servicio.renombrarGrabacion(
|
||||||
|
archivo.path,
|
||||||
|
'mi grabación',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(nuevaRuta, endsWith('mi grabación.mp3'));
|
||||||
|
expect(await File(nuevaRuta).exists(), isTrue);
|
||||||
|
expect(await archivo.exists(), isFalse);
|
||||||
|
await servicio.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user