Files
pluriwave/lib/estado/estado_grabacion.dart
T
FreeTLab d81fabbe27 refactor(iap): make esPremium a required constructor parameter
EstadoAlarmas, EstadoGrabacion and EstadoRadio defaulted `esPremium` to
`() => true`, so any construction site that forgot to wire entitlement
compiled fine and silently ran ungated — failing OPEN to premium and
disabling the paywall with no test able to catch it.

The parameter is now required with no default. Production wiring in
app.dart was already correct and is unchanged; the 184 pre-existing test
call sites now pass `() => true` explicitly, which is exactly the old
implicit default, so every assertion is untouched.

EstadoRadio has no gate of its own but constructs EstadoGrabacion, so it
inherits the same contract.

The one test that existed to pin the old default is renamed to describe
what it still covers (the premium path through iniciar() with no
duracion); its assertions are unchanged.
2026-08-10 22:06:36 +02:00

212 lines
7.8 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'dart:ui' show Locale;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/archivo_grabacion.dart';
import '../modelos/emisora.dart';
import '../servicios/servicio_grabacion_radio.dart';
/// Recording state extracted from `EstadoRadio` (S4-R2).
///
/// Owns [ServicioGrabacionRadio] and the recording-state subscription, and
/// notifies ONLY its own listeners — recording progress must not rebuild
/// `EstadoRadio` consumers (S4-R5). Playback orchestration (stop recording on
/// pause/stop/station switch) stays in `EstadoRadio`, which keeps a reference
/// to this notifier.
/// Whether [emisora] is something the recorder can actually capture: a live
/// network stream.
///
/// The recorder opens the URL as an HTTP stream and writes the bytes to disk,
/// so anything else fails inside the HTTP client with a message no user can
/// act on ("Unsupported scheme 'content' in URI content://...").
///
/// This is not hypothetical tidiness. `PluriWaveAudioHandler._cambiarFuente`
/// sets `emisoraActual` for EVERY source it plays, so a local MP3 shows up
/// here as an `Emisora` whose `url` is the `content://` document URI it was
/// opened from. Recording a local file makes no sense anyway — it is already
/// on the device.
bool esEmisoraGrabable(Emisora emisora) {
final esquema = Uri.tryParse(emisora.url)?.scheme.toLowerCase();
return esquema == 'http' || esquema == 'https';
}
/// Distinct outcome signal for [EstadoGrabacion.iniciar] (freemium-gating
/// spec "Recording Start Gated"): [requierePremium] is NOT surfaced through
/// [EstadoGrabacion.iniciar]'s existing `alError` sink — the caller must
/// react by opening the paywall, a different UI than a plain error snackbar.
enum ResultadoIniciarGrabacion { iniciada, requierePremium, error }
class EstadoGrabacion extends ChangeNotifier {
EstadoGrabacion({
ServicioGrabacionRadio? servicio,
Emisora? Function()? emisoraActual,
void Function(String mensaje)? alError,
// iap-freemium-unlock (Design ADR-3): entitlement query, mirroring
// [_emisoraActual]'s callback-injection shape. REQUIRED on purpose: an
// optional parameter with any default lets a forgotten wiring compile
// and silently pick a tier, and no test can catch that. Callers must
// state the entitlement source explicitly.
required bool Function() esPremium,
}) : servicio = servicio ?? ServicioGrabacionRadio(),
_emisoraActual = emisoraActual ?? (() => null),
_alError = alError,
_esPremium = esPremium {
_suscripcion = this.servicio.estadoStream.listen((estado) {
if (estado.tipo == EstadoGrabacionRadioTipo.error &&
estado.error != null) {
_alError?.call(_textos.radioRecordingError(estado.error!));
}
notifyListeners();
});
}
static const MethodChannel _fileActionsChannel = MethodChannel(
'pluriwave/file_actions',
);
final ServicioGrabacionRadio servicio;
/// Callback into the owner (EstadoRadio) for the currently playing station;
/// keeps this notifier free of any station-list coupling.
final Emisora? Function() _emisoraActual;
/// User-visible error sink (EstadoRadio routes it to its snackbar stream).
final void Function(String mensaje)? _alError;
final bool Function() _esPremium;
StreamSubscription<EstadoGrabacionRadio>? _suscripcion;
AppLocalizations? _l10n;
AppLocalizations get _textos {
final actual = _l10n;
if (actual != null) return actual;
return lookupAppLocalizations(const Locale('es'));
}
void configurarLocalizaciones(AppLocalizations l10n) {
_l10n = l10n;
servicio.configurarLocalizaciones(l10n);
}
Future<void> inicializar() => servicio.inicializar();
EstadoGrabacionRadio get estado => servicio.estado;
bool get activa => servicio.estado.activa;
String? get directorioConfigurado => servicio.directorioConfigurado;
int get maxBytes => servicio.maxBytes;
File? get ultimoArchivo => servicio.ultimoArchivo;
Future<ResultadoIniciarGrabacion> iniciar({Duration? duracion}) async {
// Freemium gate (freemium-gating spec "Free user starts a new
// recording"): the AUTHORITATIVE check, before touching the service at
// all. Management of already-existing recordings is untouched — this
// method only governs STARTING a new one.
if (!_esPremium()) {
return ResultadoIniciarGrabacion.requierePremium;
}
final actual = _emisoraActual();
// `emisoraActual` is set by `_cambiarFuente` for EVERY source, local
// tracks included -- a local file becomes an `Emisora` whose `url` is the
// SAF `content://` URI it was opened from. Handing that to the recorder
// produced "Unsupported scheme 'content' in URI content://..." on screen,
// and it started happening only once local music playback existed: before
// that, whatever was playing was always a real station.
if (actual == null || !esEmisoraGrabable(actual)) {
_alError?.call(_textos.recordingSelectStationFirst);
return ResultadoIniciarGrabacion.error;
}
try {
await servicio.iniciar(actual, duracion: duracion);
return ResultadoIniciarGrabacion.iniciada;
} catch (e) {
_alError?.call(_textos.recordingStartError(e.toString()));
return ResultadoIniciarGrabacion.error;
}
}
Future<void> detener() => servicio.detener();
Future<void> cambiarMaxBytes(int bytes) async {
await servicio.guardarMaxBytes(bytes);
notifyListeners();
}
Future<void> cambiarDirectorio(String path) async {
await servicio.guardarDirectorio(path);
notifyListeners();
}
Future<void> restaurarDirectorio() async {
await servicio.limpiarDirectorioConfigurado();
notifyListeners();
}
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 {
final ruta = await directorioEfectivo();
await Directory(ruta).create(recursive: true);
if (!kIsWeb && Platform.isAndroid) {
final abierto = await _fileActionsChannel.invokeMethod<bool>(
'viewDirectory',
{'path': ruta},
);
return abierto ?? false;
}
final uri = Uri.directory(ruta);
return launchUrl(uri, mode: LaunchMode.externalApplication);
}
Future<bool> abrirUltimaGrabacion() async {
final archivo = ultimoArchivo;
if (archivo == null || !await archivo.exists()) {
debugPrint('[PluriWave][recordings] last recording missing');
return false;
}
debugPrint('[PluriWave][recordings] opening last file: ${archivo.path}');
if (!kIsWeb && Platform.isAndroid) {
final abierto = await _fileActionsChannel.invokeMethod<bool>('openFile', {
'path': archivo.path,
'mimeType': 'audio/*',
});
return abierto ?? false;
}
return launchUrl(
Uri.file(archivo.path),
mode: LaunchMode.externalApplication,
);
}
@override
void dispose() {
_suscripcion?.cancel();
unawaited(servicio.dispose());
super.dispose();
}
}