Adds a permanent, non-consumable premium unlock (EstadoEntitlement + PuertoCompras/ServicioComprasPlayBilling) that removes ads and unlocks alarm vacations, alarms past a 5-alarm free cap, recording start, and full Android Auto browsing. The phone equalizer stays free for everyone. - Entitlement is prefs-backed (compra_premium_v1), fail-open, and resolvable headlessly via esPremiumPersistido() for the Android Auto audio handler, which registers before runApp. - Android Auto reduced mode keeps the real root folder labels for free users; browsing into any of them (and playFromMediaId/playFromSearch/ skipToNext/skipToPrevious) is blocked at the getChildren/servicio_audio choke points, with a locked "Función Premium" item as the backstop. Current-station play/pause/stop stays untouched. A free -> premium transition actively invalidates the head unit's cached browse tree. - Ads (top banner + capped interstitial before adding a station or an alarm) are gated behind entitlement via ServicioAnuncios, using official Google test ad unit IDs pending AdMob provisioning. - Alarm cap UX shows an explanatory message with a secondary unlock action rather than a bare paywall jump; existing data is grandfathered. - 4 new localization keys translated across all 13 supported locales. Co-located tests use strict TDD (RED test before implementation) for every new pure-logic unit; full existing suite passes unchanged.
211 lines
7.7 KiB
Dart
211 lines
7.7 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. Defaults to "premium"
|
|
// (ungated) so every pre-existing test/call site keeps its exact
|
|
// previous behavior — `app.dart` always wires the real callback.
|
|
bool Function()? esPremium,
|
|
}) : servicio = servicio ?? ServicioGrabacionRadio(),
|
|
_emisoraActual = emisoraActual ?? (() => null),
|
|
_alError = alError,
|
|
_esPremium = esPremium ?? (() => true) {
|
|
_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();
|
|
}
|
|
}
|