Compare commits
5
Commits
b1bf289e0d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2891a5703e | ||
|
|
acf2ebb55f | ||
|
|
69eea0f2a1 | ||
|
|
0b7919e72e | ||
|
|
d7366bbf99 |
@@ -12,8 +12,15 @@
|
|||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
|
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
|
||||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
|
||||||
|
<!-- Approximate location ONLY. The single consumer
|
||||||
|
(EstadoBusqueda.cargarEmisorasCercanas) asks for LocationAccuracy.low
|
||||||
|
and throws the fix away except for Placemark.isoCountryCode, so a
|
||||||
|
country-level fix is all this app can use. Declaring
|
||||||
|
ACCESS_FINE_LOCATION would also contradict the approved Data Safety
|
||||||
|
declaration. geolocator builds its runtime request from whichever of
|
||||||
|
the two permissions the merged manifest declares, so with COARSE
|
||||||
|
alone the system dialog offers approximate precision only. -->
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
|
||||||
<!--
|
<!--
|
||||||
Reading the paired-device list is gated by BLUETOOTH_CONNECT from API 31
|
Reading the paired-device list is gated by BLUETOOTH_CONNECT from API 31
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import 'estado/estado_radio.dart';
|
|||||||
import 'estado/estado_alarmas.dart';
|
import 'estado/estado_alarmas.dart';
|
||||||
import 'estado/estado_idioma.dart';
|
import 'estado/estado_idioma.dart';
|
||||||
import 'estado/estado_navegacion.dart';
|
import 'estado/estado_navegacion.dart';
|
||||||
|
import 'estado/estado_visualizador.dart';
|
||||||
import 'servicios/servicio_anuncios.dart';
|
import 'servicios/servicio_anuncios.dart';
|
||||||
import 'servicios/servicio_compras.dart';
|
import 'servicios/servicio_compras.dart';
|
||||||
import 'widgets/banner_anuncio_superior.dart';
|
import 'widgets/banner_anuncio_superior.dart';
|
||||||
@@ -122,6 +123,12 @@ class PluriWaveApp extends StatelessWidget {
|
|||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
create: (_) => EstadoIdioma(sharedPreferences: prefs),
|
create: (_) => EstadoIdioma(sharedPreferences: prefs),
|
||||||
),
|
),
|
||||||
|
// Sensitive-permission opt-in for the waveform visualizer's real
|
||||||
|
// audio capture. Lives at the root because BOTH visualizer call
|
||||||
|
// sites (the Escuchar hero and the full player) have to read it —
|
||||||
|
// whichever of them mounts first is the one that would otherwise
|
||||||
|
// trigger the RECORD_AUDIO request.
|
||||||
|
ChangeNotifierProvider(create: (_) => EstadoVisualizador(prefs: prefs)),
|
||||||
// Design ADR-8: root-to-root navigation state. `_PaginaPrincipal`
|
// Design ADR-8: root-to-root navigation state. `_PaginaPrincipal`
|
||||||
// watches this instead of owning `_indice` locally.
|
// watches this instead of owning `_indice` locally.
|
||||||
ChangeNotifierProvider(create: (_) => EstadoNavegacionRaiz()),
|
ChangeNotifierProvider(create: (_) => EstadoNavegacionRaiz()),
|
||||||
@@ -205,6 +212,10 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal>
|
|||||||
// without a live native sink. Re-subscribe and re-seed the active device
|
// without a live native sink. Re-subscribe and re-seed the active device
|
||||||
// (no-op when multi-device EQ is off).
|
// (no-op when multi-device EQ is off).
|
||||||
unawaited(context.read<EstadoEcualizador>().refrescarDispositivoActual());
|
unawaited(context.read<EstadoEcualizador>().refrescarDispositivoActual());
|
||||||
|
// Silent, throttled license re-verification (refund revocation) and a
|
||||||
|
// re-sync with any change the Android Auto path persisted meanwhile.
|
||||||
|
// Fire-and-forget: never delays the resume, never shows anything.
|
||||||
|
unawaited(context.read<EstadoEntitlement>().refrescarLicencia());
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
|
|
||||||
import '../servicios/servicio_audio.dart' show invalidarArbolAuto;
|
import '../servicios/servicio_audio.dart' show invalidarArbolAuto;
|
||||||
import '../servicios/servicio_compras.dart';
|
import '../servicios/servicio_compras.dart';
|
||||||
|
import '../servicios/verificacion_licencia.dart';
|
||||||
|
|
||||||
/// Versioned persistence key (Design ADR-1) for the permanent, non-consumable
|
/// Versioned persistence key (Design ADR-1) for the permanent, non-consumable
|
||||||
/// premium unlock. Older builds that predate this key simply never read it —
|
/// premium unlock. Older builds that predate this key simply never read it —
|
||||||
/// no migration needed (Rollout "Versioned key ... is ignored by older
|
/// no migration needed (Rollout "Versioned key ... is ignored by older
|
||||||
/// builds").
|
/// builds"). Shared with the silent license re-verification
|
||||||
const _keyPremium = 'compra_premium_v1';
|
/// (`verificacion_licencia.dart`), which may revoke it after a refund.
|
||||||
|
const _keyPremium = claveCompraPremium;
|
||||||
|
|
||||||
/// Headless-safe entitlement read (Design ADR-1, Spec "Headless-Safe
|
/// Headless-safe entitlement read (Design ADR-1, Spec "Headless-Safe
|
||||||
/// Entitlement Read"): resolves the persisted premium flag directly from
|
/// Entitlement Read"): resolves the persisted premium flag directly from
|
||||||
@@ -55,14 +57,21 @@ enum ResultadoEntitlementUsuario {
|
|||||||
/// this; headless callers (Android Auto) use [esPremiumPersistido] instead,
|
/// this; headless callers (Android Auto) use [esPremiumPersistido] instead,
|
||||||
/// since no `Provider` exists on that path.
|
/// since no `Provider` exists on that path.
|
||||||
class EstadoEntitlement extends ChangeNotifier {
|
class EstadoEntitlement extends ChangeNotifier {
|
||||||
EstadoEntitlement({SharedPreferences? prefs, PuertoCompras? compras})
|
EstadoEntitlement({
|
||||||
: _prefs = prefs,
|
SharedPreferences? prefs,
|
||||||
_compras = compras {
|
PuertoCompras? compras,
|
||||||
|
DateTime Function()? reloj,
|
||||||
|
}) : _prefs = prefs,
|
||||||
|
_compras = compras,
|
||||||
|
_reloj = reloj {
|
||||||
final flujo = _compras;
|
final flujo = _compras;
|
||||||
if (flujo != null) {
|
if (flujo != null) {
|
||||||
_comprasSub = flujo.eventos.listen(_alRecibirEvento);
|
_comprasSub = flujo.eventos.listen(_alRecibirEvento);
|
||||||
}
|
}
|
||||||
_cargar();
|
// The silent license check is chained AFTER the load and never awaited
|
||||||
|
// by anyone: the persisted flag is served immediately, exactly as
|
||||||
|
// before, and the check can only adjust it later, in the background.
|
||||||
|
unawaited(_cargar().then((_) => _verificarLicencia()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The single non-consumable product id (Design "Interfaces / Contracts"),
|
/// The single non-consumable product id (Design "Interfaces / Contracts"),
|
||||||
@@ -72,7 +81,11 @@ class EstadoEntitlement extends ChangeNotifier {
|
|||||||
|
|
||||||
final SharedPreferences? _prefs;
|
final SharedPreferences? _prefs;
|
||||||
final PuertoCompras? _compras;
|
final PuertoCompras? _compras;
|
||||||
|
|
||||||
|
/// Injectable clock for the license check's throttle/spacing rules.
|
||||||
|
final DateTime Function()? _reloj;
|
||||||
StreamSubscription<EventoCompra>? _comprasSub;
|
StreamSubscription<EventoCompra>? _comprasSub;
|
||||||
|
bool _desechado = false;
|
||||||
|
|
||||||
bool _esPremium = false;
|
bool _esPremium = false;
|
||||||
bool _compraEnCurso = false;
|
bool _compraEnCurso = false;
|
||||||
@@ -106,6 +119,53 @@ class EstadoEntitlement extends ChangeNotifier {
|
|||||||
Future<SharedPreferences> _resolverPrefs() async =>
|
Future<SharedPreferences> _resolverPrefs() async =>
|
||||||
_prefs ?? SharedPreferences.getInstance();
|
_prefs ?? SharedPreferences.getInstance();
|
||||||
|
|
||||||
|
/// Fire-and-forget hook for app resume: re-syncs with the persisted flag
|
||||||
|
/// (the Android Auto path may have changed it) and runs the throttled
|
||||||
|
/// silent license check. Never throws, never touches [compraEnCurso] or
|
||||||
|
/// [resultadoUsuario].
|
||||||
|
Future<void> refrescarLicencia() async {
|
||||||
|
try {
|
||||||
|
_sincronizarConPrefs(await _resolverPrefs());
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[PluriWave][licencia] refresco fallido $e');
|
||||||
|
}
|
||||||
|
await _verificarLicencia();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs [verificarLicencia] against the purchase port and mirrors any
|
||||||
|
/// change of the persisted flag. Silent by construction: it only ever
|
||||||
|
/// updates [esPremium] and notifies — no purchase-stream event, no
|
||||||
|
/// [resultadoUsuario], no [compraEnCurso].
|
||||||
|
Future<void> _verificarLicencia() async {
|
||||||
|
final compras = _compras;
|
||||||
|
if (compras == null || _desechado) return;
|
||||||
|
try {
|
||||||
|
final prefs = await _resolverPrefs();
|
||||||
|
await verificarLicencia(
|
||||||
|
consultar: compras.consultarPropiedad,
|
||||||
|
prefs: prefs,
|
||||||
|
reloj: _reloj,
|
||||||
|
);
|
||||||
|
_sincronizarConPrefs(prefs);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[PluriWave][licencia] verificacion fallida $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aligns [esPremium] with the persisted flag. Safe against a racing
|
||||||
|
/// [_desbloquear]: that one writes the prefs cache in the same synchronous
|
||||||
|
/// block where it flips [_esPremium], so both always agree here.
|
||||||
|
void _sincronizarConPrefs(SharedPreferences prefs) {
|
||||||
|
if (_desechado) return;
|
||||||
|
final premium = prefs.getBool(_keyPremium) ?? false;
|
||||||
|
if (premium == _esPremium) return;
|
||||||
|
_esPremium = premium;
|
||||||
|
notifyListeners();
|
||||||
|
// Either direction changes what the car may show (local music is
|
||||||
|
// premium-gated), so the cached Android Auto tree is stale both ways.
|
||||||
|
invalidarArbolAuto();
|
||||||
|
}
|
||||||
|
|
||||||
/// Starts the purchase flow (Spec "Successful purchase"). A no-op when
|
/// Starts the purchase flow (Spec "Successful purchase"). A no-op when
|
||||||
/// already premium (Spec "Already-purchased attempt is idempotent") — no
|
/// already premium (Spec "Already-purchased attempt is idempotent") — no
|
||||||
/// duplicate charge is even attempted.
|
/// duplicate charge is even attempted.
|
||||||
@@ -171,11 +231,19 @@ class EstadoEntitlement extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _desbloquear() async {
|
Future<void> _desbloquear() async {
|
||||||
|
// Prefs resolved FIRST so the in-memory flip and the prefs-cache write
|
||||||
|
// below happen in one synchronous block (`setBool` updates the cache
|
||||||
|
// before awaiting the platform) — [_sincronizarConPrefs] can never
|
||||||
|
// observe one without the other.
|
||||||
|
final prefs = await _resolverPrefs();
|
||||||
final yaEraPremium = _esPremium;
|
final yaEraPremium = _esPremium;
|
||||||
_esPremium = true;
|
_esPremium = true;
|
||||||
_compraEnCurso = false;
|
_compraEnCurso = false;
|
||||||
final prefs = await _resolverPrefs();
|
final escritura = prefs.setBool(_keyPremium, true);
|
||||||
await prefs.setBool(_keyPremium, true);
|
// A real purchase/restore is fresh proof of ownership: drop any stale
|
||||||
|
// absence streak of the silent license check.
|
||||||
|
await reiniciarAusenciasLicencia(prefs);
|
||||||
|
await escritura;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
if (!yaEraPremium) {
|
if (!yaEraPremium) {
|
||||||
// Orchestrator-resolved open question (design.md): actively
|
// Orchestrator-resolved open question (design.md): actively
|
||||||
@@ -187,6 +255,7 @@ class EstadoEntitlement extends ChangeNotifier {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_desechado = true;
|
||||||
_comprasSub?.cancel();
|
_comprasSub?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,24 +182,41 @@ class EstadoGrabacion extends ChangeNotifier {
|
|||||||
return launchUrl(uri, mode: LaunchMode.externalApplication);
|
return launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> abrirUltimaGrabacion() async {
|
/// Hands the recording at [ruta] to whatever player the user already has
|
||||||
final archivo = ultimoArchivo;
|
/// on THIS device (`ACTION_VIEW` through the native `openFile` method,
|
||||||
if (archivo == null || !await archivo.exists()) {
|
/// over the app's own `FileProvider`). Nothing leaves the device: this is
|
||||||
debugPrint('[PluriWave][recordings] last recording missing');
|
/// the "play my own copy elsewhere" action, not a share sheet.
|
||||||
|
///
|
||||||
|
/// Returns `false` when the file is gone or no installed app accepted the
|
||||||
|
/// intent, so the caller can say so instead of failing in silence.
|
||||||
|
///
|
||||||
|
/// Static-review-only, like [abrirDirectorio] and every other method here
|
||||||
|
/// that crosses `pluriwave/file_actions`: the channel has no handler under
|
||||||
|
/// `flutter test`. The screens that call it inject a seam instead.
|
||||||
|
Future<bool> abrirGrabacion(String ruta) async {
|
||||||
|
final archivo = File(ruta);
|
||||||
|
if (!await archivo.exists()) {
|
||||||
|
debugPrint('[PluriWave][recordings] file missing: $ruta');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
debugPrint('[PluriWave][recordings] opening last file: ${archivo.path}');
|
debugPrint('[PluriWave][recordings] opening file: $ruta');
|
||||||
if (!kIsWeb && Platform.isAndroid) {
|
if (!kIsWeb && Platform.isAndroid) {
|
||||||
final abierto = await _fileActionsChannel.invokeMethod<bool>('openFile', {
|
final abierto = await _fileActionsChannel.invokeMethod<bool>('openFile', {
|
||||||
'path': archivo.path,
|
'path': ruta,
|
||||||
'mimeType': 'audio/*',
|
'mimeType': 'audio/*',
|
||||||
});
|
});
|
||||||
return abierto ?? false;
|
return abierto ?? false;
|
||||||
}
|
}
|
||||||
return launchUrl(
|
return launchUrl(Uri.file(ruta), mode: LaunchMode.externalApplication);
|
||||||
Uri.file(archivo.path),
|
}
|
||||||
mode: LaunchMode.externalApplication,
|
|
||||||
);
|
Future<bool> abrirUltimaGrabacion() async {
|
||||||
|
final archivo = ultimoArchivo;
|
||||||
|
if (archivo == null) {
|
||||||
|
debugPrint('[PluriWave][recordings] last recording missing');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return abrirGrabacion(archivo.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// Opt-in for reading the REAL audio level behind the waveform visualizer.
|
||||||
|
///
|
||||||
|
/// This is a sensitive-permission gate, not a cosmetic preference.
|
||||||
|
/// `VisualizadorAudio` subscribes to the native `pluriwave/audio_visualizer`
|
||||||
|
/// EventChannel only while [ondaRealHabilitada] is true, and that
|
||||||
|
/// subscription is precisely what makes `MainActivity` request
|
||||||
|
/// `RECORD_AUDIO`. So the flag must:
|
||||||
|
///
|
||||||
|
/// * default to `false`, so a fresh install never asks;
|
||||||
|
/// * flip to `true` only from a place where the user has just been told what
|
||||||
|
/// the permission is for (see `PantallaAjustesVisualizador`);
|
||||||
|
/// * be revocable at any time with no friction at all.
|
||||||
|
///
|
||||||
|
/// Without it the visualizer animates its synthetic wave, which is exactly
|
||||||
|
/// what it already did whenever the permission was denied — nothing about
|
||||||
|
/// the app breaks.
|
||||||
|
class EstadoVisualizador extends ChangeNotifier {
|
||||||
|
EstadoVisualizador({SharedPreferences? prefs}) : _prefs = prefs {
|
||||||
|
_cargar();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persisted key. Public so tests can assert persistence without
|
||||||
|
/// duplicating the literal.
|
||||||
|
static const String claveOndaReal = 'visualizador_onda_real_v1';
|
||||||
|
|
||||||
|
final SharedPreferences? _prefs;
|
||||||
|
|
||||||
|
bool _ondaRealHabilitada = false;
|
||||||
|
|
||||||
|
bool get ondaRealHabilitada => _ondaRealHabilitada;
|
||||||
|
|
||||||
|
Future<void> cambiarOndaReal(bool habilitada) async {
|
||||||
|
if (habilitada == _ondaRealHabilitada) return;
|
||||||
|
_ondaRealHabilitada = habilitada;
|
||||||
|
notifyListeners();
|
||||||
|
final prefs = await _resolverPrefs();
|
||||||
|
await prefs.setBool(claveOndaReal, habilitada);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _cargar() async {
|
||||||
|
final prefs = await _resolverPrefs();
|
||||||
|
final guardado = prefs.getBool(claveOndaReal) ?? false;
|
||||||
|
if (guardado == _ondaRealHabilitada) return;
|
||||||
|
_ondaRealHabilitada = guardado;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SharedPreferences> _resolverPrefs() async =>
|
||||||
|
_prefs ?? SharedPreferences.getInstance();
|
||||||
|
}
|
||||||
+10
-3
@@ -769,7 +769,9 @@
|
|||||||
"radioCountriesError": "تعذّر تحميل الدول.",
|
"radioCountriesError": "تعذّر تحميل الدول.",
|
||||||
"recordingActionDelete": "حذف",
|
"recordingActionDelete": "حذف",
|
||||||
"recordingActionRename": "إعادة تسمية",
|
"recordingActionRename": "إعادة تسمية",
|
||||||
"recordingActionShare": "مشاركة",
|
"stationActionShare": "مشاركة المحطة",
|
||||||
|
"recordingActionOpenIn": "فتح في تطبيق آخر",
|
||||||
|
"recordingOpenNoAppError": "لا يوجد تطبيق على هذا الجهاز يمكنه تشغيل هذا التسجيل.",
|
||||||
"recordingDeleteConfirmMessage": "لا يمكن التراجع عن هذا الإجراء.",
|
"recordingDeleteConfirmMessage": "لا يمكن التراجع عن هذا الإجراء.",
|
||||||
"recordingDeleteConfirmTitle": "هل تريد حذف التسجيل؟",
|
"recordingDeleteConfirmTitle": "هل تريد حذف التسجيل؟",
|
||||||
"recordingRenameDialogTitle": "إعادة تسمية التسجيل",
|
"recordingRenameDialogTitle": "إعادة تسمية التسجيل",
|
||||||
@@ -907,7 +909,7 @@
|
|||||||
"premiumActivo": "النسخة المميزة مفعّلة",
|
"premiumActivo": "النسخة المميزة مفعّلة",
|
||||||
"premiumHojaTitulo": "افتح PluriWave Premium",
|
"premiumHojaTitulo": "افتح PluriWave Premium",
|
||||||
"premiumBeneficioSinAnuncios": "بدون إعلانات في التطبيق بالكامل",
|
"premiumBeneficioSinAnuncios": "بدون إعلانات في التطبيق بالكامل",
|
||||||
"premiumBeneficioAndroidAuto": "Android Auto",
|
"premiumBeneficioAndroidAuto": "الكتالوج الكامل في Android Auto: المفضلة ومحطاتي والموسيقى المحلية (مجانًا: المحطات المميزة فقط)",
|
||||||
"premiumBeneficioGrabacion": "تسجيل المحطات",
|
"premiumBeneficioGrabacion": "تسجيل المحطات",
|
||||||
"premiumBeneficioVacaciones": "فترات إجازة للمنبهات",
|
"premiumBeneficioVacaciones": "فترات إجازة للمنبهات",
|
||||||
"premiumBeneficioAlarmasIlimitadas": "منبهات غير محدودة (تسمح الخطة المجانية بحتى 5)",
|
"premiumBeneficioAlarmasIlimitadas": "منبهات غير محدودة (تسمح الخطة المجانية بحتى 5)",
|
||||||
@@ -925,5 +927,10 @@
|
|||||||
"autoOrdenarPorCalidad": "الترتيب حسب الجودة",
|
"autoOrdenarPorCalidad": "الترتيب حسب الجودة",
|
||||||
"autoReproducirCarpeta": "تشغيل المجلد",
|
"autoReproducirCarpeta": "تشغيل المجلد",
|
||||||
"autoReproducirAleatorio": "تشغيل عشوائي",
|
"autoReproducirAleatorio": "تشغيل عشوائي",
|
||||||
"autoPistaSinNombre": "مقطع بلا اسم"
|
"autoPistaSinNombre": "مقطع بلا اسم",
|
||||||
|
"recordingsPrivateUseNotice": "التسجيلات مخصصة لاستخدامك الشخصي. يُرجى احترام حقوق كل محطة وحقوق محتواها.",
|
||||||
|
"visualizerRealWaveTitle": "الموجة الحقيقية للصوت",
|
||||||
|
"visualizerRealWaveSubtitle": "تتبع الأعمدة الصوت الجاري تشغيله. وعند الإيقاف تتحرك من تلقاء نفسها.",
|
||||||
|
"visualizerRealWavePermissionExplanation": "لا يسمح أندرويد بقراءة مستوى الصوت إلا بإذن الميكروفون. لا يستمع PluriWave إلى الميكروفون ولا يسجّله: فهو يقيس الصوت الذي يشغّله بالفعل فقط. ويمكنك إيقاف ذلك متى شئت.",
|
||||||
|
"visualizerRealWaveEnableAction": "تفعيل"
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -769,7 +769,9 @@
|
|||||||
"radioCountriesError": "দেশগুলো লোড করা যায়নি।",
|
"radioCountriesError": "দেশগুলো লোড করা যায়নি।",
|
||||||
"recordingActionDelete": "মুছে ফেলুন",
|
"recordingActionDelete": "মুছে ফেলুন",
|
||||||
"recordingActionRename": "নাম পরিবর্তন করুন",
|
"recordingActionRename": "নাম পরিবর্তন করুন",
|
||||||
"recordingActionShare": "শেয়ার করুন",
|
"stationActionShare": "স্টেশন শেয়ার করুন",
|
||||||
|
"recordingActionOpenIn": "অন্য অ্যাপে খুলুন",
|
||||||
|
"recordingOpenNoAppError": "এই ডিভাইসে এই রেকর্ডিং চালাতে পারে এমন কোনো অ্যাপ নেই।",
|
||||||
"recordingDeleteConfirmMessage": "এই পদক্ষেপ ফিরিয়ে নেওয়া যাবে না।",
|
"recordingDeleteConfirmMessage": "এই পদক্ষেপ ফিরিয়ে নেওয়া যাবে না।",
|
||||||
"recordingDeleteConfirmTitle": "রেকর্ডিং মুছবেন?",
|
"recordingDeleteConfirmTitle": "রেকর্ডিং মুছবেন?",
|
||||||
"recordingRenameDialogTitle": "রেকর্ডিং-এর নাম পরিবর্তন করুন",
|
"recordingRenameDialogTitle": "রেকর্ডিং-এর নাম পরিবর্তন করুন",
|
||||||
@@ -907,7 +909,7 @@
|
|||||||
"premiumActivo": "প্রিমিয়াম সক্রিয়",
|
"premiumActivo": "প্রিমিয়াম সক্রিয়",
|
||||||
"premiumHojaTitulo": "PluriWave Premium আনলক করুন",
|
"premiumHojaTitulo": "PluriWave Premium আনলক করুন",
|
||||||
"premiumBeneficioSinAnuncios": "পুরো অ্যাপে কোনো বিজ্ঞাপন নেই",
|
"premiumBeneficioSinAnuncios": "পুরো অ্যাপে কোনো বিজ্ঞাপন নেই",
|
||||||
"premiumBeneficioAndroidAuto": "Android Auto",
|
"premiumBeneficioAndroidAuto": "Android Auto-তে সম্পূর্ণ তালিকা: প্রিয়, আমার স্টেশন ও স্থানীয় সংগীত (ফ্রি: শুধু বাছাই করা স্টেশন)",
|
||||||
"premiumBeneficioGrabacion": "স্টেশন রেকর্ডিং",
|
"premiumBeneficioGrabacion": "স্টেশন রেকর্ডিং",
|
||||||
"premiumBeneficioVacaciones": "অ্যালার্মের জন্য ছুটির সময়কাল",
|
"premiumBeneficioVacaciones": "অ্যালার্মের জন্য ছুটির সময়কাল",
|
||||||
"premiumBeneficioAlarmasIlimitadas": "সীমাহীন অ্যালার্ম (ফ্রি প্ল্যানে সর্বোচ্চ ৫টি অনুমোদিত)",
|
"premiumBeneficioAlarmasIlimitadas": "সীমাহীন অ্যালার্ম (ফ্রি প্ল্যানে সর্বোচ্চ ৫টি অনুমোদিত)",
|
||||||
@@ -925,5 +927,10 @@
|
|||||||
"autoOrdenarPorCalidad": "মান অনুসারে সাজান",
|
"autoOrdenarPorCalidad": "মান অনুসারে সাজান",
|
||||||
"autoReproducirCarpeta": "ফোল্ডার চালান",
|
"autoReproducirCarpeta": "ফোল্ডার চালান",
|
||||||
"autoReproducirAleatorio": "এলোমেলোভাবে চালান",
|
"autoReproducirAleatorio": "এলোমেলোভাবে চালান",
|
||||||
"autoPistaSinNombre": "নামহীন ট্র্যাক"
|
"autoPistaSinNombre": "নামহীন ট্র্যাক",
|
||||||
|
"recordingsPrivateUseNotice": "রেকর্ডিংগুলি আপনার ব্যক্তিগত ব্যবহারের জন্য। প্রতিটি স্টেশন ও তার কনটেন্টের অধিকারকে সম্মান করুন।",
|
||||||
|
"visualizerRealWaveTitle": "আসল অডিও তরঙ্গ",
|
||||||
|
"visualizerRealWaveSubtitle": "বারগুলি চলমান শব্দ অনুসরণ করে। বন্ধ থাকলে সেগুলি নিজে থেকেই চলে।",
|
||||||
|
"visualizerRealWavePermissionExplanation": "মাইক্রোফোন অনুমতি ছাড়া Android অডিও লেভেল পড়তে দেয় না। PluriWave মাইক্রোফোন শোনে না বা রেকর্ড করে না: এটি শুধু চলমান শব্দের মাত্রা মাপে। আপনি যেকোনো সময় এটি বন্ধ করতে পারেন।",
|
||||||
|
"visualizerRealWaveEnableAction": "চালু করুন"
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -769,7 +769,9 @@
|
|||||||
"radioCountriesError": "Die Länder konnten nicht geladen werden.",
|
"radioCountriesError": "Die Länder konnten nicht geladen werden.",
|
||||||
"recordingActionDelete": "Löschen",
|
"recordingActionDelete": "Löschen",
|
||||||
"recordingActionRename": "Umbenennen",
|
"recordingActionRename": "Umbenennen",
|
||||||
"recordingActionShare": "Teilen",
|
"stationActionShare": "Sender teilen",
|
||||||
|
"recordingActionOpenIn": "In anderer App öffnen",
|
||||||
|
"recordingOpenNoAppError": "Keine App auf diesem Gerät kann diese Aufnahme abspielen.",
|
||||||
"recordingDeleteConfirmMessage": "Dies kann nicht rückgängig gemacht werden.",
|
"recordingDeleteConfirmMessage": "Dies kann nicht rückgängig gemacht werden.",
|
||||||
"recordingDeleteConfirmTitle": "Aufnahme löschen?",
|
"recordingDeleteConfirmTitle": "Aufnahme löschen?",
|
||||||
"recordingRenameDialogTitle": "Aufnahme umbenennen",
|
"recordingRenameDialogTitle": "Aufnahme umbenennen",
|
||||||
@@ -907,7 +909,7 @@
|
|||||||
"premiumActivo": "Premium aktiv",
|
"premiumActivo": "Premium aktiv",
|
||||||
"premiumHojaTitulo": "PluriWave Premium freischalten",
|
"premiumHojaTitulo": "PluriWave Premium freischalten",
|
||||||
"premiumBeneficioSinAnuncios": "Keine Werbung in der gesamten App",
|
"premiumBeneficioSinAnuncios": "Keine Werbung in der gesamten App",
|
||||||
"premiumBeneficioAndroidAuto": "Android Auto",
|
"premiumBeneficioAndroidAuto": "Der komplette Katalog in Android Auto: Favoriten, meine Sender und lokale Musik (gratis: nur empfohlene Sender)",
|
||||||
"premiumBeneficioGrabacion": "Sender aufnehmen",
|
"premiumBeneficioGrabacion": "Sender aufnehmen",
|
||||||
"premiumBeneficioVacaciones": "Urlaubszeiträume für Wecker",
|
"premiumBeneficioVacaciones": "Urlaubszeiträume für Wecker",
|
||||||
"premiumBeneficioAlarmasIlimitadas": "Unbegrenzte Wecker (die kostenlose Version erlaubt bis zu 5)",
|
"premiumBeneficioAlarmasIlimitadas": "Unbegrenzte Wecker (die kostenlose Version erlaubt bis zu 5)",
|
||||||
@@ -925,5 +927,10 @@
|
|||||||
"autoOrdenarPorCalidad": "Nach Qualität sortieren",
|
"autoOrdenarPorCalidad": "Nach Qualität sortieren",
|
||||||
"autoReproducirCarpeta": "Ordner abspielen",
|
"autoReproducirCarpeta": "Ordner abspielen",
|
||||||
"autoReproducirAleatorio": "Zufallswiedergabe",
|
"autoReproducirAleatorio": "Zufallswiedergabe",
|
||||||
"autoPistaSinNombre": "Unbenannter Titel"
|
"autoPistaSinNombre": "Unbenannter Titel",
|
||||||
|
"recordingsPrivateUseNotice": "Aufnahmen sind für deinen persönlichen Gebrauch bestimmt. Bitte respektiere die Rechte des Senders und seiner Inhalte.",
|
||||||
|
"visualizerRealWaveTitle": "Echte Audio-Wellenform",
|
||||||
|
"visualizerRealWaveSubtitle": "Die Balken folgen dem laufenden Ton. Ausgeschaltet bewegen sie sich von selbst.",
|
||||||
|
"visualizerRealWavePermissionExplanation": "Android erlaubt das Auslesen des Audiopegels nur mit der Mikrofonberechtigung. PluriWave hört das Mikrofon weder ab noch nimmt es auf: Es misst nur den Ton, den es ohnehin abspielt. Du kannst das jederzeit wieder ausschalten.",
|
||||||
|
"visualizerRealWaveEnableAction": "Aktivieren"
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -237,7 +237,9 @@
|
|||||||
"recordingsLibraryEmptyTitle": "No recordings yet",
|
"recordingsLibraryEmptyTitle": "No recordings yet",
|
||||||
"recordingsLibraryEmptySubtitle": "Recordings you save will appear here.",
|
"recordingsLibraryEmptySubtitle": "Recordings you save will appear here.",
|
||||||
"recordingActionRename": "Rename",
|
"recordingActionRename": "Rename",
|
||||||
"recordingActionShare": "Share",
|
"stationActionShare": "Share station",
|
||||||
|
"recordingActionOpenIn": "Open in another app",
|
||||||
|
"recordingOpenNoAppError": "No app on this device can play this recording.",
|
||||||
"recordingActionDelete": "Delete",
|
"recordingActionDelete": "Delete",
|
||||||
"recordingRenameDialogTitle": "Rename recording",
|
"recordingRenameDialogTitle": "Rename recording",
|
||||||
"recordingRenameLabel": "Name",
|
"recordingRenameLabel": "Name",
|
||||||
@@ -907,7 +909,7 @@
|
|||||||
"premiumActivo": "Premium active",
|
"premiumActivo": "Premium active",
|
||||||
"premiumHojaTitulo": "Unlock PluriWave Premium",
|
"premiumHojaTitulo": "Unlock PluriWave Premium",
|
||||||
"premiumBeneficioSinAnuncios": "No ads anywhere in the app",
|
"premiumBeneficioSinAnuncios": "No ads anywhere in the app",
|
||||||
"premiumBeneficioAndroidAuto": "Android Auto",
|
"premiumBeneficioAndroidAuto": "Your full catalogue in Android Auto: favourites, my stations and local music (free: featured stations only)",
|
||||||
"premiumBeneficioGrabacion": "Station recording",
|
"premiumBeneficioGrabacion": "Station recording",
|
||||||
"premiumBeneficioVacaciones": "Vacation ranges for alarms",
|
"premiumBeneficioVacaciones": "Vacation ranges for alarms",
|
||||||
"premiumBeneficioAlarmasIlimitadas": "Unlimited alarms (the free plan allows up to 5)",
|
"premiumBeneficioAlarmasIlimitadas": "Unlimited alarms (the free plan allows up to 5)",
|
||||||
@@ -925,5 +927,10 @@
|
|||||||
"autoOrdenarPorCalidad": "Sort by quality",
|
"autoOrdenarPorCalidad": "Sort by quality",
|
||||||
"autoReproducirCarpeta": "Play folder",
|
"autoReproducirCarpeta": "Play folder",
|
||||||
"autoReproducirAleatorio": "Shuffle play",
|
"autoReproducirAleatorio": "Shuffle play",
|
||||||
"autoPistaSinNombre": "Untitled track"
|
"autoPistaSinNombre": "Untitled track",
|
||||||
|
"recordingsPrivateUseNotice": "Recordings are for your own personal use. Please respect each station's rights and those of its content.",
|
||||||
|
"visualizerRealWaveTitle": "Real audio waveform",
|
||||||
|
"visualizerRealWaveSubtitle": "The bars follow the sound that is playing. When off, they animate on their own.",
|
||||||
|
"visualizerRealWavePermissionExplanation": "Android only allows reading the audio level with the microphone permission. PluriWave never listens to or records the microphone: it only measures the sound it is already playing. You can turn this off at any time.",
|
||||||
|
"visualizerRealWaveEnableAction": "Turn on"
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -237,7 +237,9 @@
|
|||||||
"recordingsLibraryEmptyTitle": "Todavía no hay grabaciones",
|
"recordingsLibraryEmptyTitle": "Todavía no hay grabaciones",
|
||||||
"recordingsLibraryEmptySubtitle": "Las grabaciones que guardes van a aparecer acá.",
|
"recordingsLibraryEmptySubtitle": "Las grabaciones que guardes van a aparecer acá.",
|
||||||
"recordingActionRename": "Renombrar",
|
"recordingActionRename": "Renombrar",
|
||||||
"recordingActionShare": "Compartir",
|
"stationActionShare": "Compartir emisora",
|
||||||
|
"recordingActionOpenIn": "Abrir en otra app",
|
||||||
|
"recordingOpenNoAppError": "No hay ninguna app en este dispositivo que pueda reproducir la grabación.",
|
||||||
"recordingActionDelete": "Eliminar",
|
"recordingActionDelete": "Eliminar",
|
||||||
"recordingRenameDialogTitle": "Renombrar grabación",
|
"recordingRenameDialogTitle": "Renombrar grabación",
|
||||||
"recordingRenameLabel": "Nombre",
|
"recordingRenameLabel": "Nombre",
|
||||||
@@ -866,7 +868,7 @@
|
|||||||
"premiumActivo": "Premium activo",
|
"premiumActivo": "Premium activo",
|
||||||
"premiumHojaTitulo": "Desbloquea PluriWave Premium",
|
"premiumHojaTitulo": "Desbloquea PluriWave Premium",
|
||||||
"premiumBeneficioSinAnuncios": "Sin publicidad en toda la app",
|
"premiumBeneficioSinAnuncios": "Sin publicidad en toda la app",
|
||||||
"premiumBeneficioAndroidAuto": "Android Auto",
|
"premiumBeneficioAndroidAuto": "Todo el catálogo en Android Auto: favoritos, mis emisoras y música local (gratis: solo destacadas)",
|
||||||
"premiumBeneficioGrabacion": "Grabación de emisoras",
|
"premiumBeneficioGrabacion": "Grabación de emisoras",
|
||||||
"premiumBeneficioVacaciones": "Rangos de vacaciones para las alarmas",
|
"premiumBeneficioVacaciones": "Rangos de vacaciones para las alarmas",
|
||||||
"premiumBeneficioAlarmasIlimitadas": "Alarmas ilimitadas (el plan gratuito permite hasta 5)",
|
"premiumBeneficioAlarmasIlimitadas": "Alarmas ilimitadas (el plan gratuito permite hasta 5)",
|
||||||
@@ -884,5 +886,10 @@
|
|||||||
"autoOrdenarPorCalidad": "Ordenar por calidad",
|
"autoOrdenarPorCalidad": "Ordenar por calidad",
|
||||||
"autoReproducirCarpeta": "Reproducir carpeta",
|
"autoReproducirCarpeta": "Reproducir carpeta",
|
||||||
"autoReproducirAleatorio": "Reproducir aleatorio",
|
"autoReproducirAleatorio": "Reproducir aleatorio",
|
||||||
"autoPistaSinNombre": "Pista sin nombre"
|
"autoPistaSinNombre": "Pista sin nombre",
|
||||||
|
"recordingsPrivateUseNotice": "Las grabaciones son para tu uso personal. Respeta los derechos de cada emisora y de sus contenidos.",
|
||||||
|
"visualizerRealWaveTitle": "Onda real del audio",
|
||||||
|
"visualizerRealWaveSubtitle": "Las barras siguen el sonido que está sonando. Desactivado, se animan solas.",
|
||||||
|
"visualizerRealWavePermissionExplanation": "Android solo deja leer el nivel del audio con el permiso de micrófono. PluriWave no escucha ni graba el micrófono: solo mide el sonido que ya está reproduciendo. Puedes desactivarlo cuando quieras.",
|
||||||
|
"visualizerRealWaveEnableAction": "Activar"
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -769,7 +769,9 @@
|
|||||||
"radioCountriesError": "Impossible de charger les pays.",
|
"radioCountriesError": "Impossible de charger les pays.",
|
||||||
"recordingActionDelete": "Supprimer",
|
"recordingActionDelete": "Supprimer",
|
||||||
"recordingActionRename": "Renommer",
|
"recordingActionRename": "Renommer",
|
||||||
"recordingActionShare": "Partager",
|
"stationActionShare": "Partager la station",
|
||||||
|
"recordingActionOpenIn": "Ouvrir dans une autre appli",
|
||||||
|
"recordingOpenNoAppError": "Aucune application de cet appareil ne peut lire cet enregistrement.",
|
||||||
"recordingDeleteConfirmMessage": "Cette action est irréversible.",
|
"recordingDeleteConfirmMessage": "Cette action est irréversible.",
|
||||||
"recordingDeleteConfirmTitle": "Supprimer l'enregistrement ?",
|
"recordingDeleteConfirmTitle": "Supprimer l'enregistrement ?",
|
||||||
"recordingRenameDialogTitle": "Renommer l'enregistrement",
|
"recordingRenameDialogTitle": "Renommer l'enregistrement",
|
||||||
@@ -907,7 +909,7 @@
|
|||||||
"premiumActivo": "Premium actif",
|
"premiumActivo": "Premium actif",
|
||||||
"premiumHojaTitulo": "Débloquer PluriWave Premium",
|
"premiumHojaTitulo": "Débloquer PluriWave Premium",
|
||||||
"premiumBeneficioSinAnuncios": "Aucune publicité dans toute l'application",
|
"premiumBeneficioSinAnuncios": "Aucune publicité dans toute l'application",
|
||||||
"premiumBeneficioAndroidAuto": "Android Auto",
|
"premiumBeneficioAndroidAuto": "Tout le catalogue dans Android Auto : favoris, mes stations et musique locale (gratuit : stations à la une uniquement)",
|
||||||
"premiumBeneficioGrabacion": "Enregistrement des stations",
|
"premiumBeneficioGrabacion": "Enregistrement des stations",
|
||||||
"premiumBeneficioVacaciones": "Périodes de vacances pour les alarmes",
|
"premiumBeneficioVacaciones": "Périodes de vacances pour les alarmes",
|
||||||
"premiumBeneficioAlarmasIlimitadas": "Alarmes illimitées (la version gratuite en autorise jusqu'à 5)",
|
"premiumBeneficioAlarmasIlimitadas": "Alarmes illimitées (la version gratuite en autorise jusqu'à 5)",
|
||||||
@@ -925,5 +927,10 @@
|
|||||||
"autoOrdenarPorCalidad": "Trier par qualité",
|
"autoOrdenarPorCalidad": "Trier par qualité",
|
||||||
"autoReproducirCarpeta": "Lire le dossier",
|
"autoReproducirCarpeta": "Lire le dossier",
|
||||||
"autoReproducirAleatorio": "Lecture aléatoire",
|
"autoReproducirAleatorio": "Lecture aléatoire",
|
||||||
"autoPistaSinNombre": "Piste sans nom"
|
"autoPistaSinNombre": "Piste sans nom",
|
||||||
|
"recordingsPrivateUseNotice": "Les enregistrements sont destinés à votre usage personnel. Merci de respecter les droits de chaque station et de ses contenus.",
|
||||||
|
"visualizerRealWaveTitle": "Onde audio réelle",
|
||||||
|
"visualizerRealWaveSubtitle": "Les barres suivent le son en cours. Désactivées, elles s'animent d'elles-mêmes.",
|
||||||
|
"visualizerRealWavePermissionExplanation": "Android n'autorise la lecture du niveau audio qu'avec l'autorisation du microphone. PluriWave n'écoute ni n'enregistre le microphone : il mesure seulement le son qu'il diffuse déjà. Vous pouvez le désactiver à tout moment.",
|
||||||
|
"visualizerRealWaveEnableAction": "Activer"
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -769,7 +769,9 @@
|
|||||||
"radioCountriesError": "देश लोड नहीं हो सके।",
|
"radioCountriesError": "देश लोड नहीं हो सके।",
|
||||||
"recordingActionDelete": "हटाएं",
|
"recordingActionDelete": "हटाएं",
|
||||||
"recordingActionRename": "नाम बदलें",
|
"recordingActionRename": "नाम बदलें",
|
||||||
"recordingActionShare": "शेयर करें",
|
"stationActionShare": "स्टेशन शेयर करें",
|
||||||
|
"recordingActionOpenIn": "दूसरे ऐप में खोलें",
|
||||||
|
"recordingOpenNoAppError": "इस डिवाइस पर ऐसा कोई ऐप नहीं है जो यह रिकॉर्डिंग चला सके।",
|
||||||
"recordingDeleteConfirmMessage": "इसे वापस नहीं लिया जा सकता।",
|
"recordingDeleteConfirmMessage": "इसे वापस नहीं लिया जा सकता।",
|
||||||
"recordingDeleteConfirmTitle": "रिकॉर्डिंग हटाएं?",
|
"recordingDeleteConfirmTitle": "रिकॉर्डिंग हटाएं?",
|
||||||
"recordingRenameDialogTitle": "रिकॉर्डिंग का नाम बदलें",
|
"recordingRenameDialogTitle": "रिकॉर्डिंग का नाम बदलें",
|
||||||
@@ -907,7 +909,7 @@
|
|||||||
"premiumActivo": "प्रीमियम सक्रिय",
|
"premiumActivo": "प्रीमियम सक्रिय",
|
||||||
"premiumHojaTitulo": "PluriWave Premium अनलॉक करें",
|
"premiumHojaTitulo": "PluriWave Premium अनलॉक करें",
|
||||||
"premiumBeneficioSinAnuncios": "पूरे ऐप में कोई विज्ञापन नहीं",
|
"premiumBeneficioSinAnuncios": "पूरे ऐप में कोई विज्ञापन नहीं",
|
||||||
"premiumBeneficioAndroidAuto": "Android Auto",
|
"premiumBeneficioAndroidAuto": "Android Auto में पूरी सूची: पसंदीदा, मेरे स्टेशन और स्थानीय संगीत (मुफ़्त: सिर्फ़ चुनिंदा स्टेशन)",
|
||||||
"premiumBeneficioGrabacion": "स्टेशन रिकॉर्डिंग",
|
"premiumBeneficioGrabacion": "स्टेशन रिकॉर्डिंग",
|
||||||
"premiumBeneficioVacaciones": "अलार्म के लिए छुट्टी की अवधि",
|
"premiumBeneficioVacaciones": "अलार्म के लिए छुट्टी की अवधि",
|
||||||
"premiumBeneficioAlarmasIlimitadas": "असीमित अलार्म (मुफ़्त प्लान में अधिकतम 5 की अनुमति है)",
|
"premiumBeneficioAlarmasIlimitadas": "असीमित अलार्म (मुफ़्त प्लान में अधिकतम 5 की अनुमति है)",
|
||||||
@@ -925,5 +927,10 @@
|
|||||||
"autoOrdenarPorCalidad": "गुणवत्ता के अनुसार क्रमबद्ध करें",
|
"autoOrdenarPorCalidad": "गुणवत्ता के अनुसार क्रमबद्ध करें",
|
||||||
"autoReproducirCarpeta": "फ़ोल्डर चलाएँ",
|
"autoReproducirCarpeta": "फ़ोल्डर चलाएँ",
|
||||||
"autoReproducirAleatorio": "शफ़ल चलाएँ",
|
"autoReproducirAleatorio": "शफ़ल चलाएँ",
|
||||||
"autoPistaSinNombre": "बिना नाम का ट्रैक"
|
"autoPistaSinNombre": "बिना नाम का ट्रैक",
|
||||||
|
"recordingsPrivateUseNotice": "रिकॉर्डिंग आपके निजी उपयोग के लिए हैं। कृपया हर स्टेशन और उसकी सामग्री के अधिकारों का सम्मान करें।",
|
||||||
|
"visualizerRealWaveTitle": "असली ऑडियो तरंग",
|
||||||
|
"visualizerRealWaveSubtitle": "बार चल रही आवाज़ का अनुसरण करते हैं। बंद होने पर वे अपने आप चलते हैं।",
|
||||||
|
"visualizerRealWavePermissionExplanation": "Android माइक्रोफ़ोन अनुमति के बिना ऑडियो स्तर पढ़ने नहीं देता। PluriWave माइक्रोफ़ोन को न सुनता है न रिकॉर्ड करता है: यह केवल पहले से बज रही आवाज़ मापता है। आप इसे कभी भी बंद कर सकते हैं।",
|
||||||
|
"visualizerRealWaveEnableAction": "चालू करें"
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -769,7 +769,9 @@
|
|||||||
"radioCountriesError": "Negara tidak dapat dimuat.",
|
"radioCountriesError": "Negara tidak dapat dimuat.",
|
||||||
"recordingActionDelete": "Hapus",
|
"recordingActionDelete": "Hapus",
|
||||||
"recordingActionRename": "Ganti nama",
|
"recordingActionRename": "Ganti nama",
|
||||||
"recordingActionShare": "Bagikan",
|
"stationActionShare": "Bagikan stasiun",
|
||||||
|
"recordingActionOpenIn": "Buka di aplikasi lain",
|
||||||
|
"recordingOpenNoAppError": "Tidak ada aplikasi di perangkat ini yang dapat memutar rekaman ini.",
|
||||||
"recordingDeleteConfirmMessage": "Tindakan ini tidak dapat dibatalkan.",
|
"recordingDeleteConfirmMessage": "Tindakan ini tidak dapat dibatalkan.",
|
||||||
"recordingDeleteConfirmTitle": "Hapus rekaman?",
|
"recordingDeleteConfirmTitle": "Hapus rekaman?",
|
||||||
"recordingRenameDialogTitle": "Ganti nama rekaman",
|
"recordingRenameDialogTitle": "Ganti nama rekaman",
|
||||||
@@ -907,7 +909,7 @@
|
|||||||
"premiumActivo": "Premium aktif",
|
"premiumActivo": "Premium aktif",
|
||||||
"premiumHojaTitulo": "Buka PluriWave Premium",
|
"premiumHojaTitulo": "Buka PluriWave Premium",
|
||||||
"premiumBeneficioSinAnuncios": "Tanpa iklan di seluruh aplikasi",
|
"premiumBeneficioSinAnuncios": "Tanpa iklan di seluruh aplikasi",
|
||||||
"premiumBeneficioAndroidAuto": "Android Auto",
|
"premiumBeneficioAndroidAuto": "Seluruh katalog di Android Auto: favorit, stasiun saya, dan musik lokal (gratis: hanya stasiun pilihan)",
|
||||||
"premiumBeneficioGrabacion": "Perekaman stasiun",
|
"premiumBeneficioGrabacion": "Perekaman stasiun",
|
||||||
"premiumBeneficioVacaciones": "Rentang liburan untuk alarm",
|
"premiumBeneficioVacaciones": "Rentang liburan untuk alarm",
|
||||||
"premiumBeneficioAlarmasIlimitadas": "Alarm tanpa batas (paket gratis mengizinkan hingga 5)",
|
"premiumBeneficioAlarmasIlimitadas": "Alarm tanpa batas (paket gratis mengizinkan hingga 5)",
|
||||||
@@ -925,5 +927,10 @@
|
|||||||
"autoOrdenarPorCalidad": "Urutkan menurut kualitas",
|
"autoOrdenarPorCalidad": "Urutkan menurut kualitas",
|
||||||
"autoReproducirCarpeta": "Putar folder",
|
"autoReproducirCarpeta": "Putar folder",
|
||||||
"autoReproducirAleatorio": "Putar acak",
|
"autoReproducirAleatorio": "Putar acak",
|
||||||
"autoPistaSinNombre": "Trek tanpa nama"
|
"autoPistaSinNombre": "Trek tanpa nama",
|
||||||
|
"recordingsPrivateUseNotice": "Rekaman ditujukan untuk penggunaan pribadi Anda. Hormati hak setiap stasiun dan hak atas kontennya.",
|
||||||
|
"visualizerRealWaveTitle": "Gelombang audio asli",
|
||||||
|
"visualizerRealWaveSubtitle": "Bilah mengikuti suara yang sedang diputar. Saat nonaktif, bilah bergerak sendiri.",
|
||||||
|
"visualizerRealWavePermissionExplanation": "Android hanya mengizinkan pembacaan level audio dengan izin mikrofon. PluriWave tidak mendengarkan atau merekam mikrofon: aplikasi hanya mengukur suara yang sedang diputar. Anda dapat menonaktifkannya kapan saja.",
|
||||||
|
"visualizerRealWaveEnableAction": "Aktifkan"
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -769,7 +769,9 @@
|
|||||||
"radioCountriesError": "Non è stato possibile caricare i paesi.",
|
"radioCountriesError": "Non è stato possibile caricare i paesi.",
|
||||||
"recordingActionDelete": "Elimina",
|
"recordingActionDelete": "Elimina",
|
||||||
"recordingActionRename": "Rinomina",
|
"recordingActionRename": "Rinomina",
|
||||||
"recordingActionShare": "Condividi",
|
"stationActionShare": "Condividi stazione",
|
||||||
|
"recordingActionOpenIn": "Apri in un'altra app",
|
||||||
|
"recordingOpenNoAppError": "Nessuna app su questo dispositivo può riprodurre questa registrazione.",
|
||||||
"recordingDeleteConfirmMessage": "Questa azione non può essere annullata.",
|
"recordingDeleteConfirmMessage": "Questa azione non può essere annullata.",
|
||||||
"recordingDeleteConfirmTitle": "Eliminare la registrazione?",
|
"recordingDeleteConfirmTitle": "Eliminare la registrazione?",
|
||||||
"recordingRenameDialogTitle": "Rinomina registrazione",
|
"recordingRenameDialogTitle": "Rinomina registrazione",
|
||||||
@@ -907,7 +909,7 @@
|
|||||||
"premiumActivo": "Premium attivo",
|
"premiumActivo": "Premium attivo",
|
||||||
"premiumHojaTitulo": "Sblocca PluriWave Premium",
|
"premiumHojaTitulo": "Sblocca PluriWave Premium",
|
||||||
"premiumBeneficioSinAnuncios": "Nessuna pubblicità in tutta l'app",
|
"premiumBeneficioSinAnuncios": "Nessuna pubblicità in tutta l'app",
|
||||||
"premiumBeneficioAndroidAuto": "Android Auto",
|
"premiumBeneficioAndroidAuto": "Tutto il catalogo in Android Auto: preferiti, le mie stazioni e musica locale (gratis: solo stazioni in evidenza)",
|
||||||
"premiumBeneficioGrabacion": "Registrazione delle stazioni",
|
"premiumBeneficioGrabacion": "Registrazione delle stazioni",
|
||||||
"premiumBeneficioVacaciones": "Intervalli di vacanza per le sveglie",
|
"premiumBeneficioVacaciones": "Intervalli di vacanza per le sveglie",
|
||||||
"premiumBeneficioAlarmasIlimitadas": "Sveglie illimitate (il piano gratuito ne consente fino a 5)",
|
"premiumBeneficioAlarmasIlimitadas": "Sveglie illimitate (il piano gratuito ne consente fino a 5)",
|
||||||
@@ -925,5 +927,10 @@
|
|||||||
"autoOrdenarPorCalidad": "Ordina per qualità",
|
"autoOrdenarPorCalidad": "Ordina per qualità",
|
||||||
"autoReproducirCarpeta": "Riproduci cartella",
|
"autoReproducirCarpeta": "Riproduci cartella",
|
||||||
"autoReproducirAleatorio": "Riproduzione casuale",
|
"autoReproducirAleatorio": "Riproduzione casuale",
|
||||||
"autoPistaSinNombre": "Traccia senza nome"
|
"autoPistaSinNombre": "Traccia senza nome",
|
||||||
|
"recordingsPrivateUseNotice": "Le registrazioni sono per il tuo uso personale. Rispetta i diritti di ogni stazione e dei suoi contenuti.",
|
||||||
|
"visualizerRealWaveTitle": "Onda audio reale",
|
||||||
|
"visualizerRealWaveSubtitle": "Le barre seguono il suono in riproduzione. Se disattivata, si animano da sole.",
|
||||||
|
"visualizerRealWavePermissionExplanation": "Android consente di leggere il livello audio solo con l'autorizzazione del microfono. PluriWave non ascolta né registra il microfono: misura soltanto il suono che sta già riproducendo. Puoi disattivarlo quando vuoi.",
|
||||||
|
"visualizerRealWaveEnableAction": "Attiva"
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -769,7 +769,9 @@
|
|||||||
"radioCountriesError": "国の一覧を読み込めませんでした。",
|
"radioCountriesError": "国の一覧を読み込めませんでした。",
|
||||||
"recordingActionDelete": "削除",
|
"recordingActionDelete": "削除",
|
||||||
"recordingActionRename": "名前を変更",
|
"recordingActionRename": "名前を変更",
|
||||||
"recordingActionShare": "共有",
|
"stationActionShare": "放送局を共有",
|
||||||
|
"recordingActionOpenIn": "別のアプリで開く",
|
||||||
|
"recordingOpenNoAppError": "この端末には、この録音を再生できるアプリがありません。",
|
||||||
"recordingDeleteConfirmMessage": "この操作は元に戻せません。",
|
"recordingDeleteConfirmMessage": "この操作は元に戻せません。",
|
||||||
"recordingDeleteConfirmTitle": "録音を削除しますか?",
|
"recordingDeleteConfirmTitle": "録音を削除しますか?",
|
||||||
"recordingRenameDialogTitle": "録音の名前を変更",
|
"recordingRenameDialogTitle": "録音の名前を変更",
|
||||||
@@ -907,7 +909,7 @@
|
|||||||
"premiumActivo": "プレミアム有効",
|
"premiumActivo": "プレミアム有効",
|
||||||
"premiumHojaTitulo": "PluriWave Premiumのロックを解除",
|
"premiumHojaTitulo": "PluriWave Premiumのロックを解除",
|
||||||
"premiumBeneficioSinAnuncios": "アプリ全体で広告なし",
|
"premiumBeneficioSinAnuncios": "アプリ全体で広告なし",
|
||||||
"premiumBeneficioAndroidAuto": "Android Auto",
|
"premiumBeneficioAndroidAuto": "Android Auto でカタログ全体を利用: お気に入り、マイ局、ローカル音楽 (無料版はおすすめ局のみ)",
|
||||||
"premiumBeneficioGrabacion": "放送局の録音",
|
"premiumBeneficioGrabacion": "放送局の録音",
|
||||||
"premiumBeneficioVacaciones": "アラームの休暇期間設定",
|
"premiumBeneficioVacaciones": "アラームの休暇期間設定",
|
||||||
"premiumBeneficioAlarmasIlimitadas": "アラーム数無制限(無料プランは5個まで)",
|
"premiumBeneficioAlarmasIlimitadas": "アラーム数無制限(無料プランは5個まで)",
|
||||||
@@ -925,5 +927,10 @@
|
|||||||
"autoOrdenarPorCalidad": "音質順に並べ替え",
|
"autoOrdenarPorCalidad": "音質順に並べ替え",
|
||||||
"autoReproducirCarpeta": "フォルダを再生",
|
"autoReproducirCarpeta": "フォルダを再生",
|
||||||
"autoReproducirAleatorio": "シャッフル再生",
|
"autoReproducirAleatorio": "シャッフル再生",
|
||||||
"autoPistaSinNombre": "名称未設定のトラック"
|
"autoPistaSinNombre": "名称未設定のトラック",
|
||||||
|
"recordingsPrivateUseNotice": "録音はあなた個人の利用のためのものです。各放送局とその内容に関する権利を尊重してください。",
|
||||||
|
"visualizerRealWaveTitle": "実際の音声波形",
|
||||||
|
"visualizerRealWaveSubtitle": "バーが再生中の音に合わせて動きます。オフのときは独自に動きます。",
|
||||||
|
"visualizerRealWavePermissionExplanation": "Android では、マイクの権限がないと音声レベルを読み取れません。PluriWave がマイクを聞いたり録音したりすることはありません。再生中の音の大きさを測るだけです。いつでもオフにできます。",
|
||||||
|
"visualizerRealWaveEnableAction": "オンにする"
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -769,7 +769,9 @@
|
|||||||
"radioCountriesError": "Não foi possível carregar os países.",
|
"radioCountriesError": "Não foi possível carregar os países.",
|
||||||
"recordingActionDelete": "Excluir",
|
"recordingActionDelete": "Excluir",
|
||||||
"recordingActionRename": "Renomear",
|
"recordingActionRename": "Renomear",
|
||||||
"recordingActionShare": "Compartilhar",
|
"stationActionShare": "Compartilhar estação",
|
||||||
|
"recordingActionOpenIn": "Abrir em outro app",
|
||||||
|
"recordingOpenNoAppError": "Nenhum app deste dispositivo consegue reproduzir esta gravação.",
|
||||||
"recordingDeleteConfirmMessage": "Esta ação não pode ser desfeita.",
|
"recordingDeleteConfirmMessage": "Esta ação não pode ser desfeita.",
|
||||||
"recordingDeleteConfirmTitle": "Excluir gravação?",
|
"recordingDeleteConfirmTitle": "Excluir gravação?",
|
||||||
"recordingRenameDialogTitle": "Renomear gravação",
|
"recordingRenameDialogTitle": "Renomear gravação",
|
||||||
@@ -907,7 +909,7 @@
|
|||||||
"premiumActivo": "Premium ativo",
|
"premiumActivo": "Premium ativo",
|
||||||
"premiumHojaTitulo": "Desbloqueie o PluriWave Premium",
|
"premiumHojaTitulo": "Desbloqueie o PluriWave Premium",
|
||||||
"premiumBeneficioSinAnuncios": "Sem anúncios em todo o app",
|
"premiumBeneficioSinAnuncios": "Sem anúncios em todo o app",
|
||||||
"premiumBeneficioAndroidAuto": "Android Auto",
|
"premiumBeneficioAndroidAuto": "Todo o catálogo no Android Auto: favoritos, as minhas estações e música local (grátis: apenas estações em destaque)",
|
||||||
"premiumBeneficioGrabacion": "Gravação de emissoras",
|
"premiumBeneficioGrabacion": "Gravação de emissoras",
|
||||||
"premiumBeneficioVacaciones": "Períodos de férias para os alarmes",
|
"premiumBeneficioVacaciones": "Períodos de férias para os alarmes",
|
||||||
"premiumBeneficioAlarmasIlimitadas": "Alarmes ilimitados (o plano gratuito permite até 5)",
|
"premiumBeneficioAlarmasIlimitadas": "Alarmes ilimitados (o plano gratuito permite até 5)",
|
||||||
@@ -925,5 +927,10 @@
|
|||||||
"autoOrdenarPorCalidad": "Ordenar por qualidade",
|
"autoOrdenarPorCalidad": "Ordenar por qualidade",
|
||||||
"autoReproducirCarpeta": "Reproduzir pasta",
|
"autoReproducirCarpeta": "Reproduzir pasta",
|
||||||
"autoReproducirAleatorio": "Reprodução aleatória",
|
"autoReproducirAleatorio": "Reprodução aleatória",
|
||||||
"autoPistaSinNombre": "Faixa sem nome"
|
"autoPistaSinNombre": "Faixa sem nome",
|
||||||
|
"recordingsPrivateUseNotice": "As gravações são para o teu uso pessoal. Respeita os direitos de cada estação e dos seus conteúdos.",
|
||||||
|
"visualizerRealWaveTitle": "Onda de áudio real",
|
||||||
|
"visualizerRealWaveSubtitle": "As barras seguem o som que está a tocar. Desativado, animam-se sozinhas.",
|
||||||
|
"visualizerRealWavePermissionExplanation": "O Android só permite ler o nível de áudio com a permissão do microfone. O PluriWave não escuta nem grava o microfone: apenas mede o som que já está a reproduzir. Podes desativar isto quando quiseres.",
|
||||||
|
"visualizerRealWaveEnableAction": "Ativar"
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -769,7 +769,9 @@
|
|||||||
"radioCountriesError": "Не удалось загрузить страны.",
|
"radioCountriesError": "Не удалось загрузить страны.",
|
||||||
"recordingActionDelete": "Удалить",
|
"recordingActionDelete": "Удалить",
|
||||||
"recordingActionRename": "Переименовать",
|
"recordingActionRename": "Переименовать",
|
||||||
"recordingActionShare": "Поделиться",
|
"stationActionShare": "Поделиться станцией",
|
||||||
|
"recordingActionOpenIn": "Открыть в другом приложении",
|
||||||
|
"recordingOpenNoAppError": "На этом устройстве нет приложения, способного воспроизвести эту запись.",
|
||||||
"recordingDeleteConfirmMessage": "Это действие нельзя отменить.",
|
"recordingDeleteConfirmMessage": "Это действие нельзя отменить.",
|
||||||
"recordingDeleteConfirmTitle": "Удалить запись?",
|
"recordingDeleteConfirmTitle": "Удалить запись?",
|
||||||
"recordingRenameDialogTitle": "Переименовать запись",
|
"recordingRenameDialogTitle": "Переименовать запись",
|
||||||
@@ -907,7 +909,7 @@
|
|||||||
"premiumActivo": "Премиум активен",
|
"premiumActivo": "Премиум активен",
|
||||||
"premiumHojaTitulo": "Разблокировать PluriWave Premium",
|
"premiumHojaTitulo": "Разблокировать PluriWave Premium",
|
||||||
"premiumBeneficioSinAnuncios": "Никакой рекламы во всём приложении",
|
"premiumBeneficioSinAnuncios": "Никакой рекламы во всём приложении",
|
||||||
"premiumBeneficioAndroidAuto": "Android Auto",
|
"premiumBeneficioAndroidAuto": "Весь каталог в Android Auto: избранное, мои станции и локальная музыка (бесплатно: только рекомендуемые)",
|
||||||
"premiumBeneficioGrabacion": "Запись радиостанций",
|
"premiumBeneficioGrabacion": "Запись радиостанций",
|
||||||
"premiumBeneficioVacaciones": "Периоды отпуска для будильников",
|
"premiumBeneficioVacaciones": "Периоды отпуска для будильников",
|
||||||
"premiumBeneficioAlarmasIlimitadas": "Неограниченное количество будильников (бесплатный план позволяет до 5)",
|
"premiumBeneficioAlarmasIlimitadas": "Неограниченное количество будильников (бесплатный план позволяет до 5)",
|
||||||
@@ -925,5 +927,10 @@
|
|||||||
"autoOrdenarPorCalidad": "Сортировать по качеству",
|
"autoOrdenarPorCalidad": "Сортировать по качеству",
|
||||||
"autoReproducirCarpeta": "Воспроизвести папку",
|
"autoReproducirCarpeta": "Воспроизвести папку",
|
||||||
"autoReproducirAleatorio": "Случайное воспроизведение",
|
"autoReproducirAleatorio": "Случайное воспроизведение",
|
||||||
"autoPistaSinNombre": "Трек без названия"
|
"autoPistaSinNombre": "Трек без названия",
|
||||||
|
"recordingsPrivateUseNotice": "Записи предназначены только для личного использования. Уважайте права станций и их контента.",
|
||||||
|
"visualizerRealWaveTitle": "Реальная звуковая волна",
|
||||||
|
"visualizerRealWaveSubtitle": "Полосы следуют за звучащим аудио. Если выключено, они движутся сами по себе.",
|
||||||
|
"visualizerRealWavePermissionExplanation": "Android разрешает считывать уровень звука только с разрешением на микрофон. PluriWave не слушает и не записывает микрофон: он лишь измеряет уже воспроизводимый звук. Отключить можно в любой момент.",
|
||||||
|
"visualizerRealWaveEnableAction": "Включить"
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -769,7 +769,9 @@
|
|||||||
"radioCountriesError": "无法加载国家列表。",
|
"radioCountriesError": "无法加载国家列表。",
|
||||||
"recordingActionDelete": "删除",
|
"recordingActionDelete": "删除",
|
||||||
"recordingActionRename": "重命名",
|
"recordingActionRename": "重命名",
|
||||||
"recordingActionShare": "分享",
|
"stationActionShare": "分享电台",
|
||||||
|
"recordingActionOpenIn": "用其他应用打开",
|
||||||
|
"recordingOpenNoAppError": "此设备上没有可播放该录音的应用。",
|
||||||
"recordingDeleteConfirmMessage": "此操作无法撤销。",
|
"recordingDeleteConfirmMessage": "此操作无法撤销。",
|
||||||
"recordingDeleteConfirmTitle": "删除录音?",
|
"recordingDeleteConfirmTitle": "删除录音?",
|
||||||
"recordingRenameDialogTitle": "重命名录音",
|
"recordingRenameDialogTitle": "重命名录音",
|
||||||
@@ -907,7 +909,7 @@
|
|||||||
"premiumActivo": "高级版已解锁",
|
"premiumActivo": "高级版已解锁",
|
||||||
"premiumHojaTitulo": "解锁 PluriWave Premium",
|
"premiumHojaTitulo": "解锁 PluriWave Premium",
|
||||||
"premiumBeneficioSinAnuncios": "全应用无广告",
|
"premiumBeneficioSinAnuncios": "全应用无广告",
|
||||||
"premiumBeneficioAndroidAuto": "Android Auto",
|
"premiumBeneficioAndroidAuto": "在 Android Auto 中使用完整电台库:收藏、我的电台和本地音乐(免费版仅限精选电台)",
|
||||||
"premiumBeneficioGrabacion": "电台录音",
|
"premiumBeneficioGrabacion": "电台录音",
|
||||||
"premiumBeneficioVacaciones": "闹钟的假期时间段",
|
"premiumBeneficioVacaciones": "闹钟的假期时间段",
|
||||||
"premiumBeneficioAlarmasIlimitadas": "无限闹钟(免费版最多支持5个)",
|
"premiumBeneficioAlarmasIlimitadas": "无限闹钟(免费版最多支持5个)",
|
||||||
@@ -925,5 +927,10 @@
|
|||||||
"autoOrdenarPorCalidad": "按音质排序",
|
"autoOrdenarPorCalidad": "按音质排序",
|
||||||
"autoReproducirCarpeta": "播放文件夹",
|
"autoReproducirCarpeta": "播放文件夹",
|
||||||
"autoReproducirAleatorio": "随机播放",
|
"autoReproducirAleatorio": "随机播放",
|
||||||
"autoPistaSinNombre": "未命名曲目"
|
"autoPistaSinNombre": "未命名曲目",
|
||||||
|
"recordingsPrivateUseNotice": "录音仅供你个人使用。请尊重各电台及其内容的权利。",
|
||||||
|
"visualizerRealWaveTitle": "真实音频波形",
|
||||||
|
"visualizerRealWaveSubtitle": "音条会跟随正在播放的声音。关闭时,音条会自行跳动。",
|
||||||
|
"visualizerRealWavePermissionExplanation": "Android 只有在获得麦克风权限后才允许读取音频电平。PluriWave 不会监听或录制麦克风,只测量它正在播放的声音。你可以随时关闭此功能。",
|
||||||
|
"visualizerRealWaveEnableAction": "开启"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -904,11 +904,23 @@ abstract class AppLocalizations {
|
|||||||
/// **'Renombrar'**
|
/// **'Renombrar'**
|
||||||
String get recordingActionRename;
|
String get recordingActionRename;
|
||||||
|
|
||||||
/// No description provided for @recordingActionShare.
|
/// No description provided for @stationActionShare.
|
||||||
///
|
///
|
||||||
/// In es, this message translates to:
|
/// In es, this message translates to:
|
||||||
/// **'Compartir'**
|
/// **'Compartir emisora'**
|
||||||
String get recordingActionShare;
|
String get stationActionShare;
|
||||||
|
|
||||||
|
/// No description provided for @recordingActionOpenIn.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'Abrir en otra app'**
|
||||||
|
String get recordingActionOpenIn;
|
||||||
|
|
||||||
|
/// No description provided for @recordingOpenNoAppError.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'No hay ninguna app en este dispositivo que pueda reproducir la grabación.'**
|
||||||
|
String get recordingOpenNoAppError;
|
||||||
|
|
||||||
/// No description provided for @recordingActionDelete.
|
/// No description provided for @recordingActionDelete.
|
||||||
///
|
///
|
||||||
@@ -3383,7 +3395,7 @@ abstract class AppLocalizations {
|
|||||||
/// No description provided for @premiumBeneficioAndroidAuto.
|
/// No description provided for @premiumBeneficioAndroidAuto.
|
||||||
///
|
///
|
||||||
/// In es, this message translates to:
|
/// In es, this message translates to:
|
||||||
/// **'Android Auto'**
|
/// **'Todo el catálogo en Android Auto: favoritos, mis emisoras y música local (gratis: solo destacadas)'**
|
||||||
String get premiumBeneficioAndroidAuto;
|
String get premiumBeneficioAndroidAuto;
|
||||||
|
|
||||||
/// No description provided for @premiumBeneficioGrabacion.
|
/// No description provided for @premiumBeneficioGrabacion.
|
||||||
@@ -3493,6 +3505,36 @@ abstract class AppLocalizations {
|
|||||||
/// In es, this message translates to:
|
/// In es, this message translates to:
|
||||||
/// **'Pista sin nombre'**
|
/// **'Pista sin nombre'**
|
||||||
String get autoPistaSinNombre;
|
String get autoPistaSinNombre;
|
||||||
|
|
||||||
|
/// No description provided for @recordingsPrivateUseNotice.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'Las grabaciones son para tu uso personal. Respeta los derechos de cada emisora y de sus contenidos.'**
|
||||||
|
String get recordingsPrivateUseNotice;
|
||||||
|
|
||||||
|
/// No description provided for @visualizerRealWaveTitle.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'Onda real del audio'**
|
||||||
|
String get visualizerRealWaveTitle;
|
||||||
|
|
||||||
|
/// No description provided for @visualizerRealWaveSubtitle.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'Las barras siguen el sonido que está sonando. Desactivado, se animan solas.'**
|
||||||
|
String get visualizerRealWaveSubtitle;
|
||||||
|
|
||||||
|
/// No description provided for @visualizerRealWavePermissionExplanation.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'Android solo deja leer el nivel del audio con el permiso de micrófono. PluriWave no escucha ni graba el micrófono: solo mide el sonido que ya está reproduciendo. Puedes desactivarlo cuando quieras.'**
|
||||||
|
String get visualizerRealWavePermissionExplanation;
|
||||||
|
|
||||||
|
/// No description provided for @visualizerRealWaveEnableAction.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'Activar'**
|
||||||
|
String get visualizerRealWaveEnableAction;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -456,7 +456,14 @@ class AppLocalizationsAr extends AppLocalizations {
|
|||||||
String get recordingActionRename => 'إعادة تسمية';
|
String get recordingActionRename => 'إعادة تسمية';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recordingActionShare => 'مشاركة';
|
String get stationActionShare => 'مشاركة المحطة';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionOpenIn => 'فتح في تطبيق آخر';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingOpenNoAppError =>
|
||||||
|
'لا يوجد تطبيق على هذا الجهاز يمكنه تشغيل هذا التسجيل.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recordingActionDelete => 'حذف';
|
String get recordingActionDelete => 'حذف';
|
||||||
@@ -1871,7 +1878,8 @@ class AppLocalizationsAr extends AppLocalizations {
|
|||||||
String get premiumBeneficioSinAnuncios => 'بدون إعلانات في التطبيق بالكامل';
|
String get premiumBeneficioSinAnuncios => 'بدون إعلانات في التطبيق بالكامل';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get premiumBeneficioAndroidAuto => 'Android Auto';
|
String get premiumBeneficioAndroidAuto =>
|
||||||
|
'الكتالوج الكامل في Android Auto: المفضلة ومحطاتي والموسيقى المحلية (مجانًا: المحطات المميزة فقط)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get premiumBeneficioGrabacion => 'تسجيل المحطات';
|
String get premiumBeneficioGrabacion => 'تسجيل المحطات';
|
||||||
@@ -1930,4 +1938,22 @@ class AppLocalizationsAr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get autoPistaSinNombre => 'مقطع بلا اسم';
|
String get autoPistaSinNombre => 'مقطع بلا اسم';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsPrivateUseNotice =>
|
||||||
|
'التسجيلات مخصصة لاستخدامك الشخصي. يُرجى احترام حقوق كل محطة وحقوق محتواها.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveTitle => 'الموجة الحقيقية للصوت';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveSubtitle =>
|
||||||
|
'تتبع الأعمدة الصوت الجاري تشغيله. وعند الإيقاف تتحرك من تلقاء نفسها.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWavePermissionExplanation =>
|
||||||
|
'لا يسمح أندرويد بقراءة مستوى الصوت إلا بإذن الميكروفون. لا يستمع PluriWave إلى الميكروفون ولا يسجّله: فهو يقيس الصوت الذي يشغّله بالفعل فقط. ويمكنك إيقاف ذلك متى شئت.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveEnableAction => 'تفعيل';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -461,7 +461,14 @@ class AppLocalizationsBn extends AppLocalizations {
|
|||||||
String get recordingActionRename => 'নাম পরিবর্তন করুন';
|
String get recordingActionRename => 'নাম পরিবর্তন করুন';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recordingActionShare => 'শেয়ার করুন';
|
String get stationActionShare => 'স্টেশন শেয়ার করুন';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionOpenIn => 'অন্য অ্যাপে খুলুন';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingOpenNoAppError =>
|
||||||
|
'এই ডিভাইসে এই রেকর্ডিং চালাতে পারে এমন কোনো অ্যাপ নেই।';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recordingActionDelete => 'মুছে ফেলুন';
|
String get recordingActionDelete => 'মুছে ফেলুন';
|
||||||
@@ -1882,7 +1889,8 @@ class AppLocalizationsBn extends AppLocalizations {
|
|||||||
String get premiumBeneficioSinAnuncios => 'পুরো অ্যাপে কোনো বিজ্ঞাপন নেই';
|
String get premiumBeneficioSinAnuncios => 'পুরো অ্যাপে কোনো বিজ্ঞাপন নেই';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get premiumBeneficioAndroidAuto => 'Android Auto';
|
String get premiumBeneficioAndroidAuto =>
|
||||||
|
'Android Auto-তে সম্পূর্ণ তালিকা: প্রিয়, আমার স্টেশন ও স্থানীয় সংগীত (ফ্রি: শুধু বাছাই করা স্টেশন)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get premiumBeneficioGrabacion => 'স্টেশন রেকর্ডিং';
|
String get premiumBeneficioGrabacion => 'স্টেশন রেকর্ডিং';
|
||||||
@@ -1942,4 +1950,22 @@ class AppLocalizationsBn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get autoPistaSinNombre => 'নামহীন ট্র্যাক';
|
String get autoPistaSinNombre => 'নামহীন ট্র্যাক';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsPrivateUseNotice =>
|
||||||
|
'রেকর্ডিংগুলি আপনার ব্যক্তিগত ব্যবহারের জন্য। প্রতিটি স্টেশন ও তার কনটেন্টের অধিকারকে সম্মান করুন।';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveTitle => 'আসল অডিও তরঙ্গ';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveSubtitle =>
|
||||||
|
'বারগুলি চলমান শব্দ অনুসরণ করে। বন্ধ থাকলে সেগুলি নিজে থেকেই চলে।';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWavePermissionExplanation =>
|
||||||
|
'মাইক্রোফোন অনুমতি ছাড়া Android অডিও লেভেল পড়তে দেয় না। PluriWave মাইক্রোফোন শোনে না বা রেকর্ড করে না: এটি শুধু চলমান শব্দের মাত্রা মাপে। আপনি যেকোনো সময় এটি বন্ধ করতে পারেন।';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveEnableAction => 'চালু করুন';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -464,7 +464,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get recordingActionRename => 'Umbenennen';
|
String get recordingActionRename => 'Umbenennen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recordingActionShare => 'Teilen';
|
String get stationActionShare => 'Sender teilen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionOpenIn => 'In anderer App öffnen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingOpenNoAppError =>
|
||||||
|
'Keine App auf diesem Gerät kann diese Aufnahme abspielen.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recordingActionDelete => 'Löschen';
|
String get recordingActionDelete => 'Löschen';
|
||||||
@@ -1896,7 +1903,8 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get premiumBeneficioSinAnuncios => 'Keine Werbung in der gesamten App';
|
String get premiumBeneficioSinAnuncios => 'Keine Werbung in der gesamten App';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get premiumBeneficioAndroidAuto => 'Android Auto';
|
String get premiumBeneficioAndroidAuto =>
|
||||||
|
'Der komplette Katalog in Android Auto: Favoriten, meine Sender und lokale Musik (gratis: nur empfohlene Sender)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get premiumBeneficioGrabacion => 'Sender aufnehmen';
|
String get premiumBeneficioGrabacion => 'Sender aufnehmen';
|
||||||
@@ -1955,4 +1963,22 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get autoPistaSinNombre => 'Unbenannter Titel';
|
String get autoPistaSinNombre => 'Unbenannter Titel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsPrivateUseNotice =>
|
||||||
|
'Aufnahmen sind für deinen persönlichen Gebrauch bestimmt. Bitte respektiere die Rechte des Senders und seiner Inhalte.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveTitle => 'Echte Audio-Wellenform';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveSubtitle =>
|
||||||
|
'Die Balken folgen dem laufenden Ton. Ausgeschaltet bewegen sie sich von selbst.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWavePermissionExplanation =>
|
||||||
|
'Android erlaubt das Auslesen des Audiopegels nur mit der Mikrofonberechtigung. PluriWave hört das Mikrofon weder ab noch nimmt es auf: Es misst nur den Ton, den es ohnehin abspielt. Du kannst das jederzeit wieder ausschalten.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveEnableAction => 'Aktivieren';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -458,7 +458,14 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get recordingActionRename => 'Rename';
|
String get recordingActionRename => 'Rename';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recordingActionShare => 'Share';
|
String get stationActionShare => 'Share station';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionOpenIn => 'Open in another app';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingOpenNoAppError =>
|
||||||
|
'No app on this device can play this recording.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recordingActionDelete => 'Delete';
|
String get recordingActionDelete => 'Delete';
|
||||||
@@ -1875,7 +1882,8 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get premiumBeneficioSinAnuncios => 'No ads anywhere in the app';
|
String get premiumBeneficioSinAnuncios => 'No ads anywhere in the app';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get premiumBeneficioAndroidAuto => 'Android Auto';
|
String get premiumBeneficioAndroidAuto =>
|
||||||
|
'Your full catalogue in Android Auto: favourites, my stations and local music (free: featured stations only)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get premiumBeneficioGrabacion => 'Station recording';
|
String get premiumBeneficioGrabacion => 'Station recording';
|
||||||
@@ -1935,4 +1943,22 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get autoPistaSinNombre => 'Untitled track';
|
String get autoPistaSinNombre => 'Untitled track';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsPrivateUseNotice =>
|
||||||
|
'Recordings are for your own personal use. Please respect each station\'s rights and those of its content.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveTitle => 'Real audio waveform';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveSubtitle =>
|
||||||
|
'The bars follow the sound that is playing. When off, they animate on their own.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWavePermissionExplanation =>
|
||||||
|
'Android only allows reading the audio level with the microphone permission. PluriWave never listens to or records the microphone: it only measures the sound it is already playing. You can turn this off at any time.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveEnableAction => 'Turn on';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -462,7 +462,14 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get recordingActionRename => 'Renombrar';
|
String get recordingActionRename => 'Renombrar';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recordingActionShare => 'Compartir';
|
String get stationActionShare => 'Compartir emisora';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionOpenIn => 'Abrir en otra app';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingOpenNoAppError =>
|
||||||
|
'No hay ninguna app en este dispositivo que pueda reproducir la grabación.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recordingActionDelete => 'Eliminar';
|
String get recordingActionDelete => 'Eliminar';
|
||||||
@@ -1889,7 +1896,8 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get premiumBeneficioSinAnuncios => 'Sin publicidad en toda la app';
|
String get premiumBeneficioSinAnuncios => 'Sin publicidad en toda la app';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get premiumBeneficioAndroidAuto => 'Android Auto';
|
String get premiumBeneficioAndroidAuto =>
|
||||||
|
'Todo el catálogo en Android Auto: favoritos, mis emisoras y música local (gratis: solo destacadas)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get premiumBeneficioGrabacion => 'Grabación de emisoras';
|
String get premiumBeneficioGrabacion => 'Grabación de emisoras';
|
||||||
@@ -1950,4 +1958,22 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get autoPistaSinNombre => 'Pista sin nombre';
|
String get autoPistaSinNombre => 'Pista sin nombre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsPrivateUseNotice =>
|
||||||
|
'Las grabaciones son para tu uso personal. Respeta los derechos de cada emisora y de sus contenidos.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveTitle => 'Onda real del audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveSubtitle =>
|
||||||
|
'Las barras siguen el sonido que está sonando. Desactivado, se animan solas.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWavePermissionExplanation =>
|
||||||
|
'Android solo deja leer el nivel del audio con el permiso de micrófono. PluriWave no escucha ni graba el micrófono: solo mide el sonido que ya está reproduciendo. Puedes desactivarlo cuando quieras.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveEnableAction => 'Activar';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -467,7 +467,14 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get recordingActionRename => 'Renommer';
|
String get recordingActionRename => 'Renommer';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recordingActionShare => 'Partager';
|
String get stationActionShare => 'Partager la station';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionOpenIn => 'Ouvrir dans une autre appli';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingOpenNoAppError =>
|
||||||
|
'Aucune application de cet appareil ne peut lire cet enregistrement.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recordingActionDelete => 'Supprimer';
|
String get recordingActionDelete => 'Supprimer';
|
||||||
@@ -1903,7 +1910,8 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
'Aucune publicité dans toute l\'application';
|
'Aucune publicité dans toute l\'application';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get premiumBeneficioAndroidAuto => 'Android Auto';
|
String get premiumBeneficioAndroidAuto =>
|
||||||
|
'Tout le catalogue dans Android Auto : favoris, mes stations et musique locale (gratuit : stations à la une uniquement)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get premiumBeneficioGrabacion => 'Enregistrement des stations';
|
String get premiumBeneficioGrabacion => 'Enregistrement des stations';
|
||||||
@@ -1964,4 +1972,22 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get autoPistaSinNombre => 'Piste sans nom';
|
String get autoPistaSinNombre => 'Piste sans nom';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsPrivateUseNotice =>
|
||||||
|
'Les enregistrements sont destinés à votre usage personnel. Merci de respecter les droits de chaque station et de ses contenus.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveTitle => 'Onde audio réelle';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveSubtitle =>
|
||||||
|
'Les barres suivent le son en cours. Désactivées, elles s\'animent d\'elles-mêmes.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWavePermissionExplanation =>
|
||||||
|
'Android n\'autorise la lecture du niveau audio qu\'avec l\'autorisation du microphone. PluriWave n\'écoute ni n\'enregistre le microphone : il mesure seulement le son qu\'il diffuse déjà. Vous pouvez le désactiver à tout moment.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveEnableAction => 'Activer';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -459,7 +459,14 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String get recordingActionRename => 'नाम बदलें';
|
String get recordingActionRename => 'नाम बदलें';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recordingActionShare => 'शेयर करें';
|
String get stationActionShare => 'स्टेशन शेयर करें';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionOpenIn => 'दूसरे ऐप में खोलें';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingOpenNoAppError =>
|
||||||
|
'इस डिवाइस पर ऐसा कोई ऐप नहीं है जो यह रिकॉर्डिंग चला सके।';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recordingActionDelete => 'हटाएं';
|
String get recordingActionDelete => 'हटाएं';
|
||||||
@@ -1875,7 +1882,8 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String get premiumBeneficioSinAnuncios => 'पूरे ऐप में कोई विज्ञापन नहीं';
|
String get premiumBeneficioSinAnuncios => 'पूरे ऐप में कोई विज्ञापन नहीं';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get premiumBeneficioAndroidAuto => 'Android Auto';
|
String get premiumBeneficioAndroidAuto =>
|
||||||
|
'Android Auto में पूरी सूची: पसंदीदा, मेरे स्टेशन और स्थानीय संगीत (मुफ़्त: सिर्फ़ चुनिंदा स्टेशन)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get premiumBeneficioGrabacion => 'स्टेशन रिकॉर्डिंग';
|
String get premiumBeneficioGrabacion => 'स्टेशन रिकॉर्डिंग';
|
||||||
@@ -1935,4 +1943,22 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get autoPistaSinNombre => 'बिना नाम का ट्रैक';
|
String get autoPistaSinNombre => 'बिना नाम का ट्रैक';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsPrivateUseNotice =>
|
||||||
|
'रिकॉर्डिंग आपके निजी उपयोग के लिए हैं। कृपया हर स्टेशन और उसकी सामग्री के अधिकारों का सम्मान करें।';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveTitle => 'असली ऑडियो तरंग';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveSubtitle =>
|
||||||
|
'बार चल रही आवाज़ का अनुसरण करते हैं। बंद होने पर वे अपने आप चलते हैं।';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWavePermissionExplanation =>
|
||||||
|
'Android माइक्रोफ़ोन अनुमति के बिना ऑडियो स्तर पढ़ने नहीं देता। PluriWave माइक्रोफ़ोन को न सुनता है न रिकॉर्ड करता है: यह केवल पहले से बज रही आवाज़ मापता है। आप इसे कभी भी बंद कर सकते हैं।';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveEnableAction => 'चालू करें';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -459,7 +459,14 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get recordingActionRename => 'Ganti nama';
|
String get recordingActionRename => 'Ganti nama';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recordingActionShare => 'Bagikan';
|
String get stationActionShare => 'Bagikan stasiun';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionOpenIn => 'Buka di aplikasi lain';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingOpenNoAppError =>
|
||||||
|
'Tidak ada aplikasi di perangkat ini yang dapat memutar rekaman ini.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recordingActionDelete => 'Hapus';
|
String get recordingActionDelete => 'Hapus';
|
||||||
@@ -1886,7 +1893,8 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get premiumBeneficioSinAnuncios => 'Tanpa iklan di seluruh aplikasi';
|
String get premiumBeneficioSinAnuncios => 'Tanpa iklan di seluruh aplikasi';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get premiumBeneficioAndroidAuto => 'Android Auto';
|
String get premiumBeneficioAndroidAuto =>
|
||||||
|
'Seluruh katalog di Android Auto: favorit, stasiun saya, dan musik lokal (gratis: hanya stasiun pilihan)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get premiumBeneficioGrabacion => 'Perekaman stasiun';
|
String get premiumBeneficioGrabacion => 'Perekaman stasiun';
|
||||||
@@ -1946,4 +1954,22 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get autoPistaSinNombre => 'Trek tanpa nama';
|
String get autoPistaSinNombre => 'Trek tanpa nama';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsPrivateUseNotice =>
|
||||||
|
'Rekaman ditujukan untuk penggunaan pribadi Anda. Hormati hak setiap stasiun dan hak atas kontennya.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveTitle => 'Gelombang audio asli';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveSubtitle =>
|
||||||
|
'Bilah mengikuti suara yang sedang diputar. Saat nonaktif, bilah bergerak sendiri.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWavePermissionExplanation =>
|
||||||
|
'Android hanya mengizinkan pembacaan level audio dengan izin mikrofon. PluriWave tidak mendengarkan atau merekam mikrofon: aplikasi hanya mengukur suara yang sedang diputar. Anda dapat menonaktifkannya kapan saja.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveEnableAction => 'Aktifkan';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -463,7 +463,14 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
String get recordingActionRename => 'Rinomina';
|
String get recordingActionRename => 'Rinomina';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recordingActionShare => 'Condividi';
|
String get stationActionShare => 'Condividi stazione';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionOpenIn => 'Apri in un\'altra app';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingOpenNoAppError =>
|
||||||
|
'Nessuna app su questo dispositivo può riprodurre questa registrazione.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recordingActionDelete => 'Elimina';
|
String get recordingActionDelete => 'Elimina';
|
||||||
@@ -1900,7 +1907,8 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
'Nessuna pubblicità in tutta l\'app';
|
'Nessuna pubblicità in tutta l\'app';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get premiumBeneficioAndroidAuto => 'Android Auto';
|
String get premiumBeneficioAndroidAuto =>
|
||||||
|
'Tutto il catalogo in Android Auto: preferiti, le mie stazioni e musica locale (gratis: solo stazioni in evidenza)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get premiumBeneficioGrabacion => 'Registrazione delle stazioni';
|
String get premiumBeneficioGrabacion => 'Registrazione delle stazioni';
|
||||||
@@ -1961,4 +1969,22 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get autoPistaSinNombre => 'Traccia senza nome';
|
String get autoPistaSinNombre => 'Traccia senza nome';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsPrivateUseNotice =>
|
||||||
|
'Le registrazioni sono per il tuo uso personale. Rispetta i diritti di ogni stazione e dei suoi contenuti.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveTitle => 'Onda audio reale';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveSubtitle =>
|
||||||
|
'Le barre seguono il suono in riproduzione. Se disattivata, si animano da sole.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWavePermissionExplanation =>
|
||||||
|
'Android consente di leggere il livello audio solo con l\'autorizzazione del microfono. PluriWave non ascolta né registra il microfono: misura soltanto il suono che sta già riproducendo. Puoi disattivarlo quando vuoi.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveEnableAction => 'Attiva';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -445,7 +445,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get recordingActionRename => '名前を変更';
|
String get recordingActionRename => '名前を変更';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recordingActionShare => '共有';
|
String get stationActionShare => '放送局を共有';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionOpenIn => '別のアプリで開く';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingOpenNoAppError => 'この端末には、この録音を再生できるアプリがありません。';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recordingActionDelete => '削除';
|
String get recordingActionDelete => '削除';
|
||||||
@@ -1820,7 +1826,8 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get premiumBeneficioSinAnuncios => 'アプリ全体で広告なし';
|
String get premiumBeneficioSinAnuncios => 'アプリ全体で広告なし';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get premiumBeneficioAndroidAuto => 'Android Auto';
|
String get premiumBeneficioAndroidAuto =>
|
||||||
|
'Android Auto でカタログ全体を利用: お気に入り、マイ局、ローカル音楽 (無料版はおすすめ局のみ)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get premiumBeneficioGrabacion => '放送局の録音';
|
String get premiumBeneficioGrabacion => '放送局の録音';
|
||||||
@@ -1877,4 +1884,21 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get autoPistaSinNombre => '名称未設定のトラック';
|
String get autoPistaSinNombre => '名称未設定のトラック';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsPrivateUseNotice =>
|
||||||
|
'録音はあなた個人の利用のためのものです。各放送局とその内容に関する権利を尊重してください。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveTitle => '実際の音声波形';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveSubtitle => 'バーが再生中の音に合わせて動きます。オフのときは独自に動きます。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWavePermissionExplanation =>
|
||||||
|
'Android では、マイクの権限がないと音声レベルを読み取れません。PluriWave がマイクを聞いたり録音したりすることはありません。再生中の音の大きさを測るだけです。いつでもオフにできます。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveEnableAction => 'オンにする';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -461,7 +461,14 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
String get recordingActionRename => 'Renomear';
|
String get recordingActionRename => 'Renomear';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recordingActionShare => 'Compartilhar';
|
String get stationActionShare => 'Compartilhar estação';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionOpenIn => 'Abrir em outro app';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingOpenNoAppError =>
|
||||||
|
'Nenhum app deste dispositivo consegue reproduzir esta gravação.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recordingActionDelete => 'Excluir';
|
String get recordingActionDelete => 'Excluir';
|
||||||
@@ -1886,7 +1893,8 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
String get premiumBeneficioSinAnuncios => 'Sem anúncios em todo o app';
|
String get premiumBeneficioSinAnuncios => 'Sem anúncios em todo o app';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get premiumBeneficioAndroidAuto => 'Android Auto';
|
String get premiumBeneficioAndroidAuto =>
|
||||||
|
'Todo o catálogo no Android Auto: favoritos, as minhas estações e música local (grátis: apenas estações em destaque)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get premiumBeneficioGrabacion => 'Gravação de emissoras';
|
String get premiumBeneficioGrabacion => 'Gravação de emissoras';
|
||||||
@@ -1946,4 +1954,22 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get autoPistaSinNombre => 'Faixa sem nome';
|
String get autoPistaSinNombre => 'Faixa sem nome';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsPrivateUseNotice =>
|
||||||
|
'As gravações são para o teu uso pessoal. Respeita os direitos de cada estação e dos seus conteúdos.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveTitle => 'Onda de áudio real';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveSubtitle =>
|
||||||
|
'As barras seguem o som que está a tocar. Desativado, animam-se sozinhas.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWavePermissionExplanation =>
|
||||||
|
'O Android só permite ler o nível de áudio com a permissão do microfone. O PluriWave não escuta nem grava o microfone: apenas mede o som que já está a reproduzir. Podes desativar isto quando quiseres.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveEnableAction => 'Ativar';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -461,7 +461,14 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get recordingActionRename => 'Переименовать';
|
String get recordingActionRename => 'Переименовать';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recordingActionShare => 'Поделиться';
|
String get stationActionShare => 'Поделиться станцией';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionOpenIn => 'Открыть в другом приложении';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingOpenNoAppError =>
|
||||||
|
'На этом устройстве нет приложения, способного воспроизвести эту запись.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recordingActionDelete => 'Удалить';
|
String get recordingActionDelete => 'Удалить';
|
||||||
@@ -1893,7 +1900,8 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
'Никакой рекламы во всём приложении';
|
'Никакой рекламы во всём приложении';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get premiumBeneficioAndroidAuto => 'Android Auto';
|
String get premiumBeneficioAndroidAuto =>
|
||||||
|
'Весь каталог в Android Auto: избранное, мои станции и локальная музыка (бесплатно: только рекомендуемые)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get premiumBeneficioGrabacion => 'Запись радиостанций';
|
String get premiumBeneficioGrabacion => 'Запись радиостанций';
|
||||||
@@ -1953,4 +1961,22 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get autoPistaSinNombre => 'Трек без названия';
|
String get autoPistaSinNombre => 'Трек без названия';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsPrivateUseNotice =>
|
||||||
|
'Записи предназначены только для личного использования. Уважайте права станций и их контента.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveTitle => 'Реальная звуковая волна';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveSubtitle =>
|
||||||
|
'Полосы следуют за звучащим аудио. Если выключено, они движутся сами по себе.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWavePermissionExplanation =>
|
||||||
|
'Android разрешает считывать уровень звука только с разрешением на микрофон. PluriWave не слушает и не записывает микрофон: он лишь измеряет уже воспроизводимый звук. Отключить можно в любой момент.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveEnableAction => 'Включить';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -443,7 +443,13 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
String get recordingActionRename => '重命名';
|
String get recordingActionRename => '重命名';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recordingActionShare => '分享';
|
String get stationActionShare => '分享电台';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingActionOpenIn => '用其他应用打开';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingOpenNoAppError => '此设备上没有可播放该录音的应用。';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recordingActionDelete => '删除';
|
String get recordingActionDelete => '删除';
|
||||||
@@ -1805,7 +1811,8 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
String get premiumBeneficioSinAnuncios => '全应用无广告';
|
String get premiumBeneficioSinAnuncios => '全应用无广告';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get premiumBeneficioAndroidAuto => 'Android Auto';
|
String get premiumBeneficioAndroidAuto =>
|
||||||
|
'在 Android Auto 中使用完整电台库:收藏、我的电台和本地音乐(免费版仅限精选电台)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get premiumBeneficioGrabacion => '电台录音';
|
String get premiumBeneficioGrabacion => '电台录音';
|
||||||
@@ -1861,4 +1868,20 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get autoPistaSinNombre => '未命名曲目';
|
String get autoPistaSinNombre => '未命名曲目';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingsPrivateUseNotice => '录音仅供你个人使用。请尊重各电台及其内容的权利。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveTitle => '真实音频波形';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveSubtitle => '音条会跟随正在播放的声音。关闭时,音条会自行跳动。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWavePermissionExplanation =>
|
||||||
|
'Android 只有在获得麦克风权限后才允许读取音频电平。PluriWave 不会监听或录制麦克风,只测量它正在播放的声音。你可以随时关闭此功能。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get visualizerRealWaveEnableAction => '开启';
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-2
@@ -20,6 +20,7 @@ import 'servicios/servicio_compras.dart';
|
|||||||
import 'servicios/servicio_consentimiento.dart';
|
import 'servicios/servicio_consentimiento.dart';
|
||||||
import 'servicios/servicio_ecualizador.dart';
|
import 'servicios/servicio_ecualizador.dart';
|
||||||
import 'servicios/servicio_presets_personalizados.dart';
|
import 'servicios/servicio_presets_personalizados.dart';
|
||||||
|
import 'servicios/verificacion_licencia.dart';
|
||||||
import 'tema/pluriwave_tokens.dart';
|
import 'tema/pluriwave_tokens.dart';
|
||||||
|
|
||||||
const _anchoMinimoLandscape = 600.0;
|
const _anchoMinimoLandscape = 600.0;
|
||||||
@@ -146,6 +147,16 @@ Future<void> main() async {
|
|||||||
// injected into every state/service below.
|
// injected into every state/service below.
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
|
// Silent license re-verification (refund revocation) for the Android Auto
|
||||||
|
// path: this engine may be the headless one, with no widget tree and so no
|
||||||
|
// `EstadoEntitlement`. The browse root triggers it fire-and-forget; the
|
||||||
|
// shared in-flight guard in `verificarLicencia` keeps it to one query at a
|
||||||
|
// time even when the phone UI checks too.
|
||||||
|
registrarVerificacionLicenciaAuto(
|
||||||
|
() =>
|
||||||
|
verificarLicencia(consultar: compras.consultarPropiedad, prefs: prefs),
|
||||||
|
);
|
||||||
|
|
||||||
// User-saved EQ presets for the car's Ecualizador folder, same
|
// User-saved EQ presets for the car's Ecualizador folder, same
|
||||||
// injectable-prefs DI convention and same pre-init placement as the two
|
// injectable-prefs DI convention and same pre-init placement as the two
|
||||||
// registrations above (neither depends on the AudioHandler). Passed as a
|
// registrations above (neither depends on the AudioHandler). Passed as a
|
||||||
@@ -354,8 +365,7 @@ bool debeInvalidarArbolAutoAlReanudar({
|
|||||||
required AppLifecycleState estado,
|
required AppLifecycleState estado,
|
||||||
required bool hayCocheSuscrito,
|
required bool hayCocheSuscrito,
|
||||||
required bool yaInvalidado,
|
required bool yaInvalidado,
|
||||||
}) =>
|
}) => !yaInvalidado && hayCocheSuscrito && estado == AppLifecycleState.resumed;
|
||||||
!yaInvalidado && hayCocheSuscrito && estado == AppLifecycleState.resumed;
|
|
||||||
|
|
||||||
/// Root wrapper that keeps the orientation policy applied and owns the
|
/// Root wrapper that keeps the orientation policy applied and owns the
|
||||||
/// Android Auto browse-tree recovery hook.
|
/// Android Auto browse-tree recovery hook.
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../estado/estado_visualizador.dart';
|
||||||
|
import '../../l10n/gen/app_localizations.dart';
|
||||||
|
import '../../widgets/pluri_glass_surface.dart';
|
||||||
|
import '../../widgets/pluri_layout.dart';
|
||||||
|
import '../../widgets/pluri_push_scaffold.dart';
|
||||||
|
|
||||||
|
/// AUDIO group · "Onda real del audio" — the point-of-intent opt-in for the
|
||||||
|
/// waveform visualizer's microphone permission.
|
||||||
|
///
|
||||||
|
/// Before this screen existed, `RECORD_AUDIO` was requested the moment
|
||||||
|
/// `VisualizadorAudio` subscribed to its native EventChannel, which the home
|
||||||
|
/// screen's "Escuchar" hero does on the user's FIRST play. A radio app that
|
||||||
|
/// pops "allow PluriWave to record audio?" the first time you press play is
|
||||||
|
/// asking for a sensitive permission with zero context, and Play expects
|
||||||
|
/// context.
|
||||||
|
///
|
||||||
|
/// Mirrors `PantallaAjustesSalidaAudio`'s point-of-intent shape (its
|
||||||
|
/// BLUETOOTH_CONNECT request sits behind the multi-device toggle the same
|
||||||
|
/// way), with one addition: the explanation is shown and accepted BEFORE the
|
||||||
|
/// flag flips, since flipping it is what triggers the system dialog.
|
||||||
|
class PantallaAjustesVisualizador extends StatelessWidget {
|
||||||
|
const PantallaAjustesVisualizador({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
return PluriPushScaffold(
|
||||||
|
title: l10n.visualizerRealWaveTitle,
|
||||||
|
body: ListView(
|
||||||
|
padding: PluriLayout.pageContentPadding,
|
||||||
|
children: const [_CuerpoVisualizador()],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CuerpoVisualizador extends StatelessWidget {
|
||||||
|
const _CuerpoVisualizador();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final estado = context.watch<EstadoVisualizador>();
|
||||||
|
final habilitada = estado.ondaRealHabilitada;
|
||||||
|
|
||||||
|
return PluriGlassSurface(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// GestureDetector + custom row instead of SwitchListTile, for the
|
||||||
|
// same reason PantallaAjustesSalidaAudio does it: ListTile ink
|
||||||
|
// inside PluriGlassSurface's DecoratedBox trips a Material
|
||||||
|
// assertion.
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
onTap: () => _alternar(context, estado, !habilitada),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l10n.visualizerRealWaveTitle,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
l10n.visualizerRealWaveSubtitle,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Switch.adaptive(
|
||||||
|
value: habilitada,
|
||||||
|
onChanged: (valor) => _alternar(context, estado, valor),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// The same text the confirmation dialog shows, kept permanently on
|
||||||
|
// screen: a user who already granted the permission should be able
|
||||||
|
// to re-read what it is for without toggling anything.
|
||||||
|
Text(
|
||||||
|
l10n.visualizerRealWavePermissionExplanation,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turning it OFF is immediate — withdrawing a permission must never be
|
||||||
|
/// harder than granting it. Turning it ON goes through the explanation
|
||||||
|
/// first, and only a deliberate confirmation flips the flag.
|
||||||
|
Future<void> _alternar(
|
||||||
|
BuildContext context,
|
||||||
|
EstadoVisualizador estado,
|
||||||
|
bool habilitada,
|
||||||
|
) async {
|
||||||
|
if (!habilitada) {
|
||||||
|
await estado.cambiarOndaReal(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final confirmado = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder:
|
||||||
|
(ctx) => AlertDialog(
|
||||||
|
key: const ValueKey('visualizador-explicacion-permiso'),
|
||||||
|
title: Text(l10n.visualizerRealWaveTitle),
|
||||||
|
content: Text(l10n.visualizerRealWavePermissionExplanation),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: Text(l10n.cancelAction),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: Text(l10n.visualizerRealWaveEnableAction),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmado != true) return;
|
||||||
|
await estado.cambiarOndaReal(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
|||||||
import '../estado/estado_ecualizador.dart';
|
import '../estado/estado_ecualizador.dart';
|
||||||
import '../estado/estado_entitlement.dart';
|
import '../estado/estado_entitlement.dart';
|
||||||
import '../estado/estado_grabacion.dart';
|
import '../estado/estado_grabacion.dart';
|
||||||
|
import '../estado/estado_visualizador.dart';
|
||||||
import '../estado/estado_idioma.dart';
|
import '../estado/estado_idioma.dart';
|
||||||
import '../estado/estado_radio.dart';
|
import '../estado/estado_radio.dart';
|
||||||
import '../l10n/display_names.dart';
|
import '../l10n/display_names.dart';
|
||||||
@@ -27,6 +28,7 @@ import 'ajustes/pantalla_ajustes_musica_local.dart';
|
|||||||
import 'ajustes/pantalla_ajustes_orden_listas.dart';
|
import 'ajustes/pantalla_ajustes_orden_listas.dart';
|
||||||
import 'ajustes/pantalla_ajustes_salida_audio.dart';
|
import 'ajustes/pantalla_ajustes_salida_audio.dart';
|
||||||
import 'ajustes/pantalla_ajustes_timer_sueno.dart';
|
import 'ajustes/pantalla_ajustes_timer_sueno.dart';
|
||||||
|
import 'ajustes/pantalla_ajustes_visualizador.dart';
|
||||||
import 'ajustes/widgets/fila_ajuste.dart';
|
import 'ajustes/widgets/fila_ajuste.dart';
|
||||||
import 'pantalla_grabaciones.dart';
|
import 'pantalla_grabaciones.dart';
|
||||||
|
|
||||||
@@ -104,6 +106,9 @@ class _AjustesContent extends StatelessWidget {
|
|||||||
final esPremium = context.select<EstadoEntitlement, bool>(
|
final esPremium = context.select<EstadoEntitlement, bool>(
|
||||||
(e) => e.esPremium,
|
(e) => e.esPremium,
|
||||||
);
|
);
|
||||||
|
final ondaRealActiva = context.select<EstadoVisualizador, bool>(
|
||||||
|
(e) => e.ondaRealHabilitada,
|
||||||
|
);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -137,6 +142,22 @@ class _AjustesContent extends StatelessWidget {
|
|||||||
(_) => const PantallaAjustesSalidaAudio(),
|
(_) => const PantallaAjustesSalidaAudio(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// Point-of-intent entry for the waveform visualizer's
|
||||||
|
// microphone opt-in. It is a nav row, not an inline switch,
|
||||||
|
// because the Settings root carries zero inline controls by
|
||||||
|
// design — the switch and its explanation live on the detail
|
||||||
|
// screen, which is also where the user reads what the
|
||||||
|
// permission is for before granting it.
|
||||||
|
FilaAjuste(
|
||||||
|
icon: Icons.graphic_eq_rounded,
|
||||||
|
titulo: l10n.visualizerRealWaveTitle,
|
||||||
|
valor: ondaRealActiva ? l10n.equalizerActive : null,
|
||||||
|
onTap:
|
||||||
|
() => PluriPushScaffold.push(
|
||||||
|
context,
|
||||||
|
(_) => const PantallaAjustesVisualizador(),
|
||||||
|
),
|
||||||
|
),
|
||||||
FilaAjuste(
|
FilaAjuste(
|
||||||
icon: Icons.bedtime_rounded,
|
icon: Icons.bedtime_rounded,
|
||||||
titulo: l10n.timerSectionTitle,
|
titulo: l10n.timerSectionTitle,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:just_audio/just_audio.dart';
|
import 'package:just_audio/just_audio.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:share_plus/share_plus.dart' show Share, XFile;
|
|
||||||
|
|
||||||
import '../estado/estado_grabacion.dart';
|
import '../estado/estado_grabacion.dart';
|
||||||
import '../l10n/gen/app_localizations.dart';
|
import '../l10n/gen/app_localizations.dart';
|
||||||
@@ -90,19 +89,24 @@ class _ReproductorGrabacionesJustAudio implements ReproductorGrabaciones {
|
|||||||
|
|
||||||
/// WU15: the recordings library — storage usage, browsable rows with
|
/// WU15: the recordings library — storage usage, browsable rows with
|
||||||
/// inline playback, and a "⋮" menu constrained to exactly
|
/// inline playback, and a "⋮" menu constrained to exactly
|
||||||
/// Rename/Share/Delete (`recordings-library` spec). Distinct from
|
/// Rename/Open-in-another-app/Delete (`recordings-library` spec). Distinct
|
||||||
/// `PantallaAjustesGrabaciones` (WU3b), which is the folder/size-limit
|
/// from `PantallaAjustesGrabaciones` (WU3b), which is the folder/size-limit
|
||||||
/// SETTINGS screen, not this browsable file list.
|
/// SETTINGS screen, not this browsable file list.
|
||||||
class PantallaGrabaciones extends StatefulWidget {
|
class PantallaGrabaciones extends StatefulWidget {
|
||||||
const PantallaGrabaciones({
|
const PantallaGrabaciones({
|
||||||
super.key,
|
super.key,
|
||||||
ReproductorGrabaciones? reproductor,
|
ReproductorGrabaciones? reproductor,
|
||||||
Future<void> Function(String ruta)? compartir,
|
Future<bool> Function(String ruta)? abrirEnOtraApp,
|
||||||
}) : _reproductorInyectado = reproductor,
|
}) : _reproductorInyectado = reproductor,
|
||||||
_compartirInyectado = compartir;
|
_abrirEnOtraAppInyectada = abrirEnOtraApp;
|
||||||
|
|
||||||
final ReproductorGrabaciones? _reproductorInyectado;
|
final ReproductorGrabaciones? _reproductorInyectado;
|
||||||
final Future<void> Function(String ruta)? _compartirInyectado;
|
|
||||||
|
/// Seam for the local-open action. Was `compartir`, which handed the audio
|
||||||
|
/// file to the system share sheet — redistribution of someone else's
|
||||||
|
/// broadcast. It now opens the file in a player already installed on THIS
|
||||||
|
/// device, and returns whether any app accepted it.
|
||||||
|
final Future<bool> Function(String ruta)? _abrirEnOtraAppInyectada;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<PantallaGrabaciones> createState() => _PantallaGrabacionesState();
|
State<PantallaGrabaciones> createState() => _PantallaGrabacionesState();
|
||||||
@@ -111,8 +115,9 @@ class PantallaGrabaciones extends StatefulWidget {
|
|||||||
class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
|
class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
|
||||||
late final ReproductorGrabaciones _reproductor =
|
late final ReproductorGrabaciones _reproductor =
|
||||||
widget._reproductorInyectado ?? _ReproductorGrabacionesJustAudio();
|
widget._reproductorInyectado ?? _ReproductorGrabacionesJustAudio();
|
||||||
late final Future<void> Function(String ruta) _compartir =
|
late final Future<bool> Function(String ruta) _abrirEnOtraApp =
|
||||||
widget._compartirInyectado ?? (ruta) => Share.shareXFiles([XFile(ruta)]);
|
widget._abrirEnOtraAppInyectada ??
|
||||||
|
(ruta) => context.read<EstadoGrabacion>().abrirGrabacion(ruta);
|
||||||
|
|
||||||
late Future<List<ArchivoGrabacion>> _grabaciones;
|
late Future<List<ArchivoGrabacion>> _grabaciones;
|
||||||
final Map<String, Future<Duration?>> _duracionCache = {};
|
final Map<String, Future<Duration?>> _duracionCache = {};
|
||||||
@@ -153,8 +158,8 @@ class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
|
|||||||
await _renombrar(archivo);
|
await _renombrar(archivo);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (accion == 'share') {
|
if (accion == 'open') {
|
||||||
await _compartir(archivo.ruta);
|
await _abrirLocalmente(archivo);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (accion == 'delete') {
|
if (accion == 'delete') {
|
||||||
@@ -162,6 +167,20 @@ class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Plays the user's own recording in another app on the same device. A
|
||||||
|
/// device with no audio viewer installed (and the native side's own
|
||||||
|
/// fallback to the containing folder failing too) returns `false` — the
|
||||||
|
/// action then says so instead of looking like a dead menu entry.
|
||||||
|
Future<void> _abrirLocalmente(ArchivoGrabacion archivo) async {
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final abierto = await _abrirEnOtraApp(archivo.ruta);
|
||||||
|
if (!mounted || abierto) return;
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(content: Text(l10n.recordingOpenNoAppError)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _renombrar(ArchivoGrabacion archivo) async {
|
Future<void> _renombrar(ArchivoGrabacion archivo) async {
|
||||||
final nuevoNombre = await showDialog<String>(
|
final nuevoNombre = await showDialog<String>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -286,6 +305,27 @@ class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
// Production-readiness pass: recording a broadcast holds up as
|
||||||
|
// a private copy, and stops holding up the moment the product
|
||||||
|
// reads as a redistribution tool. The library had no such
|
||||||
|
// statement at all, while the manifest already publishes the
|
||||||
|
// recordings folder to the system file manager
|
||||||
|
// (RecordingsDocumentsProvider). Deliberately factual and
|
||||||
|
// low-key — a footnote, not a warning banner — and always
|
||||||
|
// visible, empty library included.
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Padding(
|
||||||
|
key: const ValueKey('grabaciones-aviso-uso-privado'),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
child: Text(
|
||||||
|
l10n.recordingsPrivateUseNotice,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodySmall?.color?.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -373,7 +413,7 @@ class _FilaGrabacion extends StatelessWidget {
|
|||||||
// 44x44/radius-12 art placeholder (recordings carry no per-station
|
// 44x44/radius-12 art placeholder (recordings carry no per-station
|
||||||
// favicon, so this is a themed fallback square, not invented artwork),
|
// favicon, so this is a themed fallback square, not invented artwork),
|
||||||
// name, meta line, a 24px play/pause affordance, and the SAME "-"
|
// name, meta line, a 24px play/pause affordance, and the SAME "-"
|
||||||
// menu (Rename/Share/Delete) as before, just restyled.
|
// menu (Rename/Open in another app/Delete) as before, just restyled.
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -466,8 +506,8 @@ class _FilaGrabacion extends StatelessWidget {
|
|||||||
child: Text(l10n.recordingActionRename),
|
child: Text(l10n.recordingActionRename),
|
||||||
),
|
),
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 'share',
|
value: 'open',
|
||||||
child: Text(l10n.recordingActionShare),
|
child: Text(l10n.recordingActionOpenIn),
|
||||||
),
|
),
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 'delete',
|
value: 'delete',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:shimmer/shimmer.dart' as shimmer;
|
|||||||
import '../estado/estado_ecualizador.dart';
|
import '../estado/estado_ecualizador.dart';
|
||||||
import '../estado/estado_navegacion.dart';
|
import '../estado/estado_navegacion.dart';
|
||||||
import '../estado/estado_radio.dart';
|
import '../estado/estado_radio.dart';
|
||||||
|
import '../estado/estado_visualizador.dart';
|
||||||
import '../l10n/display_names.dart';
|
import '../l10n/display_names.dart';
|
||||||
import '../l10n/gen/app_localizations.dart';
|
import '../l10n/gen/app_localizations.dart';
|
||||||
import '../modelos/emisora.dart';
|
import '../modelos/emisora.dart';
|
||||||
@@ -471,6 +472,14 @@ class _EscucharHero extends StatelessWidget {
|
|||||||
// Audit 1.7 (t4 lines 66-68): 30 discrete
|
// Audit 1.7 (t4 lines 66-68): 30 discrete
|
||||||
// bottom-anchored bars, not a continuous stroke.
|
// bottom-anchored bars, not a continuous stroke.
|
||||||
barrasDiscretas: true,
|
barrasDiscretas: true,
|
||||||
|
// Sensitive-permission gate: subscribing to the
|
||||||
|
// native waveform channel is what makes Android ask
|
||||||
|
// for RECORD_AUDIO, so it happens only after the
|
||||||
|
// user opts in from Settings.
|
||||||
|
capturaRealHabilitada:
|
||||||
|
context
|
||||||
|
.watch<EstadoVisualizador>()
|
||||||
|
.ondaRealHabilitada,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'package:shimmer/shimmer.dart';
|
|||||||
import '../estado/estado_ecualizador.dart';
|
import '../estado/estado_ecualizador.dart';
|
||||||
import '../estado/estado_grabacion.dart';
|
import '../estado/estado_grabacion.dart';
|
||||||
import '../estado/estado_radio.dart';
|
import '../estado/estado_radio.dart';
|
||||||
|
import '../estado/estado_visualizador.dart';
|
||||||
import '../l10n/gen/app_localizations.dart';
|
import '../l10n/gen/app_localizations.dart';
|
||||||
import '../modelos/emisora.dart';
|
import '../modelos/emisora.dart';
|
||||||
import '../servicios/servicio_audio.dart';
|
import '../servicios/servicio_audio.dart';
|
||||||
@@ -185,6 +186,10 @@ class _PantallaReproductorState extends State<PantallaReproductor> {
|
|||||||
color: tokens.warmCoral,
|
color: tokens.warmCoral,
|
||||||
altura: 40,
|
altura: 40,
|
||||||
barrasDiscretas: true,
|
barrasDiscretas: true,
|
||||||
|
// Sensitive-permission gate: see the same note on the
|
||||||
|
// Escuchar hero's visualizer in `pantalla_inicio.dart`.
|
||||||
|
capturaRealHabilitada:
|
||||||
|
context.watch<EstadoVisualizador>().ondaRealHabilitada,
|
||||||
gradienteFinAlpha: 0.45,
|
gradienteFinAlpha: 0.45,
|
||||||
).pluriFadeIn(
|
).pluriFadeIn(
|
||||||
context,
|
context,
|
||||||
@@ -1133,7 +1138,11 @@ class _BandejaHerramientas extends StatelessWidget {
|
|||||||
child: _TileHerramienta(
|
child: _TileHerramienta(
|
||||||
key: const Key('player-tool-share'),
|
key: const Key('player-tool-share'),
|
||||||
icon: Icons.share_rounded,
|
icon: Icons.share_rounded,
|
||||||
label: l10n.recordingActionShare,
|
// Shares the STATION — its name and its stream url — never an
|
||||||
|
// audio file. This used to borrow `recordingActionShare`, the
|
||||||
|
// recordings library's own menu label, which made one key stand
|
||||||
|
// for two unrelated actions.
|
||||||
|
label: l10n.stationActionShare,
|
||||||
onTap: () => compartir('${emisora.nombre}\n${emisora.url}'),
|
onTap: () => compartir('${emisora.nombre}\n${emisora.url}'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import 'emisoras_destacadas.dart';
|
|||||||
import 'musica_local_auto.dart';
|
import 'musica_local_auto.dart';
|
||||||
import 'navegacion_auto.dart';
|
import 'navegacion_auto.dart';
|
||||||
import 'servicio_audio_session.dart';
|
import 'servicio_audio_session.dart';
|
||||||
|
import 'verificacion_licencia.dart' show CambioLicencia;
|
||||||
|
|
||||||
/// Estado de reproducción expuesto al UI.
|
/// Estado de reproducción expuesto al UI.
|
||||||
enum EstadoReproduccion {
|
enum EstadoReproduccion {
|
||||||
@@ -383,6 +384,36 @@ void invalidarArbolAuto() {
|
|||||||
_invalidarArbolAutoGlobal?.call();
|
_invalidarArbolAutoGlobal?.call();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Silent license re-verification for the headless Android Auto engine,
|
||||||
|
/// registered from `main.dart` (it owns the purchase port and the prefs).
|
||||||
|
/// `null` until registered — [dispararVerificacionLicenciaAuto] is then a
|
||||||
|
/// no-op.
|
||||||
|
Future<CambioLicencia> Function()? _verificarLicenciaAutoGlobal;
|
||||||
|
|
||||||
|
/// Registers (or, with `null`, clears) the verification
|
||||||
|
/// [dispararVerificacionLicenciaAuto] runs. Module-level like every other
|
||||||
|
/// `registrar*` seam here, so tests need no real handler.
|
||||||
|
void registrarVerificacionLicenciaAuto(
|
||||||
|
Future<CambioLicencia> Function()? verificar,
|
||||||
|
) {
|
||||||
|
_verificarLicenciaAutoGlobal = verificar;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the registered license check and, if it changed the persisted flag
|
||||||
|
/// in either direction, invalidates the cached car tree (local music is
|
||||||
|
/// premium-gated). Never throws. The browse path calls it UNAWAITED so a
|
||||||
|
/// head unit's browse answer is never delayed by a Play query.
|
||||||
|
Future<void> dispararVerificacionLicenciaAuto() async {
|
||||||
|
final verificar = _verificarLicenciaAutoGlobal;
|
||||||
|
if (verificar == null) return;
|
||||||
|
try {
|
||||||
|
final cambio = await verificar();
|
||||||
|
if (cambio != CambioLicencia.sinCambios) invalidarArbolAuto();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[PluriWave][licencia] verificacion Auto fallida $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether a head unit has actually SUBSCRIBED to at least one browse id on
|
/// Whether a head unit has actually SUBSCRIBED to at least one browse id on
|
||||||
/// the live handler (fix/android-auto-musica-local, item 4 — corrected).
|
/// the live handler (fix/android-auto-musica-local, item 4 — corrected).
|
||||||
///
|
///
|
||||||
@@ -3472,6 +3503,13 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
|||||||
// [resolverLocalizacionesRespaldo], so this works on the engine
|
// [resolverLocalizacionesRespaldo], so this works on the engine
|
||||||
// Android Auto starts without an Activity -- which is the only engine
|
// Android Auto starts without an Activity -- which is the only engine
|
||||||
// a Play reviewer ever gets.
|
// a Play reviewer ever gets.
|
||||||
|
// Silent license re-verification (refund revocation), on the root
|
||||||
|
// only — every car connection asks for it — and fire-and-forget: the
|
||||||
|
// browse answer below is served from the persisted flag right away,
|
||||||
|
// and the check is throttled to at most once a day.
|
||||||
|
if (parentMediaId == AudioService.browsableRootId) {
|
||||||
|
unawaited(dispararVerificacionLicenciaAuto());
|
||||||
|
}
|
||||||
final etiquetas = etiquetasArbolAutoDesde(_textos);
|
final etiquetas = etiquetasArbolAutoDesde(_textos);
|
||||||
final constructor = ConstructorArbolAuto(etiquetas: etiquetas);
|
final constructor = ConstructorArbolAuto(etiquetas: etiquetas);
|
||||||
// The "recent" root, resolved BEFORE the entitlement gate.
|
// The "recent" root, resolved BEFORE the entitlement gate.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter/foundation.dart' show debugPrint;
|
import 'package:flutter/foundation.dart' show debugPrint;
|
||||||
import 'package:in_app_purchase/in_app_purchase.dart';
|
import 'package:in_app_purchase/in_app_purchase.dart';
|
||||||
|
import 'package:in_app_purchase_android/in_app_purchase_android.dart';
|
||||||
|
|
||||||
/// Outcome kinds the purchase stream can report (Design ADR-2). Mirrors
|
/// Outcome kinds the purchase stream can report (Design ADR-2). Mirrors
|
||||||
/// `in_app_purchase`'s [PurchaseStatus] but stays a PluriWave-owned type so
|
/// `in_app_purchase`'s [PurchaseStatus] but stays a PluriWave-owned type so
|
||||||
@@ -38,6 +39,25 @@ class EventoCompra {
|
|||||||
final String? mensaje;
|
final String? mensaje;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Outcome of the SILENT ownership query ([PuertoCompras.consultarPropiedad])
|
||||||
|
/// used to re-verify the persisted premium flag (refund revocation).
|
||||||
|
///
|
||||||
|
/// Only [poseida] and [noPoseida] are definitive answers from Play; anything
|
||||||
|
/// that is not a clean answer (offline, billing unavailable, query error,
|
||||||
|
/// exception, timeout, pending purchase) is [desconocido], which the
|
||||||
|
/// verification policy treats as "change nothing" (fail-open, ADR-2).
|
||||||
|
enum ResultadoVerificacionLicencia {
|
||||||
|
/// Play reports the premium product as purchased on this account.
|
||||||
|
poseida,
|
||||||
|
|
||||||
|
/// Play answered successfully and the premium product is NOT among the
|
||||||
|
/// account's purchases (e.g. refunded or revoked).
|
||||||
|
noPoseida,
|
||||||
|
|
||||||
|
/// No trustworthy answer — never used to revoke.
|
||||||
|
desconocido,
|
||||||
|
}
|
||||||
|
|
||||||
/// Purchase I/O abstraction (Design ADR-2): [EstadoEntitlement] depends on
|
/// Purchase I/O abstraction (Design ADR-2): [EstadoEntitlement] depends on
|
||||||
/// this port, never on `in_app_purchase` directly — matches
|
/// this port, never on `in_app_purchase` directly — matches
|
||||||
/// `EstadoAlarmas(android: PuertoAlarmasAndroid)`'s injection shape, and
|
/// `EstadoAlarmas(android: PuertoAlarmasAndroid)`'s injection shape, and
|
||||||
@@ -55,13 +75,24 @@ abstract class PuertoCompras {
|
|||||||
|
|
||||||
/// Re-queries Play Billing for a prior purchase on this account.
|
/// Re-queries Play Billing for a prior purchase on this account.
|
||||||
Future<void> restaurar();
|
Future<void> restaurar();
|
||||||
|
|
||||||
|
/// Silently asks the store whether this account currently owns the
|
||||||
|
/// premium product. Unlike [restaurar], it NEVER emits on [eventos] (the
|
||||||
|
/// premium sheet listens there) and never throws: every failure maps to
|
||||||
|
/// [ResultadoVerificacionLicencia.desconocido].
|
||||||
|
Future<ResultadoVerificacionLicencia> consultarPropiedad();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The SOLE `in_app_purchase` call site (Design ADR-2) — every other file
|
/// The SOLE `in_app_purchase` call site (Design ADR-2) — every other file
|
||||||
/// depends on [PuertoCompras] instead.
|
/// depends on [PuertoCompras] instead.
|
||||||
class ServicioComprasPlayBilling implements PuertoCompras {
|
class ServicioComprasPlayBilling implements PuertoCompras {
|
||||||
ServicioComprasPlayBilling({InAppPurchase? inAppPurchase})
|
ServicioComprasPlayBilling({
|
||||||
: _iap = inAppPurchase ?? InAppPurchase.instance {
|
InAppPurchase? inAppPurchase,
|
||||||
|
Future<QueryPurchaseDetailsResponse> Function()? consultarComprasPasadas,
|
||||||
|
Duration limiteConsultaPropiedad = const Duration(seconds: 10),
|
||||||
|
}) : _iap = inAppPurchase ?? InAppPurchase.instance,
|
||||||
|
_consultarComprasPasadasInyectada = consultarComprasPasadas,
|
||||||
|
_limiteConsultaPropiedad = limiteConsultaPropiedad {
|
||||||
_sub = _iap.purchaseStream.listen(
|
_sub = _iap.purchaseStream.listen(
|
||||||
_alRecibirCompras,
|
_alRecibirCompras,
|
||||||
onError: (Object error) {
|
onError: (Object error) {
|
||||||
@@ -77,6 +108,15 @@ class ServicioComprasPlayBilling implements PuertoCompras {
|
|||||||
static const idProducto = 'pluriwave_premium';
|
static const idProducto = 'pluriwave_premium';
|
||||||
|
|
||||||
final InAppPurchase _iap;
|
final InAppPurchase _iap;
|
||||||
|
|
||||||
|
/// Test seam for [consultarPropiedad]; `null` in production, where the
|
||||||
|
/// Android platform addition's `queryPastPurchases` is used.
|
||||||
|
final Future<QueryPurchaseDetailsResponse> Function()?
|
||||||
|
_consultarComprasPasadasInyectada;
|
||||||
|
|
||||||
|
/// Upper bound for [consultarPropiedad]: a hung BillingClient connection
|
||||||
|
/// resolves to [ResultadoVerificacionLicencia.desconocido].
|
||||||
|
final Duration _limiteConsultaPropiedad;
|
||||||
final _eventos = StreamController<EventoCompra>.broadcast();
|
final _eventos = StreamController<EventoCompra>.broadcast();
|
||||||
StreamSubscription<List<PurchaseDetails>>? _sub;
|
StreamSubscription<List<PurchaseDetails>>? _sub;
|
||||||
|
|
||||||
@@ -125,6 +165,33 @@ class ServicioComprasPlayBilling implements PuertoCompras {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ResultadoVerificacionLicencia> consultarPropiedad() async {
|
||||||
|
try {
|
||||||
|
final disponible = await _iap.isAvailable().timeout(
|
||||||
|
_limiteConsultaPropiedad,
|
||||||
|
);
|
||||||
|
if (!disponible) return ResultadoVerificacionLicencia.desconocido;
|
||||||
|
final consultar =
|
||||||
|
_consultarComprasPasadasInyectada ??
|
||||||
|
() =>
|
||||||
|
_iap
|
||||||
|
.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>()
|
||||||
|
.queryPastPurchases();
|
||||||
|
// `queryPastPurchases` reads the account's purchases straight from
|
||||||
|
// the BillingClient: unlike `restorePurchases` it does NOT push them
|
||||||
|
// into `purchaseStream`, so the premium sheet never sees this check.
|
||||||
|
final respuesta = await consultar().timeout(_limiteConsultaPropiedad);
|
||||||
|
return resultadoDesdeComprasPasadas(
|
||||||
|
respuesta.pastPurchases,
|
||||||
|
conError: respuesta.error != null,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[PluriWave][compras] consultarPropiedad -> desconocido $e');
|
||||||
|
return ResultadoVerificacionLicencia.desconocido;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _alRecibirCompras(List<PurchaseDetails> compras) {
|
void _alRecibirCompras(List<PurchaseDetails> compras) {
|
||||||
if (compras.isEmpty) {
|
if (compras.isEmpty) {
|
||||||
// `restorePurchases()` with nothing to restore pushes an EMPTY batch
|
// `restorePurchases()` with nothing to restore pushes an EMPTY batch
|
||||||
@@ -176,6 +243,33 @@ EventoCompra eventoDesdeEstadoCompra(PurchaseStatus status, {String? mensaje}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pure mapping from a `queryPastPurchases` answer to the typed ownership
|
||||||
|
/// result (same port-boundary rationale as [eventoDesdeEstadoCompra]).
|
||||||
|
///
|
||||||
|
/// The premium product present as purchased/restored is proof of ownership
|
||||||
|
/// even when the answer also carries an error (the query spans in-app AND
|
||||||
|
/// subscriptions, and a failure of the latter is irrelevant here). A pending
|
||||||
|
/// entry is not a clean answer. Absence only counts as [noPoseida] when the
|
||||||
|
/// query succeeded without error.
|
||||||
|
ResultadoVerificacionLicencia resultadoDesdeComprasPasadas(
|
||||||
|
List<PurchaseDetails> compras, {
|
||||||
|
required bool conError,
|
||||||
|
}) {
|
||||||
|
final delProducto = compras.where(
|
||||||
|
(c) => c.productID == ServicioComprasPlayBilling.idProducto,
|
||||||
|
);
|
||||||
|
final comprada = delProducto.any(
|
||||||
|
(c) =>
|
||||||
|
c.status == PurchaseStatus.purchased ||
|
||||||
|
c.status == PurchaseStatus.restored,
|
||||||
|
);
|
||||||
|
if (comprada) return ResultadoVerificacionLicencia.poseida;
|
||||||
|
if (conError || delProducto.isNotEmpty) {
|
||||||
|
return ResultadoVerificacionLicencia.desconocido;
|
||||||
|
}
|
||||||
|
return ResultadoVerificacionLicencia.noPoseida;
|
||||||
|
}
|
||||||
|
|
||||||
extension<T> on List<T> {
|
extension<T> on List<T> {
|
||||||
T? get firstOrNull => isEmpty ? null : first;
|
T? get firstOrNull => isEmpty ? null : first;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show debugPrint;
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'servicio_compras.dart';
|
||||||
|
|
||||||
|
export 'servicio_compras.dart' show ResultadoVerificacionLicencia;
|
||||||
|
|
||||||
|
/// Versioned persistence key (Design ADR-1) of the permanent, non-consumable
|
||||||
|
/// premium unlock. Owned here so the headless verification below and
|
||||||
|
/// `EstadoEntitlement` read/write exactly ONE key.
|
||||||
|
const claveCompraPremium = 'compra_premium_v1';
|
||||||
|
|
||||||
|
/// Epoch millis of the last verification that got a DEFINITIVE answer
|
||||||
|
/// ([ResultadoVerificacionLicencia.poseida] / [noPoseida]).
|
||||||
|
const claveUltimaVerificacionLicencia = 'licencia_ultima_verificacion_ms';
|
||||||
|
|
||||||
|
/// Epoch millis of the last ATTEMPT, whatever its outcome.
|
||||||
|
const claveUltimoIntentoLicencia = 'licencia_ultimo_intento_ms';
|
||||||
|
|
||||||
|
/// Consecutive definitive "not owned" answers while the flag was premium.
|
||||||
|
const claveAusenciasLicencia = 'licencia_ausencias_consecutivas';
|
||||||
|
|
||||||
|
/// Epoch millis of the FIRST absence of the current streak.
|
||||||
|
const clavePrimeraAusenciaLicencia = 'licencia_primera_ausencia_ms';
|
||||||
|
|
||||||
|
/// After a definitive answer, Play is not asked again for this long.
|
||||||
|
const intervaloVerificacionLicencia = Duration(hours: 24);
|
||||||
|
|
||||||
|
/// After an attempt without a definitive answer (offline, billing
|
||||||
|
/// unavailable, error), the retry waits at least this long, so resuming the
|
||||||
|
/// app or reconnecting the car while offline never hammers Play.
|
||||||
|
const intervaloReintentoLicencia = Duration(hours: 1);
|
||||||
|
|
||||||
|
/// Minimum time between the first absence and the one that confirms it —
|
||||||
|
/// a transient empty Play Store cache must never revoke a paying user.
|
||||||
|
const separacionMinimaAusencias = Duration(hours: 12);
|
||||||
|
|
||||||
|
/// Definitive absences required (spaced by [separacionMinimaAusencias])
|
||||||
|
/// before the premium flag is revoked.
|
||||||
|
const ausenciasParaRevocar = 2;
|
||||||
|
|
||||||
|
/// What a [verificarLicencia] run changed in the persisted flag.
|
||||||
|
enum CambioLicencia {
|
||||||
|
/// Nothing changed (throttled, unknown answer, or state already right).
|
||||||
|
sinCambios,
|
||||||
|
|
||||||
|
/// The flag went false -> true (e.g. a reinstall of a paying user).
|
||||||
|
desbloqueada,
|
||||||
|
|
||||||
|
/// The flag went true -> false (refund confirmed twice, spaced apart).
|
||||||
|
revocada,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The run currently in flight, shared by every caller in this isolate
|
||||||
|
/// (phone UI and Android Auto), so only ONE ownership query runs at a time.
|
||||||
|
Future<CambioLicencia>? _verificacionEnCurso;
|
||||||
|
|
||||||
|
/// Silent, headless-safe license re-verification (refund revocation).
|
||||||
|
///
|
||||||
|
/// No `BuildContext`, no purchase-stream events, never throws, and every
|
||||||
|
/// non-definitive outcome leaves the persisted state untouched (fail-open,
|
||||||
|
/// ADR-2) — PRO is never removed for lack of connectivity. Callers fire and
|
||||||
|
/// forget it; it must never sit on a startup or browse path.
|
||||||
|
///
|
||||||
|
/// Rules:
|
||||||
|
/// * throttled by [intervaloVerificacionLicencia] after a definitive answer
|
||||||
|
/// and by [intervaloReintentoLicencia] after any attempt; a clock that
|
||||||
|
/// went backwards never blocks it;
|
||||||
|
/// * [ResultadoVerificacionLicencia.poseida]: flag forced to `true` (silent
|
||||||
|
/// unlock if it was `false`), absence streak cleared;
|
||||||
|
/// * [ResultadoVerificacionLicencia.noPoseida] with the flag `true`: the
|
||||||
|
/// streak grows, and the flag is revoked only once it reaches
|
||||||
|
/// [ausenciasParaRevocar] AND at least [separacionMinimaAusencias] passed
|
||||||
|
/// since its first absence; with the flag `false` there is nothing to do;
|
||||||
|
/// * [ResultadoVerificacionLicencia.desconocido] (or an exception): nothing
|
||||||
|
/// changes, not even the streak.
|
||||||
|
Future<CambioLicencia> verificarLicencia({
|
||||||
|
required Future<ResultadoVerificacionLicencia> Function() consultar,
|
||||||
|
SharedPreferences? prefs,
|
||||||
|
DateTime Function()? reloj,
|
||||||
|
}) {
|
||||||
|
final enCurso = _verificacionEnCurso;
|
||||||
|
if (enCurso != null) return enCurso;
|
||||||
|
final ejecucion = _verificar(
|
||||||
|
consultar: consultar,
|
||||||
|
prefs: prefs,
|
||||||
|
reloj: reloj ?? DateTime.now,
|
||||||
|
);
|
||||||
|
_verificacionEnCurso = ejecucion;
|
||||||
|
unawaited(
|
||||||
|
ejecucion.whenComplete(() {
|
||||||
|
if (identical(_verificacionEnCurso, ejecucion)) {
|
||||||
|
_verificacionEnCurso = null;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return ejecucion;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears the absence streak — a real purchase/restore is fresh proof of
|
||||||
|
/// ownership, so a stale streak must not survive it.
|
||||||
|
Future<void> reiniciarAusenciasLicencia(SharedPreferences prefs) async {
|
||||||
|
await prefs.remove(claveAusenciasLicencia);
|
||||||
|
await prefs.remove(clavePrimeraAusenciaLicencia);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<CambioLicencia> _verificar({
|
||||||
|
required Future<ResultadoVerificacionLicencia> Function() consultar,
|
||||||
|
required SharedPreferences? prefs,
|
||||||
|
required DateTime Function() reloj,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final p = prefs ?? await SharedPreferences.getInstance();
|
||||||
|
final ahora = reloj();
|
||||||
|
if (_dentroDeVentana(
|
||||||
|
p,
|
||||||
|
claveUltimaVerificacionLicencia,
|
||||||
|
ahora,
|
||||||
|
intervaloVerificacionLicencia,
|
||||||
|
) ||
|
||||||
|
_dentroDeVentana(
|
||||||
|
p,
|
||||||
|
claveUltimoIntentoLicencia,
|
||||||
|
ahora,
|
||||||
|
intervaloReintentoLicencia,
|
||||||
|
)) {
|
||||||
|
return CambioLicencia.sinCambios;
|
||||||
|
}
|
||||||
|
await p.setInt(claveUltimoIntentoLicencia, ahora.millisecondsSinceEpoch);
|
||||||
|
|
||||||
|
ResultadoVerificacionLicencia resultado;
|
||||||
|
try {
|
||||||
|
resultado = await consultar();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[PluriWave][licencia] consulta fallida -> sin cambios $e');
|
||||||
|
resultado = ResultadoVerificacionLicencia.desconocido;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (resultado) {
|
||||||
|
case ResultadoVerificacionLicencia.desconocido:
|
||||||
|
return CambioLicencia.sinCambios;
|
||||||
|
case ResultadoVerificacionLicencia.poseida:
|
||||||
|
await p.setInt(
|
||||||
|
claveUltimaVerificacionLicencia,
|
||||||
|
ahora.millisecondsSinceEpoch,
|
||||||
|
);
|
||||||
|
await reiniciarAusenciasLicencia(p);
|
||||||
|
if (p.getBool(claveCompraPremium) ?? false) {
|
||||||
|
return CambioLicencia.sinCambios;
|
||||||
|
}
|
||||||
|
await p.setBool(claveCompraPremium, true);
|
||||||
|
return CambioLicencia.desbloqueada;
|
||||||
|
case ResultadoVerificacionLicencia.noPoseida:
|
||||||
|
await p.setInt(
|
||||||
|
claveUltimaVerificacionLicencia,
|
||||||
|
ahora.millisecondsSinceEpoch,
|
||||||
|
);
|
||||||
|
if (!(p.getBool(claveCompraPremium) ?? false)) {
|
||||||
|
await reiniciarAusenciasLicencia(p);
|
||||||
|
return CambioLicencia.sinCambios;
|
||||||
|
}
|
||||||
|
return _registrarAusencia(p, ahora);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Fail-open (ADR-2): a prefs failure never touches the entitlement.
|
||||||
|
debugPrint('[PluriWave][licencia] verificacion fallida -> sin cambios $e');
|
||||||
|
return CambioLicencia.sinCambios;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<CambioLicencia> _registrarAusencia(
|
||||||
|
SharedPreferences p,
|
||||||
|
DateTime ahora,
|
||||||
|
) async {
|
||||||
|
final ausencias = (p.getInt(claveAusenciasLicencia) ?? 0) + 1;
|
||||||
|
final primeraMs = p.getInt(clavePrimeraAusenciaLicencia);
|
||||||
|
if (primeraMs == null || ausencias == 1) {
|
||||||
|
await p.setInt(claveAusenciasLicencia, 1);
|
||||||
|
await p.setInt(clavePrimeraAusenciaLicencia, ahora.millisecondsSinceEpoch);
|
||||||
|
return CambioLicencia.sinCambios;
|
||||||
|
}
|
||||||
|
final separacion = ahora.difference(
|
||||||
|
DateTime.fromMillisecondsSinceEpoch(primeraMs),
|
||||||
|
);
|
||||||
|
if (separacion.isNegative) {
|
||||||
|
// The clock went backwards: restart the spacing from now (delays the
|
||||||
|
// revocation, never hastens it).
|
||||||
|
await p.setInt(clavePrimeraAusenciaLicencia, ahora.millisecondsSinceEpoch);
|
||||||
|
await p.setInt(claveAusenciasLicencia, ausencias);
|
||||||
|
return CambioLicencia.sinCambios;
|
||||||
|
}
|
||||||
|
if (ausencias >= ausenciasParaRevocar &&
|
||||||
|
separacion >= separacionMinimaAusencias) {
|
||||||
|
await p.setBool(claveCompraPremium, false);
|
||||||
|
await reiniciarAusenciasLicencia(p);
|
||||||
|
return CambioLicencia.revocada;
|
||||||
|
}
|
||||||
|
await p.setInt(claveAusenciasLicencia, ausencias);
|
||||||
|
return CambioLicencia.sinCambios;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether [clave]'s timestamp is less than [ventana] before [ahora]. A
|
||||||
|
/// timestamp in the future (clock moved backwards) does NOT throttle.
|
||||||
|
bool _dentroDeVentana(
|
||||||
|
SharedPreferences p,
|
||||||
|
String clave,
|
||||||
|
DateTime ahora,
|
||||||
|
Duration ventana,
|
||||||
|
) {
|
||||||
|
final ms = p.getInt(clave);
|
||||||
|
if (ms == null) return false;
|
||||||
|
final transcurrido = ahora.difference(
|
||||||
|
DateTime.fromMillisecondsSinceEpoch(ms),
|
||||||
|
);
|
||||||
|
return !transcurrido.isNegative && transcurrido < ventana;
|
||||||
|
}
|
||||||
@@ -40,6 +40,13 @@ class HojaPremium extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: PluriGlassSurface(
|
child: PluriGlassSurface(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
|
// The sheet's height is data-dependent: five benefit bullets whose
|
||||||
|
// wrapped line count varies per locale, plus an optional
|
||||||
|
// purchase/restore result banner. Together they already overflow a
|
||||||
|
// short viewport by a couple of pixels, and a translation one word
|
||||||
|
// longer would make it worse. Scrolling is the only shape that
|
||||||
|
// cannot overflow, and it costs nothing when everything fits.
|
||||||
|
child: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
@@ -77,6 +84,12 @@ class HojaPremium extends StatelessWidget {
|
|||||||
// equalizer stays free for everyone and must NEVER appear here;
|
// equalizer stays free for everyone and must NEVER appear here;
|
||||||
// only its Android Auto surface is affected, as a consequence of
|
// only its Android Auto surface is affected, as a consequence of
|
||||||
// Auto itself being gated.
|
// Auto itself being gated.
|
||||||
|
//
|
||||||
|
// The Android Auto line describes what PRO adds in the CAR — the
|
||||||
|
// full catalogue, favourites, my stations, local music — because
|
||||||
|
// the free tier already gets a real, playable featured folder
|
||||||
|
// there. A bare "Android Auto" bullet sold something free users
|
||||||
|
// already have.
|
||||||
_BeneficioPremium(texto: l10n.premiumBeneficioSinAnuncios),
|
_BeneficioPremium(texto: l10n.premiumBeneficioSinAnuncios),
|
||||||
_BeneficioPremium(texto: l10n.premiumBeneficioAndroidAuto),
|
_BeneficioPremium(texto: l10n.premiumBeneficioAndroidAuto),
|
||||||
_BeneficioPremium(texto: l10n.premiumBeneficioGrabacion),
|
_BeneficioPremium(texto: l10n.premiumBeneficioGrabacion),
|
||||||
@@ -186,6 +199,7 @@ class HojaPremium extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,17 @@ class VisualizadorAudio extends StatefulWidget {
|
|||||||
/// player: `.45`, t4 line 121), so this is a parameter, not a constant.
|
/// player: `.45`, t4 line 121), so this is a parameter, not a constant.
|
||||||
final double gradienteFinAlpha;
|
final double gradienteFinAlpha;
|
||||||
|
|
||||||
|
/// Whether the user has opted in to reading the REAL audio level.
|
||||||
|
///
|
||||||
|
/// Subscribing to the native `pluriwave/audio_visualizer` EventChannel is
|
||||||
|
/// what makes `MainActivity.startVisualizerWhenAllowed` request
|
||||||
|
/// `RECORD_AUDIO`, so this flag is the Dart-side gate on a sensitive
|
||||||
|
/// permission, not a cosmetic preference. Defaults to `false`: without the
|
||||||
|
/// opt-in the widget never touches the channel and animates the synthetic
|
||||||
|
/// wave instead, which is exactly what it already did whenever the
|
||||||
|
/// permission was denied.
|
||||||
|
final bool capturaRealHabilitada;
|
||||||
|
|
||||||
const VisualizadorAudio({
|
const VisualizadorAudio({
|
||||||
super.key,
|
super.key,
|
||||||
required this.estadoStream,
|
required this.estadoStream,
|
||||||
@@ -42,6 +53,7 @@ class VisualizadorAudio extends StatefulWidget {
|
|||||||
this.anchuraTotal = double.infinity,
|
this.anchuraTotal = double.infinity,
|
||||||
this.barrasDiscretas = false,
|
this.barrasDiscretas = false,
|
||||||
this.gradienteFinAlpha = 0.3,
|
this.gradienteFinAlpha = 0.3,
|
||||||
|
this.capturaRealHabilitada = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -101,9 +113,31 @@ class _VisualizadorAudioState extends State<VisualizadorAudio>
|
|||||||
_sincronizarOndaReal();
|
_sincronizarOndaReal();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(VisualizadorAudio oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
// The opt-in can be revoked while this widget is mounted; dropping the
|
||||||
|
// subscription is what makes the native side release the Visualizer.
|
||||||
|
if (oldWidget.capturaRealHabilitada != widget.capturaRealHabilitada) {
|
||||||
|
if (!widget.capturaRealHabilitada) {
|
||||||
|
unawaited(_ondaSubscription?.cancel());
|
||||||
|
_ondaSubscription = null;
|
||||||
|
_ultimaOndaReal = null;
|
||||||
|
}
|
||||||
|
_sincronizarOndaReal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _sincronizarOndaReal() {
|
void _sincronizarOndaReal() {
|
||||||
final sessionId = _sessionId;
|
final sessionId = _sessionId;
|
||||||
final puedeCapturar = sessionId != null && sessionId > 0 && _activo;
|
// `capturaRealHabilitada` first: subscribing to the EventChannel is what
|
||||||
|
// triggers the native RECORD_AUDIO request, so the opt-in gates the
|
||||||
|
// subscription itself, never just the rendering of its result.
|
||||||
|
final puedeCapturar =
|
||||||
|
widget.capturaRealHabilitada &&
|
||||||
|
sessionId != null &&
|
||||||
|
sessionId > 0 &&
|
||||||
|
_activo;
|
||||||
|
|
||||||
if (!puedeCapturar) {
|
if (!puedeCapturar) {
|
||||||
unawaited(_ondaSubscription?.cancel());
|
unawaited(_ondaSubscription?.cancel());
|
||||||
|
|||||||
+1
-1
@@ -366,7 +366,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.3.0"
|
version: "3.3.0"
|
||||||
in_app_purchase_android:
|
in_app_purchase_android:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: in_app_purchase_android
|
name: in_app_purchase_android
|
||||||
sha256: c04e2cad0470fc868cb0ea06477648f003220b1b6612909db83528ca83ac0905
|
sha256: c04e2cad0470fc868cb0ea06477648f003220b1b6612909db83528ca83ac0905
|
||||||
|
|||||||
+5
-1
@@ -1,7 +1,7 @@
|
|||||||
name: pluriwave
|
name: pluriwave
|
||||||
description: "Radio mundial con ecualizador, reconocimiento de canciones y UI premium"
|
description: "Radio mundial con ecualizador, reconocimiento de canciones y UI premium"
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.3.3+161
|
version: 1.3.3+164
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.7.0
|
sdk: ^3.7.0
|
||||||
@@ -55,6 +55,10 @@ dependencies:
|
|||||||
|
|
||||||
# In-app purchase
|
# In-app purchase
|
||||||
in_app_purchase: ^3.2.0
|
in_app_purchase: ^3.2.0
|
||||||
|
# Direct dependency only for `InAppPurchaseAndroidPlatformAddition
|
||||||
|
# .queryPastPurchases` (silent license re-verification in
|
||||||
|
# `ServicioComprasPlayBilling.consultarPropiedad`).
|
||||||
|
in_app_purchase_android: ^0.5.0
|
||||||
|
|
||||||
# Canal nativo SAF (`pluriwave/file_actions`) empaquetado como plugin para
|
# Canal nativo SAF (`pluriwave/file_actions`) empaquetado como plugin para
|
||||||
# que GeneratedPluginRegistrant lo instale TAMBIEN en el FlutterEngine
|
# que GeneratedPluginRegistrant lo instale TAMBIEN en el FlutterEngine
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
/// Play production-readiness guard over the declared Android permissions.
|
||||||
|
///
|
||||||
|
/// There is no Dart seam around `Geolocator` (it is a static facade, not an
|
||||||
|
/// injectable port), so the only honest place to pin the permission surface
|
||||||
|
/// is the manifest file itself. That is deliberate: the risk this guards is
|
||||||
|
/// a *declaration* mismatch with the Data Safety form, which is a property
|
||||||
|
/// of the manifest, not of any Dart call.
|
||||||
|
void main() {
|
||||||
|
late String manifiesto;
|
||||||
|
|
||||||
|
setUpAll(() {
|
||||||
|
manifiesto =
|
||||||
|
File('android/app/src/main/AndroidManifest.xml').readAsStringSync();
|
||||||
|
});
|
||||||
|
|
||||||
|
bool declara(String permiso) =>
|
||||||
|
manifiesto.contains('android.permission.$permiso');
|
||||||
|
|
||||||
|
group('permisos de ubicacion', () {
|
||||||
|
test('NO declara ACCESS_FINE_LOCATION: la app solo resuelve un codigo ISO '
|
||||||
|
'de pais y el Data Safety enviado declara ubicacion aproximada', () {
|
||||||
|
expect(
|
||||||
|
declara('ACCESS_FINE_LOCATION'),
|
||||||
|
isFalse,
|
||||||
|
reason:
|
||||||
|
'The only location consumer is '
|
||||||
|
'EstadoBusqueda.cargarEmisorasCercanas, which asks for '
|
||||||
|
'LocationAccuracy.low and reduces the fix to '
|
||||||
|
'Placemark.isoCountryCode. Declaring FINE contradicts the '
|
||||||
|
'approved "approximate location" Data Safety declaration.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SI declara ACCESS_COARSE_LOCATION: sin ninguno de los dos, el '
|
||||||
|
'plugin lanza PermissionUndefinedException', () {
|
||||||
|
expect(declara('ACCESS_COARSE_LOCATION'), isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,7 +2,10 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:pluriwave/estado/estado_entitlement.dart';
|
import 'package:pluriwave/estado/estado_entitlement.dart';
|
||||||
|
import 'package:pluriwave/servicios/servicio_audio.dart'
|
||||||
|
show registrarInvalidacionArbolAuto;
|
||||||
import 'package:pluriwave/servicios/servicio_compras.dart';
|
import 'package:pluriwave/servicios/servicio_compras.dart';
|
||||||
|
import 'package:pluriwave/servicios/verificacion_licencia.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
/// Fake [PuertoCompras] (Design ADR-2): never touches `in_app_purchase`,
|
/// Fake [PuertoCompras] (Design ADR-2): never touches `in_app_purchase`,
|
||||||
@@ -11,6 +14,12 @@ class _PuertoComprasFalso implements PuertoCompras {
|
|||||||
final _eventos = StreamController<EventoCompra>.broadcast();
|
final _eventos = StreamController<EventoCompra>.broadcast();
|
||||||
int comprasIntentadas = 0;
|
int comprasIntentadas = 0;
|
||||||
int restaurosIntentados = 0;
|
int restaurosIntentados = 0;
|
||||||
|
int consultasPropiedad = 0;
|
||||||
|
|
||||||
|
/// What the silent ownership query answers. Defaults to [desconocido] so
|
||||||
|
/// every pre-existing test keeps its old behavior (fail-open: no change).
|
||||||
|
ResultadoVerificacionLicencia propiedad =
|
||||||
|
ResultadoVerificacionLicencia.desconocido;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<EventoCompra> get eventos => _eventos.stream;
|
Stream<EventoCompra> get eventos => _eventos.stream;
|
||||||
@@ -25,11 +34,32 @@ class _PuertoComprasFalso implements PuertoCompras {
|
|||||||
restaurosIntentados++;
|
restaurosIntentados++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ResultadoVerificacionLicencia> consultarPropiedad() async {
|
||||||
|
consultasPropiedad++;
|
||||||
|
return propiedad;
|
||||||
|
}
|
||||||
|
|
||||||
void emitir(EventoCompra evento) => _eventos.add(evento);
|
void emitir(EventoCompra evento) => _eventos.add(evento);
|
||||||
|
|
||||||
Future<void> dispose() => _eventos.close();
|
Future<void> dispose() => _eventos.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mutable clock for the throttle/spacing rules of the license check.
|
||||||
|
class _Reloj {
|
||||||
|
DateTime ahora = DateTime(2026, 9, 18, 10);
|
||||||
|
|
||||||
|
DateTime call() => ahora;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lets every microtask/async continuation of the fire-and-forget license
|
||||||
|
/// check settle (mock prefs + fake port complete immediately).
|
||||||
|
Future<void> _asentar() async {
|
||||||
|
for (var i = 0; i < 10; i++) {
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
@@ -327,6 +357,135 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('verificacion silenciosa de licencia (reembolsos)', () {
|
||||||
|
late _PuertoComprasFalso compras;
|
||||||
|
late _Reloj reloj;
|
||||||
|
late int invalidaciones;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
compras = _PuertoComprasFalso();
|
||||||
|
reloj = _Reloj();
|
||||||
|
invalidaciones = 0;
|
||||||
|
registrarInvalidacionArbolAuto(() => invalidaciones++);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
registrarInvalidacionArbolAuto(() {});
|
||||||
|
await compras.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<EstadoEntitlement> crear({required bool premium}) async {
|
||||||
|
SharedPreferences.setMockInitialValues({'compra_premium_v1': premium});
|
||||||
|
final estado = EstadoEntitlement(
|
||||||
|
prefs: await SharedPreferences.getInstance(),
|
||||||
|
compras: compras,
|
||||||
|
reloj: reloj.call,
|
||||||
|
);
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
await _asentar();
|
||||||
|
return estado;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('se dispara sola al cargar, sin bloquear la carga', () async {
|
||||||
|
final estado = await crear(premium: true);
|
||||||
|
|
||||||
|
expect(estado.esPremium, isTrue);
|
||||||
|
expect(compras.consultasPropiedad, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('desconocido (offline) conserva premium sin notificar nada', () async {
|
||||||
|
final estado = await crear(premium: true);
|
||||||
|
var notificaciones = 0;
|
||||||
|
estado.addListener(() => notificaciones++);
|
||||||
|
|
||||||
|
reloj.ahora = reloj.ahora.add(const Duration(days: 2));
|
||||||
|
await estado.refrescarLicencia();
|
||||||
|
|
||||||
|
expect(estado.esPremium, isTrue);
|
||||||
|
expect(notificaciones, 0);
|
||||||
|
expect(invalidaciones, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('una sola ausencia no revoca', () async {
|
||||||
|
compras.propiedad = ResultadoVerificacionLicencia.noPoseida;
|
||||||
|
final estado = await crear(premium: true);
|
||||||
|
|
||||||
|
expect(estado.esPremium, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('revocacion confirmada: notifica, invalida el arbol de Auto y NUNCA '
|
||||||
|
'toca resultadoUsuario ni compraEnCurso', () async {
|
||||||
|
compras.propiedad = ResultadoVerificacionLicencia.noPoseida;
|
||||||
|
final estado = await crear(premium: true);
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
|
// A restore the user started stays in flight, untouched.
|
||||||
|
unawaited(estado.restaurar());
|
||||||
|
await _asentar();
|
||||||
|
expect(estado.compraEnCurso, isTrue);
|
||||||
|
|
||||||
|
var notificaciones = 0;
|
||||||
|
estado.addListener(() => notificaciones++);
|
||||||
|
|
||||||
|
reloj.ahora = reloj.ahora.add(const Duration(days: 1));
|
||||||
|
await estado.refrescarLicencia();
|
||||||
|
|
||||||
|
expect(estado.esPremium, isFalse);
|
||||||
|
expect(prefs.getBool('compra_premium_v1'), isFalse);
|
||||||
|
expect(notificaciones, greaterThan(0));
|
||||||
|
expect(invalidaciones, 1);
|
||||||
|
expect(estado.resultadoUsuario, isNull);
|
||||||
|
expect(estado.compraEnCurso, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('poseida con la flag en false desbloquea en silencio', () async {
|
||||||
|
compras.propiedad = ResultadoVerificacionLicencia.poseida;
|
||||||
|
final estado = await crear(premium: false);
|
||||||
|
|
||||||
|
expect(estado.esPremium, isTrue);
|
||||||
|
expect(invalidaciones, 1);
|
||||||
|
expect(estado.resultadoUsuario, isNull);
|
||||||
|
expect(estado.compraEnCurso, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refrescarLicencia recoge un cambio hecho por otra via (Android '
|
||||||
|
'Auto) en prefs', () async {
|
||||||
|
final estado = await crear(premium: true);
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
|
await prefs.setBool('compra_premium_v1', false);
|
||||||
|
await estado.refrescarLicencia();
|
||||||
|
|
||||||
|
expect(estado.esPremium, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('una compra real reinicia el contador de ausencias', () async {
|
||||||
|
compras.propiedad = ResultadoVerificacionLicencia.noPoseida;
|
||||||
|
final estado = await crear(premium: false);
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setInt(claveAusenciasLicencia, 1);
|
||||||
|
|
||||||
|
compras.emitir(const EventoCompra(TipoEventoCompra.comprada));
|
||||||
|
await _asentar();
|
||||||
|
|
||||||
|
expect(estado.esPremium, isTrue);
|
||||||
|
expect(prefs.getInt(claveAusenciasLicencia), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sin puerto de compras no verifica nada', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({'compra_premium_v1': true});
|
||||||
|
final estado = EstadoEntitlement(
|
||||||
|
prefs: await SharedPreferences.getInstance(),
|
||||||
|
reloj: reloj.call,
|
||||||
|
);
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
await _asentar();
|
||||||
|
await estado.refrescarLicencia();
|
||||||
|
|
||||||
|
expect(estado.esPremium, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('esPremiumPersistido (headless, sin BuildContext)', () {
|
group('esPremiumPersistido (headless, sin BuildContext)', () {
|
||||||
test('lee la flag persistida directamente desde prefs', () async {
|
test('lee la flag persistida directamente desde prefs', () async {
|
||||||
SharedPreferences.setMockInitialValues({'compra_premium_v1': true});
|
SharedPreferences.setMockInitialValues({'compra_premium_v1': true});
|
||||||
|
|||||||
@@ -46,20 +46,9 @@ const Set<(String locale, String key)> identicalValueAllowlist = {
|
|||||||
('pt', 'welcomeBullet2Title'),
|
('pt', 'welcomeBullet2Title'),
|
||||||
('ru', 'welcomeBullet2Title'),
|
('ru', 'welcomeBullet2Title'),
|
||||||
('zh', 'welcomeBullet2Title'),
|
('zh', 'welcomeBullet2Title'),
|
||||||
(
|
// `premiumBeneficioAndroidAuto` used to live here as a bare "Android
|
||||||
'ar',
|
// Auto" product name. It is now a real sentence describing what PRO adds
|
||||||
'premiumBeneficioAndroidAuto',
|
// in the car, so every locale translates it and no entry belongs here.
|
||||||
), // "Android Auto" -- Google product name (fix/import-alarmas-y-paywall)
|
|
||||||
('bn', 'premiumBeneficioAndroidAuto'),
|
|
||||||
('de', 'premiumBeneficioAndroidAuto'),
|
|
||||||
('fr', 'premiumBeneficioAndroidAuto'),
|
|
||||||
('hi', 'premiumBeneficioAndroidAuto'),
|
|
||||||
('id', 'premiumBeneficioAndroidAuto'),
|
|
||||||
('it', 'premiumBeneficioAndroidAuto'),
|
|
||||||
('ja', 'premiumBeneficioAndroidAuto'),
|
|
||||||
('pt', 'premiumBeneficioAndroidAuto'),
|
|
||||||
('ru', 'premiumBeneficioAndroidAuto'),
|
|
||||||
('zh', 'premiumBeneficioAndroidAuto'),
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Pure symbols / placeholders -- no translatable text at all.
|
// Pure symbols / placeholders -- no translatable text at all.
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'arb_test_helpers.dart';
|
||||||
|
|
||||||
|
/// Play "Deceptive Behavior" guard over the purchase sheet's copy.
|
||||||
|
///
|
||||||
|
/// The free Android Auto root is NOT a paywall: `ConstructorArbolAuto.raiz`
|
||||||
|
/// hands free users a real, browsable `idDestacadas` folder whose rows are
|
||||||
|
/// playable (`hijosDestacadas`), precisely because Google Play cited the old
|
||||||
|
/// "Premium feature" dead-end rows against the Android for Cars App Quality
|
||||||
|
/// Guidelines (see the comment at `navegacion_auto.dart`). What PRO adds in
|
||||||
|
/// the car is the rest of the tree: the full catalogue, favourites, my
|
||||||
|
/// stations and local music.
|
||||||
|
///
|
||||||
|
/// So a bullet reading just "Android Auto" claims the free tier has no
|
||||||
|
/// Android Auto at all, which is false in every locale.
|
||||||
|
void main() {
|
||||||
|
test('premiumBeneficioAndroidAuto no es el nombre pelado del producto en '
|
||||||
|
'ninguna de las 13 locales', () {
|
||||||
|
final ofensores = <String>[];
|
||||||
|
|
||||||
|
for (final locale in supportedArbLocales) {
|
||||||
|
final valor = readArb(locale)['premiumBeneficioAndroidAuto'] as String?;
|
||||||
|
if (valor == null) continue;
|
||||||
|
if (valor.trim() == 'Android Auto') ofensores.add(locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(
|
||||||
|
ofensores,
|
||||||
|
isEmpty,
|
||||||
|
reason:
|
||||||
|
'Free users already get a playable Android Auto folder, so a '
|
||||||
|
'bullet whose whole text is the product name sells them '
|
||||||
|
'something they have. Describe what PRO actually adds in the '
|
||||||
|
'car instead. Offending locales: $ofensores',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('premiumBeneficioAndroidAuto nombra lo que PRO anade de verdad en el '
|
||||||
|
'coche (plantilla es)', () {
|
||||||
|
final es = readArb('es')['premiumBeneficioAndroidAuto'] as String;
|
||||||
|
final minusculas = es.toLowerCase();
|
||||||
|
|
||||||
|
expect(minusculas, contains('android auto'));
|
||||||
|
expect(
|
||||||
|
minusculas,
|
||||||
|
contains('catálogo'),
|
||||||
|
reason: 'the full catalogue (idTodas) is the headline PRO folder',
|
||||||
|
);
|
||||||
|
expect(minusculas, contains('favoritos'));
|
||||||
|
expect(minusculas, contains('mis emisoras'));
|
||||||
|
expect(minusculas, contains('música local'));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:pluriwave/estado/estado_visualizador.dart';
|
||||||
|
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||||
|
import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_visualizador.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// Play sensitive-permission contract for the waveform visualizer's
|
||||||
|
/// microphone opt-in.
|
||||||
|
///
|
||||||
|
/// Turning the switch ON is what eventually makes the native side ask for
|
||||||
|
/// `RECORD_AUDIO`, so the explanation must be on screen and accepted BEFORE
|
||||||
|
/// the flag flips — never a system dialog the user meets cold.
|
||||||
|
void main() {
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<EstadoVisualizador> montar(WidgetTester tester) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final estado = EstadoVisualizador(prefs: prefs);
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ChangeNotifierProvider<EstadoVisualizador>.value(
|
||||||
|
value: estado,
|
||||||
|
child: const MaterialApp(
|
||||||
|
locale: Locale('es'),
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: PantallaAjustesVisualizador(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
return estado;
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLocalizations textos(WidgetTester tester) => AppLocalizations.of(
|
||||||
|
tester.element(find.byType(PantallaAjustesVisualizador)),
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('arranca desactivado: la onda real es opt-in, nunca el defecto', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final estado = await montar(tester);
|
||||||
|
|
||||||
|
expect(estado.ondaRealHabilitada, isFalse);
|
||||||
|
expect(tester.widget<Switch>(find.byType(Switch)).value, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'activar muestra PRIMERO la explicacion y NO cambia el ajuste todavia',
|
||||||
|
(tester) async {
|
||||||
|
final estado = await montar(tester);
|
||||||
|
final l10n = textos(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.byType(Switch));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final dialogo = find.byKey(
|
||||||
|
const ValueKey('visualizador-explicacion-permiso'),
|
||||||
|
);
|
||||||
|
expect(dialogo, findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.descendant(
|
||||||
|
of: dialogo,
|
||||||
|
matching: find.text(l10n.visualizerRealWavePermissionExplanation),
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
estado.ondaRealHabilitada,
|
||||||
|
isFalse,
|
||||||
|
reason:
|
||||||
|
'the flag must not flip while the explanation is still on '
|
||||||
|
'screen — flipping it is what triggers the permission request',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('cancelar la explicacion deja el ajuste desactivado', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final estado = await montar(tester);
|
||||||
|
final l10n = textos(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.byType(Switch));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text(l10n.cancelAction));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(estado.ondaRealHabilitada, isFalse);
|
||||||
|
expect(tester.widget<Switch>(find.byType(Switch)).value, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('aceptar la explicacion activa el ajuste y lo persiste', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final estado = await montar(tester);
|
||||||
|
final l10n = textos(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.byType(Switch));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text(l10n.visualizerRealWaveEnableAction));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(estado.ondaRealHabilitada, isTrue);
|
||||||
|
expect(tester.widget<Switch>(find.byType(Switch)).value, isTrue);
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
expect(prefs.getBool(EstadoVisualizador.claveOndaReal), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('desactivar NO pide explicacion: retirar un permiso es libre', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
SharedPreferences.setMockInitialValues({
|
||||||
|
EstadoVisualizador.claveOndaReal: true,
|
||||||
|
});
|
||||||
|
final estado = await montar(tester);
|
||||||
|
|
||||||
|
expect(estado.ondaRealHabilitada, isTrue);
|
||||||
|
|
||||||
|
await tester.tap(find.byType(Switch));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.byKey(const ValueKey('visualizador-explicacion-permiso')),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
expect(estado.ondaRealHabilitada, isFalse);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import 'package:pluriwave/estado/estado_entitlement.dart';
|
|||||||
import 'package:pluriwave/estado/estado_grabacion.dart';
|
import 'package:pluriwave/estado/estado_grabacion.dart';
|
||||||
import 'package:pluriwave/estado/estado_idioma.dart';
|
import 'package:pluriwave/estado/estado_idioma.dart';
|
||||||
import 'package:pluriwave/estado/estado_radio.dart';
|
import 'package:pluriwave/estado/estado_radio.dart';
|
||||||
|
import 'package:pluriwave/estado/estado_visualizador.dart';
|
||||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||||
import 'package:pluriwave/modelos/archivo_grabacion.dart';
|
import 'package:pluriwave/modelos/archivo_grabacion.dart';
|
||||||
import 'package:pluriwave/pantallas/pantalla_ajustes.dart';
|
import 'package:pluriwave/pantallas/pantalla_ajustes.dart';
|
||||||
@@ -108,6 +109,9 @@ void main() {
|
|||||||
ChangeNotifierProvider<EstadoEntitlement>(
|
ChangeNotifierProvider<EstadoEntitlement>(
|
||||||
create: (_) => EstadoEntitlement(prefs: null),
|
create: (_) => EstadoEntitlement(prefs: null),
|
||||||
),
|
),
|
||||||
|
ChangeNotifierProvider<EstadoVisualizador>(
|
||||||
|
create: (_) => EstadoVisualizador(prefs: null),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
locale: const Locale('en'),
|
locale: const Locale('en'),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:pluriwave/estado/estado_entitlement.dart';
|
|||||||
import 'package:pluriwave/estado/estado_grabacion.dart';
|
import 'package:pluriwave/estado/estado_grabacion.dart';
|
||||||
import 'package:pluriwave/estado/estado_idioma.dart';
|
import 'package:pluriwave/estado/estado_idioma.dart';
|
||||||
import 'package:pluriwave/estado/estado_radio.dart';
|
import 'package:pluriwave/estado/estado_radio.dart';
|
||||||
|
import 'package:pluriwave/estado/estado_visualizador.dart';
|
||||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||||
import 'package:pluriwave/pantallas/pantalla_ajustes.dart';
|
import 'package:pluriwave/pantallas/pantalla_ajustes.dart';
|
||||||
import 'package:pluriwave/servicios/servicio_grabacion_radio.dart';
|
import 'package:pluriwave/servicios/servicio_grabacion_radio.dart';
|
||||||
@@ -54,6 +55,9 @@ void main() {
|
|||||||
ChangeNotifierProvider<EstadoEntitlement>(
|
ChangeNotifierProvider<EstadoEntitlement>(
|
||||||
create: (_) => EstadoEntitlement(prefs: null),
|
create: (_) => EstadoEntitlement(prefs: null),
|
||||||
),
|
),
|
||||||
|
ChangeNotifierProvider<EstadoVisualizador>(
|
||||||
|
create: (_) => EstadoVisualizador(prefs: null),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
locale: const Locale('en'),
|
locale: const Locale('en'),
|
||||||
@@ -100,7 +104,7 @@ void main() {
|
|||||||
// completes the remaining 5 sections, so the root is now exactly 4
|
// completes the remaining 5 sections, so the root is now exactly 4
|
||||||
// GrupoAjustes cards, under 400 lines.
|
// GrupoAjustes cards, under 400 lines.
|
||||||
group('WU3a — AUDIO and EMISORAS groups', () {
|
group('WU3a — AUDIO and EMISORAS groups', () {
|
||||||
testWidgets('AUDIO group renders exactly 3 nav rows, no inline controls', (
|
testWidgets('AUDIO group renders exactly 4 nav rows, no inline controls', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
setLargeSurface(tester);
|
setLargeSurface(tester);
|
||||||
@@ -114,6 +118,10 @@ void main() {
|
|||||||
expect(find.text('AUDIO'), findsOneWidget);
|
expect(find.text('AUDIO'), findsOneWidget);
|
||||||
expect(find.text('Equalizer'), findsOneWidget);
|
expect(find.text('Equalizer'), findsOneWidget);
|
||||||
expect(find.text('Advanced Equalization Options'), findsOneWidget);
|
expect(find.text('Advanced Equalization Options'), findsOneWidget);
|
||||||
|
// The waveform visualizer's microphone opt-in is reachable from the
|
||||||
|
// root: the RECORD_AUDIO request must have a settings home the user
|
||||||
|
// can find, not only the moment they happen to press play.
|
||||||
|
expect(find.text('Real audio waveform'), findsOneWidget);
|
||||||
expect(find.text('Sleep timer'), findsOneWidget);
|
expect(find.text('Sleep timer'), findsOneWidget);
|
||||||
|
|
||||||
// Zero inline controls: the old always-visible enable switch and
|
// Zero inline controls: the old always-visible enable switch and
|
||||||
|
|||||||
@@ -15,16 +15,16 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
|
|
||||||
/// WU15: the recordings library screen — storage usage, browsable rows
|
/// WU15: the recordings library screen — storage usage, browsable rows
|
||||||
/// (name/date/duration/size) with inline playback, and a "⋮" menu
|
/// (name/date/duration/size) with inline playback, and a "⋮" menu
|
||||||
/// constrained to exactly Rename/Share/Delete.
|
/// constrained to exactly Rename/Open-in-another-app/Delete.
|
||||||
///
|
///
|
||||||
/// [ReproductorGrabaciones] is always injected with a fake here:
|
/// [ReproductorGrabaciones] is always injected with a fake here:
|
||||||
/// constructing a real `just_audio.AudioPlayer` needs platform
|
/// constructing a real `just_audio.AudioPlayer` needs platform
|
||||||
/// `MethodChannel`s this suite does not mock — the same documented
|
/// `MethodChannel`s this suite does not mock — the same documented
|
||||||
/// constraint `cola_local_test.dart` records for `PluriWaveAudioHandler`.
|
/// constraint `cola_local_test.dart` records for `PluriWaveAudioHandler`.
|
||||||
/// Likewise, `compartir` is always injected with a fake recorder instead of
|
/// Likewise, `abrirEnOtraApp` is always injected with a fake recorder
|
||||||
/// the real `share_plus` call, since this suite does not mock that channel
|
/// instead of the real `pluriwave/file_actions` round trip, since this suite
|
||||||
/// either (see `pantalla_ajustes_backup_test.dart`'s note on the same
|
/// does not mock that channel either (see `pantalla_ajustes_backup_test.dart`
|
||||||
/// constraint).
|
/// for the same constraint).
|
||||||
///
|
///
|
||||||
/// Pre-existing project constraint (see `pantalla_ajustes_test.dart`):
|
/// Pre-existing project constraint (see `pantalla_ajustes_test.dart`):
|
||||||
/// PluriGlassSurface paints a background over ListTile's ink layer, which
|
/// PluriGlassSurface paints a background over ListTile's ink layer, which
|
||||||
@@ -72,7 +72,7 @@ void main() {
|
|||||||
Widget buildScreen({
|
Widget buildScreen({
|
||||||
required EstadoGrabacion estado,
|
required EstadoGrabacion estado,
|
||||||
required ReproductorGrabaciones reproductor,
|
required ReproductorGrabaciones reproductor,
|
||||||
Future<void> Function(String ruta)? compartir,
|
Future<bool> Function(String ruta)? abrirEnOtraApp,
|
||||||
}) {
|
}) {
|
||||||
return ListenableProvider<EstadoGrabacion>.value(
|
return ListenableProvider<EstadoGrabacion>.value(
|
||||||
value: estado,
|
value: estado,
|
||||||
@@ -82,7 +82,7 @@ void main() {
|
|||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
home: PantallaGrabaciones(
|
home: PantallaGrabaciones(
|
||||||
reproductor: reproductor,
|
reproductor: reproductor,
|
||||||
compartir: compartir ?? (_) async {},
|
abrirEnOtraApp: abrirEnOtraApp ?? (_) async => true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -117,6 +117,66 @@ void main() {
|
|||||||
expect(find.text('My recordings'), findsOneWidget);
|
expect(find.text('My recordings'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('aviso de uso privado', () {
|
||||||
|
/// Recording a broadcast is defensible as a private copy, and stops
|
||||||
|
/// being defensible the moment the product reads as a redistribution
|
||||||
|
/// tool. The library screen had no such statement at all, while the
|
||||||
|
/// manifest already exposes the recordings folder to the system file
|
||||||
|
/// manager, so the notice states the intended use in plain words.
|
||||||
|
testWidgets(
|
||||||
|
'la biblioteca muestra el aviso de uso personal con la lista vacia',
|
||||||
|
(tester) async {
|
||||||
|
final estado = EstadoGrabacion(
|
||||||
|
esPremium: () => true,
|
||||||
|
servicio: _FakeServicioGrabacionConArchivos(
|
||||||
|
const [],
|
||||||
|
maxBytesFijo: 200 * 1024 * 1024,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildScreen(
|
||||||
|
estado: estado,
|
||||||
|
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
final l10n = AppLocalizations.of(
|
||||||
|
tester.element(find.byType(PantallaGrabaciones)),
|
||||||
|
);
|
||||||
|
expect(find.text(l10n.recordingsPrivateUseNotice), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('el aviso sigue presente con grabaciones en la lista', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final estado = EstadoGrabacion(
|
||||||
|
esPremium: () => true,
|
||||||
|
servicio: _FakeServicioGrabacionConArchivos([
|
||||||
|
fijaA,
|
||||||
|
fijaB,
|
||||||
|
], maxBytesFijo: 200 * 1024 * 1024),
|
||||||
|
);
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildScreen(
|
||||||
|
estado: estado,
|
||||||
|
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
final l10n = AppLocalizations.of(
|
||||||
|
tester.element(find.byType(PantallaGrabaciones)),
|
||||||
|
);
|
||||||
|
expect(find.text(l10n.recordingsPrivateUseNotice), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'WU15b: tapping the settings icon pushes the folder/size settings '
|
'WU15b: tapping the settings icon pushes the folder/size settings '
|
||||||
'screen (PantallaAjustesGrabaciones stays reachable, now from within '
|
'screen (PantallaAjustesGrabaciones stays reachable, now from within '
|
||||||
@@ -379,9 +439,12 @@ void main() {
|
|||||||
expect(find.byIcon(Icons.pause_circle_filled_rounded), findsNothing);
|
expect(find.byIcon(Icons.pause_circle_filled_rounded), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('15.3: the "⋮" menu exposes exactly Rename, Share, Delete', (
|
// The middle entry used to be Share, which handed the audio file to the
|
||||||
tester,
|
// system share sheet. It is now a LOCAL open: play your own recording in
|
||||||
) async {
|
// another app on the same device. The exact-count assertion is the guard
|
||||||
|
// that no off-device action creeps back in beside it.
|
||||||
|
testWidgets('15.3: the "⋮" menu exposes exactly Rename, Open in another '
|
||||||
|
'app, Delete', (tester) async {
|
||||||
final estado = EstadoGrabacion(
|
final estado = EstadoGrabacion(
|
||||||
esPremium: () => true,
|
esPremium: () => true,
|
||||||
servicio: _FakeServicioGrabacionConArchivos([
|
servicio: _FakeServicioGrabacionConArchivos([
|
||||||
@@ -405,7 +468,10 @@ void main() {
|
|||||||
find.widgetWithText(PopupMenuItem<String>, 'Rename'),
|
find.widgetWithText(PopupMenuItem<String>, 'Rename'),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
expect(find.widgetWithText(PopupMenuItem<String>, 'Share'), findsOneWidget);
|
expect(
|
||||||
|
find.widgetWithText(PopupMenuItem<String>, 'Open in another app'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
find.widgetWithText(PopupMenuItem<String>, 'Delete'),
|
find.widgetWithText(PopupMenuItem<String>, 'Delete'),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
@@ -555,10 +621,15 @@ void main() {
|
|||||||
skip: true,
|
skip: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
testWidgets(
|
/// The row menu used to hand the audio file to the system share sheet,
|
||||||
'15.4-C: Share invokes the injected share callback with the file path',
|
/// which is redistribution of someone else's broadcast. What the owner
|
||||||
(tester) async {
|
/// actually wanted is to play your own recording in another app on the
|
||||||
final compartidos = <String>[];
|
/// same device, so the action is a local ACTION_VIEW instead.
|
||||||
|
group('15.4-C: abrir la grabacion en otra app del dispositivo', () {
|
||||||
|
testWidgets('invoca el seam de apertura local con la ruta del archivo', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final abiertos = <String>[];
|
||||||
final estado = EstadoGrabacion(
|
final estado = EstadoGrabacion(
|
||||||
esPremium: () => true,
|
esPremium: () => true,
|
||||||
servicio: _FakeServicioGrabacionConArchivos([
|
servicio: _FakeServicioGrabacionConArchivos([
|
||||||
@@ -571,21 +642,68 @@ void main() {
|
|||||||
buildScreen(
|
buildScreen(
|
||||||
estado: estado,
|
estado: estado,
|
||||||
reproductor: _ReproductorGrabacionesFake(const {}),
|
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||||
compartir: (ruta) async {
|
abrirEnOtraApp: (ruta) async {
|
||||||
compartidos.add(ruta);
|
abiertos.add(ruta);
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await pumpStable(tester);
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
final l10n = AppLocalizations.of(
|
||||||
|
tester.element(find.byType(PantallaGrabaciones)),
|
||||||
|
);
|
||||||
await tester.tap(find.byIcon(Icons.more_vert_rounded));
|
await tester.tap(find.byIcon(Icons.more_vert_rounded));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.widgetWithText(PopupMenuItem<String>, 'Share'));
|
await tester.tap(
|
||||||
|
find.widgetWithText(PopupMenuItem<String>, l10n.recordingActionOpenIn),
|
||||||
|
);
|
||||||
await pumpStable(tester);
|
await pumpStable(tester);
|
||||||
|
|
||||||
expect(compartidos, [fijaA.ruta]);
|
expect(abiertos, [fijaA.ruta]);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'si ningun reproductor del dispositivo puede abrirla, lo dice en vez '
|
||||||
|
'de fallar en silencio',
|
||||||
|
(tester) async {
|
||||||
|
final estado = EstadoGrabacion(
|
||||||
|
esPremium: () => true,
|
||||||
|
servicio: _FakeServicioGrabacionConArchivos([
|
||||||
|
fijaA,
|
||||||
|
], maxBytesFijo: 200 * 1024 * 1024),
|
||||||
|
);
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildScreen(
|
||||||
|
estado: estado,
|
||||||
|
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||||
|
abrirEnOtraApp: (_) async => false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
final l10n = AppLocalizations.of(
|
||||||
|
tester.element(find.byType(PantallaGrabaciones)),
|
||||||
|
);
|
||||||
|
await tester.tap(find.byIcon(Icons.more_vert_rounded));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(
|
||||||
|
find.widgetWithText(
|
||||||
|
PopupMenuItem<String>,
|
||||||
|
l10n.recordingActionOpenIn,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await pumpStable(tester);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.widgetWithText(SnackBar, l10n.recordingOpenNoAppError),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Infrastructure ──────────────────────────────────────────────────────────
|
// ── Infrastructure ──────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:pluriwave/estado/estado_ecualizador.dart';
|
|||||||
import 'package:pluriwave/estado/estado_grabacion.dart';
|
import 'package:pluriwave/estado/estado_grabacion.dart';
|
||||||
import 'package:pluriwave/estado/estado_navegacion.dart';
|
import 'package:pluriwave/estado/estado_navegacion.dart';
|
||||||
import 'package:pluriwave/estado/estado_radio.dart';
|
import 'package:pluriwave/estado/estado_radio.dart';
|
||||||
|
import 'package:pluriwave/estado/estado_visualizador.dart';
|
||||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||||
import 'package:pluriwave/modelos/emisora.dart';
|
import 'package:pluriwave/modelos/emisora.dart';
|
||||||
import 'package:pluriwave/pantallas/pantalla_inicio.dart';
|
import 'package:pluriwave/pantallas/pantalla_inicio.dart';
|
||||||
@@ -804,6 +805,9 @@ Widget _conProviders(
|
|||||||
ChangeNotifierProvider<EstadoRadio>.value(value: estado),
|
ChangeNotifierProvider<EstadoRadio>.value(value: estado),
|
||||||
ListenableProvider<EstadoEcualizador>.value(value: estado.ecualizador),
|
ListenableProvider<EstadoEcualizador>.value(value: estado.ecualizador),
|
||||||
ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion),
|
ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion),
|
||||||
|
ChangeNotifierProvider<EstadoVisualizador>(
|
||||||
|
create: (_) => EstadoVisualizador(prefs: null),
|
||||||
|
),
|
||||||
ListenableProvider<EstadoBusqueda>.value(value: estado.busqueda),
|
ListenableProvider<EstadoBusqueda>.value(value: estado.busqueda),
|
||||||
ChangeNotifierProvider<EstadoNavegacionRaiz>.value(
|
ChangeNotifierProvider<EstadoNavegacionRaiz>.value(
|
||||||
value: navegacion ?? EstadoNavegacionRaiz(),
|
value: navegacion ?? EstadoNavegacionRaiz(),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:pluriwave/estado/estado_ecualizador.dart';
|
import 'package:pluriwave/estado/estado_ecualizador.dart';
|
||||||
import 'package:pluriwave/estado/estado_grabacion.dart';
|
import 'package:pluriwave/estado/estado_grabacion.dart';
|
||||||
import 'package:pluriwave/estado/estado_radio.dart';
|
import 'package:pluriwave/estado/estado_radio.dart';
|
||||||
|
import 'package:pluriwave/estado/estado_visualizador.dart';
|
||||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||||
import 'package:pluriwave/modelos/emisora.dart';
|
import 'package:pluriwave/modelos/emisora.dart';
|
||||||
import 'package:pluriwave/modelos/preset_ecualizador.dart';
|
import 'package:pluriwave/modelos/preset_ecualizador.dart';
|
||||||
@@ -106,6 +107,9 @@ void main() {
|
|||||||
ChangeNotifierProvider<EstadoRadio>.value(value: estado),
|
ChangeNotifierProvider<EstadoRadio>.value(value: estado),
|
||||||
ListenableProvider<EstadoEcualizador>.value(value: estado.ecualizador),
|
ListenableProvider<EstadoEcualizador>.value(value: estado.ecualizador),
|
||||||
ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion),
|
ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion),
|
||||||
|
ChangeNotifierProvider<EstadoVisualizador>(
|
||||||
|
create: (_) => EstadoVisualizador(prefs: null),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
locale: const Locale('en'),
|
locale: const Locale('en'),
|
||||||
@@ -482,6 +486,32 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// This tile shares the STATION (its name and its url) — never an audio
|
||||||
|
// file. It used to borrow `recordingActionShare`, the recordings
|
||||||
|
// library's own menu label, so one key stood for two different
|
||||||
|
// actions and the tile could not say which one it performed. Its
|
||||||
|
// label is now its own key, and it names the station.
|
||||||
|
testWidgets('la etiqueta del boton de compartir nombra la emisora, no un '
|
||||||
|
'generico "Compartir" compartido con la biblioteca de grabaciones', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final estado = crearEstado();
|
||||||
|
addTearDown(estado.dispose);
|
||||||
|
|
||||||
|
await montarPantalla(tester, estado);
|
||||||
|
|
||||||
|
final l10n = AppLocalizations.of(
|
||||||
|
tester.element(find.byType(PantallaReproductor)),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byKey(const Key('player-tool-share')),
|
||||||
|
matching: find.text(l10n.stationActionShare),
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'tapping EQ propio opens a sheet reusing EcualizadorWidget by exact runtime type',
|
'tapping EQ propio opens a sheet reusing EcualizadorWidget by exact runtime type',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'package:pluriwave/estado/estado_entitlement.dart';
|
|||||||
import 'package:pluriwave/estado/estado_grabacion.dart';
|
import 'package:pluriwave/estado/estado_grabacion.dart';
|
||||||
import 'package:pluriwave/estado/estado_idioma.dart';
|
import 'package:pluriwave/estado/estado_idioma.dart';
|
||||||
import 'package:pluriwave/estado/estado_radio.dart';
|
import 'package:pluriwave/estado/estado_radio.dart';
|
||||||
|
import 'package:pluriwave/estado/estado_visualizador.dart';
|
||||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||||
import 'package:pluriwave/pantallas/pantalla_ajustes.dart';
|
import 'package:pluriwave/pantallas/pantalla_ajustes.dart';
|
||||||
import 'package:pluriwave/pantallas/pantalla_alarmas.dart';
|
import 'package:pluriwave/pantallas/pantalla_alarmas.dart';
|
||||||
@@ -80,6 +81,9 @@ void main() {
|
|||||||
ChangeNotifierProvider<EstadoEntitlement>(
|
ChangeNotifierProvider<EstadoEntitlement>(
|
||||||
create: (_) => EstadoEntitlement(prefs: null),
|
create: (_) => EstadoEntitlement(prefs: null),
|
||||||
),
|
),
|
||||||
|
ChangeNotifierProvider<EstadoVisualizador>(
|
||||||
|
create: (_) => EstadoVisualizador(prefs: null),
|
||||||
|
),
|
||||||
Provider<ServicioAnuncios>(
|
Provider<ServicioAnuncios>(
|
||||||
create: (_) => ServicioAnuncios(esPremium: () => true),
|
create: (_) => ServicioAnuncios(esPremium: () => true),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'package:pluriwave/estado/estado_entitlement.dart';
|
|||||||
import 'package:pluriwave/estado/estado_grabacion.dart';
|
import 'package:pluriwave/estado/estado_grabacion.dart';
|
||||||
import 'package:pluriwave/estado/estado_idioma.dart';
|
import 'package:pluriwave/estado/estado_idioma.dart';
|
||||||
import 'package:pluriwave/estado/estado_radio.dart';
|
import 'package:pluriwave/estado/estado_radio.dart';
|
||||||
|
import 'package:pluriwave/estado/estado_visualizador.dart';
|
||||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||||
import 'package:pluriwave/pantallas/pantalla_ajustes.dart';
|
import 'package:pluriwave/pantallas/pantalla_ajustes.dart';
|
||||||
import 'package:pluriwave/pantallas/pantalla_alarmas.dart';
|
import 'package:pluriwave/pantallas/pantalla_alarmas.dart';
|
||||||
@@ -78,6 +79,9 @@ void main() {
|
|||||||
ChangeNotifierProvider<EstadoEntitlement>(
|
ChangeNotifierProvider<EstadoEntitlement>(
|
||||||
create: (_) => EstadoEntitlement(prefs: null),
|
create: (_) => EstadoEntitlement(prefs: null),
|
||||||
),
|
),
|
||||||
|
ChangeNotifierProvider<EstadoVisualizador>(
|
||||||
|
create: (_) => EstadoVisualizador(prefs: null),
|
||||||
|
),
|
||||||
Provider<ServicioAnuncios>(
|
Provider<ServicioAnuncios>(
|
||||||
create: (_) => ServicioAnuncios(esPremium: () => true),
|
create: (_) => ServicioAnuncios(esPremium: () => true),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:in_app_purchase/in_app_purchase.dart';
|
import 'package:in_app_purchase/in_app_purchase.dart';
|
||||||
|
import 'package:in_app_purchase_android/billing_client_wrappers.dart';
|
||||||
|
import 'package:in_app_purchase_android/in_app_purchase_android.dart';
|
||||||
import 'package:pluriwave/servicios/servicio_compras.dart';
|
import 'package:pluriwave/servicios/servicio_compras.dart';
|
||||||
|
|
||||||
/// Port-boundary tests (Design ADR-2 "keeps Strict TDD viable with zero
|
/// Port-boundary tests (Design ADR-2 "keeps Strict TDD viable with zero
|
||||||
@@ -17,8 +19,12 @@ import 'package:pluriwave/servicios/servicio_compras.dart';
|
|||||||
class _InAppPurchaseFalso implements InAppPurchase {
|
class _InAppPurchaseFalso implements InAppPurchase {
|
||||||
final _compras = StreamController<List<PurchaseDetails>>.broadcast();
|
final _compras = StreamController<List<PurchaseDetails>>.broadcast();
|
||||||
int restauracionesPedidas = 0;
|
int restauracionesPedidas = 0;
|
||||||
|
bool disponible = true;
|
||||||
final completadas = <PurchaseDetails>[];
|
final completadas = <PurchaseDetails>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isAvailable() async => disponible;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<List<PurchaseDetails>> get purchaseStream => _compras.stream;
|
Stream<List<PurchaseDetails>> get purchaseStream => _compras.stream;
|
||||||
|
|
||||||
@@ -52,6 +58,40 @@ PurchaseDetails _compraFalsa(PurchaseStatus status) => PurchaseDetails(
|
|||||||
status: status,
|
status: status,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// A Play Billing past purchase as `queryPastPurchases` returns it.
|
||||||
|
GooglePlayPurchaseDetails _compraPasada(
|
||||||
|
PurchaseStatus status, {
|
||||||
|
String productId = ServicioComprasPlayBilling.idProducto,
|
||||||
|
}) => GooglePlayPurchaseDetails(
|
||||||
|
purchaseID: 'GPA.1',
|
||||||
|
productID: productId,
|
||||||
|
verificationData: PurchaseVerificationData(
|
||||||
|
localVerificationData: '{}',
|
||||||
|
serverVerificationData: 'token',
|
||||||
|
source: 'google_play',
|
||||||
|
),
|
||||||
|
transactionDate: '0',
|
||||||
|
status: status,
|
||||||
|
billingClientPurchase: PurchaseWrapper(
|
||||||
|
orderId: 'GPA.1',
|
||||||
|
packageName: 'es.freetimelab.pluriwave',
|
||||||
|
purchaseTime: 0,
|
||||||
|
purchaseToken: 'token',
|
||||||
|
signature: 'firma',
|
||||||
|
products: <String>[productId],
|
||||||
|
isAutoRenewing: false,
|
||||||
|
originalJson: '{}',
|
||||||
|
isAcknowledged: true,
|
||||||
|
purchaseState: PurchaseStateWrapper.purchased,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
IAPError _errorBilling() => IAPError(
|
||||||
|
source: 'google_play',
|
||||||
|
code: 'restore_transactions_failed',
|
||||||
|
message: 'BillingResponse.serviceUnavailable',
|
||||||
|
);
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('eventoDesdeEstadoCompra', () {
|
group('eventoDesdeEstadoCompra', () {
|
||||||
test('purchased -> comprada', () {
|
test('purchased -> comprada', () {
|
||||||
@@ -137,8 +177,8 @@ void main() {
|
|||||||
final servicio = ServicioComprasPlayBilling(inAppPurchase: iap);
|
final servicio = ServicioComprasPlayBilling(inAppPurchase: iap);
|
||||||
addTearDown(servicio.dispose);
|
addTearDown(servicio.dispose);
|
||||||
|
|
||||||
final compra =
|
final compra = _compraFalsa(PurchaseStatus.purchased)
|
||||||
_compraFalsa(PurchaseStatus.purchased)..pendingCompletePurchase = true;
|
..pendingCompletePurchase = true;
|
||||||
iap.emitir(<PurchaseDetails>[compra]);
|
iap.emitir(<PurchaseDetails>[compra]);
|
||||||
await Future<void>.delayed(Duration.zero);
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
@@ -146,6 +186,153 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('ServicioComprasPlayBilling.consultarPropiedad (verificacion '
|
||||||
|
'silenciosa)', () {
|
||||||
|
late _InAppPurchaseFalso iap;
|
||||||
|
|
||||||
|
setUp(() => iap = _InAppPurchaseFalso());
|
||||||
|
tearDown(() => iap.dispose());
|
||||||
|
|
||||||
|
ServicioComprasPlayBilling servicioCon(
|
||||||
|
Future<QueryPurchaseDetailsResponse> Function() consulta, {
|
||||||
|
Duration limite = const Duration(seconds: 10),
|
||||||
|
}) {
|
||||||
|
final servicio = ServicioComprasPlayBilling(
|
||||||
|
inAppPurchase: iap,
|
||||||
|
consultarComprasPasadas: consulta,
|
||||||
|
limiteConsultaPropiedad: limite,
|
||||||
|
);
|
||||||
|
addTearDown(servicio.dispose);
|
||||||
|
return servicio;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('producto comprado -> poseida', () async {
|
||||||
|
final servicio = servicioCon(
|
||||||
|
() async => QueryPurchaseDetailsResponse(
|
||||||
|
pastPurchases: [_compraPasada(PurchaseStatus.purchased)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await servicio.consultarPropiedad(),
|
||||||
|
ResultadoVerificacionLicencia.poseida,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('respuesta correcta sin el producto -> noPoseida', () async {
|
||||||
|
final servicio = servicioCon(
|
||||||
|
() async => QueryPurchaseDetailsResponse(
|
||||||
|
pastPurchases: [
|
||||||
|
_compraPasada(PurchaseStatus.purchased, productId: 'otro'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await servicio.consultarPropiedad(),
|
||||||
|
ResultadoVerificacionLicencia.noPoseida,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('respuesta con error y sin producto -> desconocido', () async {
|
||||||
|
final servicio = servicioCon(
|
||||||
|
() async => QueryPurchaseDetailsResponse(
|
||||||
|
pastPurchases: const [],
|
||||||
|
error: _errorBilling(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await servicio.consultarPropiedad(),
|
||||||
|
ResultadoVerificacionLicencia.desconocido,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'respuesta con error parcial pero con el producto -> poseida',
|
||||||
|
() async {
|
||||||
|
final servicio = servicioCon(
|
||||||
|
() async => QueryPurchaseDetailsResponse(
|
||||||
|
pastPurchases: [_compraPasada(PurchaseStatus.purchased)],
|
||||||
|
error: _errorBilling(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await servicio.consultarPropiedad(),
|
||||||
|
ResultadoVerificacionLicencia.poseida,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('compra pendiente -> desconocido (nunca revoca)', () async {
|
||||||
|
final servicio = servicioCon(
|
||||||
|
() async => QueryPurchaseDetailsResponse(
|
||||||
|
pastPurchases: [_compraPasada(PurchaseStatus.pending)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await servicio.consultarPropiedad(),
|
||||||
|
ResultadoVerificacionLicencia.desconocido,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('billing no disponible -> desconocido, sin consultar', () async {
|
||||||
|
var consultas = 0;
|
||||||
|
iap.disponible = false;
|
||||||
|
final servicio = servicioCon(() async {
|
||||||
|
consultas++;
|
||||||
|
return QueryPurchaseDetailsResponse(pastPurchases: const []);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await servicio.consultarPropiedad(),
|
||||||
|
ResultadoVerificacionLicencia.desconocido,
|
||||||
|
);
|
||||||
|
expect(consultas, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('excepcion -> desconocido', () async {
|
||||||
|
final servicio = servicioCon(
|
||||||
|
() async => throw Exception('BillingClient desconectado'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await servicio.consultarPropiedad(),
|
||||||
|
ResultadoVerificacionLicencia.desconocido,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('timeout -> desconocido', () async {
|
||||||
|
final servicio = servicioCon(
|
||||||
|
() => Completer<QueryPurchaseDetailsResponse>().future,
|
||||||
|
limite: const Duration(milliseconds: 10),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await servicio.consultarPropiedad(),
|
||||||
|
ResultadoVerificacionLicencia.desconocido,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('es silenciosa: no emite eventos de compra ni usa '
|
||||||
|
'restorePurchases', () async {
|
||||||
|
final servicio = servicioCon(
|
||||||
|
() async => QueryPurchaseDetailsResponse(pastPurchases: const []),
|
||||||
|
);
|
||||||
|
final eventos = <EventoCompra>[];
|
||||||
|
final sub = servicio.eventos.listen(eventos.add);
|
||||||
|
addTearDown(sub.cancel);
|
||||||
|
|
||||||
|
await servicio.consultarPropiedad();
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(eventos, isEmpty);
|
||||||
|
expect(iap.restauracionesPedidas, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('idProducto es el identificador unico no-consumible', () {
|
test('idProducto es el identificador unico no-consumible', () {
|
||||||
expect(ServicioComprasPlayBilling.idProducto, 'pluriwave_premium');
|
expect(ServicioComprasPlayBilling.idProducto, 'pluriwave_premium');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||||
|
import 'package:pluriwave/servicios/verificacion_licencia.dart';
|
||||||
|
|
||||||
|
/// Android Auto (headless) trigger of the silent license check: the
|
||||||
|
/// `registrar*` seam `main.dart` wires, exercised without a real
|
||||||
|
/// `PluriWaveAudioHandler`.
|
||||||
|
void main() {
|
||||||
|
late int invalidaciones;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
invalidaciones = 0;
|
||||||
|
registrarInvalidacionArbolAuto(() => invalidaciones++);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
registrarInvalidacionArbolAuto(() {});
|
||||||
|
registrarVerificacionLicenciaAuto(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sin verificador registrado es un no-op que nunca lanza', () async {
|
||||||
|
registrarVerificacionLicenciaAuto(null);
|
||||||
|
|
||||||
|
await dispararVerificacionLicenciaAuto();
|
||||||
|
|
||||||
|
expect(invalidaciones, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('una revocacion invalida el arbol de Auto', () async {
|
||||||
|
registrarVerificacionLicenciaAuto(() async => CambioLicencia.revocada);
|
||||||
|
|
||||||
|
await dispararVerificacionLicenciaAuto();
|
||||||
|
|
||||||
|
expect(invalidaciones, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un desbloqueo silencioso tambien invalida el arbol', () async {
|
||||||
|
registrarVerificacionLicenciaAuto(() async => CambioLicencia.desbloqueada);
|
||||||
|
|
||||||
|
await dispararVerificacionLicenciaAuto();
|
||||||
|
|
||||||
|
expect(invalidaciones, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sin cambios no invalida nada', () async {
|
||||||
|
registrarVerificacionLicenciaAuto(() async => CambioLicencia.sinCambios);
|
||||||
|
|
||||||
|
await dispararVerificacionLicenciaAuto();
|
||||||
|
|
||||||
|
expect(invalidaciones, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un fallo del verificador se traga en silencio', () async {
|
||||||
|
registrarVerificacionLicenciaAuto(() async => throw StateError('boom'));
|
||||||
|
|
||||||
|
await expectLater(dispararVerificacionLicenciaAuto(), completes);
|
||||||
|
expect(invalidaciones, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'no retiene al llamador: devuelve antes de que la consulta acabe',
|
||||||
|
() async {
|
||||||
|
final pendiente = Completer<CambioLicencia>();
|
||||||
|
registrarVerificacionLicenciaAuto(() => pendiente.future);
|
||||||
|
|
||||||
|
// The browse path calls this unawaited; the returned future being
|
||||||
|
// pending here proves the verification runs in the background.
|
||||||
|
var terminado = false;
|
||||||
|
unawaited(
|
||||||
|
dispararVerificacionLicenciaAuto().then((_) => terminado = true),
|
||||||
|
);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(terminado, isFalse);
|
||||||
|
|
||||||
|
pendiente.complete(CambioLicencia.revocada);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(terminado, isTrue);
|
||||||
|
expect(invalidaciones, 1);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:pluriwave/servicios/verificacion_licencia.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// Silent license re-verification policy (refund revocation): pure,
|
||||||
|
/// headless-safe, driven by an injectable clock and a fake ownership query —
|
||||||
|
/// zero plugin channels.
|
||||||
|
|
||||||
|
/// Scripted ownership query: returns [resultados] in order and counts calls.
|
||||||
|
class _ConsultaFalsa {
|
||||||
|
_ConsultaFalsa(this.resultados);
|
||||||
|
|
||||||
|
final List<ResultadoVerificacionLicencia> resultados;
|
||||||
|
int llamadas = 0;
|
||||||
|
|
||||||
|
Future<ResultadoVerificacionLicencia> call() async {
|
||||||
|
final resultado = resultados[llamadas.clamp(0, resultados.length - 1)];
|
||||||
|
llamadas++;
|
||||||
|
return resultado;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mutable clock so each test can move time forward between checks.
|
||||||
|
class _Reloj {
|
||||||
|
_Reloj(this.ahora);
|
||||||
|
|
||||||
|
DateTime ahora;
|
||||||
|
|
||||||
|
DateTime call() => ahora;
|
||||||
|
|
||||||
|
void avanzar(Duration d) => ahora = ahora.add(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late _Reloj reloj;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
reloj = _Reloj(DateTime(2026, 9, 18, 10));
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<SharedPreferences> prefsCon({required bool premium}) async {
|
||||||
|
SharedPreferences.setMockInitialValues({claveCompraPremium: premium});
|
||||||
|
return SharedPreferences.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<CambioLicencia> verificar(
|
||||||
|
SharedPreferences prefs,
|
||||||
|
Future<ResultadoVerificacionLicencia> Function() consultar,
|
||||||
|
) => verificarLicencia(consultar: consultar, prefs: prefs, reloj: reloj.call);
|
||||||
|
|
||||||
|
group('verificarLicencia', () {
|
||||||
|
test('desconocido (offline / sin billing) conserva premium y no toca el '
|
||||||
|
'contador de ausencias', () async {
|
||||||
|
final prefs = await prefsCon(premium: true);
|
||||||
|
final consulta = _ConsultaFalsa([
|
||||||
|
ResultadoVerificacionLicencia.noPoseida,
|
||||||
|
]);
|
||||||
|
await verificar(prefs, consulta.call);
|
||||||
|
expect(prefs.getInt(claveAusenciasLicencia), 1);
|
||||||
|
|
||||||
|
reloj.avanzar(const Duration(days: 2));
|
||||||
|
final cambio = await verificar(
|
||||||
|
prefs,
|
||||||
|
() async => ResultadoVerificacionLicencia.desconocido,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cambio, CambioLicencia.sinCambios);
|
||||||
|
expect(prefs.getBool(claveCompraPremium), isTrue);
|
||||||
|
expect(prefs.getInt(claveAusenciasLicencia), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('una excepcion en la consulta no cambia nada (fail-open)', () async {
|
||||||
|
final prefs = await prefsCon(premium: true);
|
||||||
|
|
||||||
|
final cambio = await verificar(
|
||||||
|
prefs,
|
||||||
|
() async => throw Exception('BillingClient desconectado'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cambio, CambioLicencia.sinCambios);
|
||||||
|
expect(prefs.getBool(claveCompraPremium), isTrue);
|
||||||
|
expect(prefs.getInt(claveAusenciasLicencia), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('poseida conserva premium y reinicia el contador', () async {
|
||||||
|
final prefs = await prefsCon(premium: true);
|
||||||
|
await verificar(
|
||||||
|
prefs,
|
||||||
|
() async => ResultadoVerificacionLicencia.noPoseida,
|
||||||
|
);
|
||||||
|
expect(prefs.getInt(claveAusenciasLicencia), 1);
|
||||||
|
|
||||||
|
reloj.avanzar(const Duration(days: 1));
|
||||||
|
final cambio = await verificar(
|
||||||
|
prefs,
|
||||||
|
() async => ResultadoVerificacionLicencia.poseida,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cambio, CambioLicencia.sinCambios);
|
||||||
|
expect(prefs.getBool(claveCompraPremium), isTrue);
|
||||||
|
expect(prefs.getInt(claveAusenciasLicencia), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('poseida con la flag en false desbloquea en silencio '
|
||||||
|
'(reinstalacion)', () async {
|
||||||
|
final prefs = await prefsCon(premium: false);
|
||||||
|
|
||||||
|
final cambio = await verificar(
|
||||||
|
prefs,
|
||||||
|
() async => ResultadoVerificacionLicencia.poseida,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cambio, CambioLicencia.desbloqueada);
|
||||||
|
expect(prefs.getBool(claveCompraPremium), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('una sola ausencia NO revoca', () async {
|
||||||
|
final prefs = await prefsCon(premium: true);
|
||||||
|
|
||||||
|
final cambio = await verificar(
|
||||||
|
prefs,
|
||||||
|
() async => ResultadoVerificacionLicencia.noPoseida,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cambio, CambioLicencia.sinCambios);
|
||||||
|
expect(prefs.getBool(claveCompraPremium), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dos ausencias separadas por el umbral revocan', () async {
|
||||||
|
final prefs = await prefsCon(premium: true);
|
||||||
|
await verificar(
|
||||||
|
prefs,
|
||||||
|
() async => ResultadoVerificacionLicencia.noPoseida,
|
||||||
|
);
|
||||||
|
|
||||||
|
reloj.avanzar(intervaloVerificacionLicencia);
|
||||||
|
final cambio = await verificar(
|
||||||
|
prefs,
|
||||||
|
() async => ResultadoVerificacionLicencia.noPoseida,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cambio, CambioLicencia.revocada);
|
||||||
|
expect(prefs.getBool(claveCompraPremium), isFalse);
|
||||||
|
expect(prefs.getInt(claveAusenciasLicencia), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dos ausencias demasiado juntas NO revocan', () async {
|
||||||
|
final prefs = await prefsCon(premium: true);
|
||||||
|
await verificar(
|
||||||
|
prefs,
|
||||||
|
() async => ResultadoVerificacionLicencia.noPoseida,
|
||||||
|
);
|
||||||
|
// Simulates the throttle having been bypassed (e.g. prefs cleared by
|
||||||
|
// a second engine): only the spacing guard stands between a transient
|
||||||
|
// empty Play cache and a false revocation.
|
||||||
|
await prefs.remove(claveUltimaVerificacionLicencia);
|
||||||
|
await prefs.remove(claveUltimoIntentoLicencia);
|
||||||
|
|
||||||
|
reloj.avanzar(separacionMinimaAusencias - const Duration(minutes: 1));
|
||||||
|
final cambio = await verificar(
|
||||||
|
prefs,
|
||||||
|
() async => ResultadoVerificacionLicencia.noPoseida,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cambio, CambioLicencia.sinCambios);
|
||||||
|
expect(prefs.getBool(claveCompraPremium), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ausencia seguida de poseida reinicia: la siguiente ausencia '
|
||||||
|
'vuelve a contar desde cero', () async {
|
||||||
|
final prefs = await prefsCon(premium: true);
|
||||||
|
final consulta = _ConsultaFalsa([
|
||||||
|
ResultadoVerificacionLicencia.noPoseida,
|
||||||
|
ResultadoVerificacionLicencia.poseida,
|
||||||
|
ResultadoVerificacionLicencia.noPoseida,
|
||||||
|
]);
|
||||||
|
|
||||||
|
await verificar(prefs, consulta.call);
|
||||||
|
reloj.avanzar(const Duration(days: 1));
|
||||||
|
await verificar(prefs, consulta.call);
|
||||||
|
reloj.avanzar(const Duration(days: 1));
|
||||||
|
final cambio = await verificar(prefs, consulta.call);
|
||||||
|
|
||||||
|
expect(consulta.llamadas, 3);
|
||||||
|
expect(cambio, CambioLicencia.sinCambios);
|
||||||
|
expect(prefs.getBool(claveCompraPremium), isTrue);
|
||||||
|
expect(prefs.getInt(claveAusenciasLicencia), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('noPoseida con la flag ya en false no hace nada', () async {
|
||||||
|
final prefs = await prefsCon(premium: false);
|
||||||
|
|
||||||
|
final cambio = await verificar(
|
||||||
|
prefs,
|
||||||
|
() async => ResultadoVerificacionLicencia.noPoseida,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cambio, CambioLicencia.sinCambios);
|
||||||
|
expect(prefs.getBool(claveCompraPremium), isFalse);
|
||||||
|
expect(prefs.getInt(claveAusenciasLicencia), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throttle: no vuelve a consultar dentro de las 24h tras una '
|
||||||
|
'verificacion con respuesta', () async {
|
||||||
|
final prefs = await prefsCon(premium: true);
|
||||||
|
final consulta = _ConsultaFalsa([ResultadoVerificacionLicencia.poseida]);
|
||||||
|
|
||||||
|
await verificar(prefs, consulta.call);
|
||||||
|
reloj.avanzar(intervaloVerificacionLicencia - const Duration(minutes: 1));
|
||||||
|
await verificar(prefs, consulta.call);
|
||||||
|
expect(consulta.llamadas, 1);
|
||||||
|
|
||||||
|
reloj.avanzar(const Duration(minutes: 1));
|
||||||
|
await verificar(prefs, consulta.call);
|
||||||
|
expect(consulta.llamadas, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throttle: tras un desconocido reintenta pasada la ventana corta, '
|
||||||
|
'no antes', () async {
|
||||||
|
final prefs = await prefsCon(premium: true);
|
||||||
|
final consulta = _ConsultaFalsa([
|
||||||
|
ResultadoVerificacionLicencia.desconocido,
|
||||||
|
]);
|
||||||
|
|
||||||
|
await verificar(prefs, consulta.call);
|
||||||
|
reloj.avanzar(intervaloReintentoLicencia - const Duration(minutes: 1));
|
||||||
|
await verificar(prefs, consulta.call);
|
||||||
|
expect(consulta.llamadas, 1);
|
||||||
|
|
||||||
|
reloj.avanzar(const Duration(minutes: 1));
|
||||||
|
await verificar(prefs, consulta.call);
|
||||||
|
expect(consulta.llamadas, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'un reloj que retrocede no bloquea la verificacion para siempre',
|
||||||
|
() async {
|
||||||
|
final prefs = await prefsCon(premium: true);
|
||||||
|
final consulta = _ConsultaFalsa([
|
||||||
|
ResultadoVerificacionLicencia.poseida,
|
||||||
|
]);
|
||||||
|
|
||||||
|
await verificar(prefs, consulta.call);
|
||||||
|
reloj.avanzar(const Duration(days: -3));
|
||||||
|
await verificar(prefs, consulta.call);
|
||||||
|
|
||||||
|
expect(consulta.llamadas, 2);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('solo UNA verificacion en vuelo: llamadas concurrentes comparten la '
|
||||||
|
'misma consulta', () async {
|
||||||
|
final prefs = await prefsCon(premium: true);
|
||||||
|
final respuesta = Completer<ResultadoVerificacionLicencia>();
|
||||||
|
var llamadas = 0;
|
||||||
|
Future<ResultadoVerificacionLicencia> consultar() {
|
||||||
|
llamadas++;
|
||||||
|
return respuesta.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
final a = verificar(prefs, consultar);
|
||||||
|
final b = verificar(prefs, consultar);
|
||||||
|
respuesta.complete(ResultadoVerificacionLicencia.poseida);
|
||||||
|
await Future.wait([a, b]);
|
||||||
|
|
||||||
|
expect(llamadas, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -24,6 +24,10 @@ class _PuertoComprasFalso implements PuertoCompras {
|
|||||||
@override
|
@override
|
||||||
Future<void> restaurar() async {}
|
Future<void> restaurar() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ResultadoVerificacionLicencia> consultarPropiedad() async =>
|
||||||
|
ResultadoVerificacionLicencia.desconocido;
|
||||||
|
|
||||||
void emitir(EventoCompra evento) => _eventos.add(evento);
|
void emitir(EventoCompra evento) => _eventos.add(evento);
|
||||||
|
|
||||||
Future<void> dispose() => _eventos.close();
|
Future<void> dispose() => _eventos.close();
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ class _PuertoComprasFalso implements PuertoCompras {
|
|||||||
@override
|
@override
|
||||||
Future<void> restaurar() async {}
|
Future<void> restaurar() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ResultadoVerificacionLicencia> consultarPropiedad() async =>
|
||||||
|
ResultadoVerificacionLicencia.desconocido;
|
||||||
|
|
||||||
void emitir(EventoCompra evento) => _eventos.add(evento);
|
void emitir(EventoCompra evento) => _eventos.add(evento);
|
||||||
|
|
||||||
Future<void> dispose() => _eventos.close();
|
Future<void> dispose() => _eventos.close();
|
||||||
@@ -216,6 +220,19 @@ void main() {
|
|||||||
expect(find.text(l10n.premiumBeneficioAlarmasIlimitadas), findsOneWidget);
|
expect(find.text(l10n.premiumBeneficioAlarmasIlimitadas), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'the Android Auto bullet names what PRO adds in the car, not the '
|
||||||
|
'product itself (free already gets a playable featured folder)',
|
||||||
|
(tester) async {
|
||||||
|
final compras = _PuertoComprasFalso();
|
||||||
|
addTearDown(compras.dispose);
|
||||||
|
await bombear(tester, compras: compras);
|
||||||
|
|
||||||
|
expect(l10n.premiumBeneficioAndroidAuto, isNot('Android Auto'));
|
||||||
|
expect(find.text(l10n.premiumBeneficioAndroidAuto), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
testWidgets('states this is a one-time purchase, not a subscription', (
|
testWidgets('states this is a one-time purchase, not a subscription', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
@@ -334,8 +351,7 @@ void main() {
|
|||||||
MultiProvider(
|
MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
ChangeNotifierProvider<EstadoEntitlement>(
|
ChangeNotifierProvider<EstadoEntitlement>(
|
||||||
create:
|
create: (_) => EstadoEntitlement(prefs: null, compras: servicio),
|
||||||
(_) => EstadoEntitlement(prefs: null, compras: servicio),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'package:pluriwave/estado/estado_entitlement.dart';
|
|||||||
import 'package:pluriwave/estado/estado_grabacion.dart';
|
import 'package:pluriwave/estado/estado_grabacion.dart';
|
||||||
import 'package:pluriwave/estado/estado_idioma.dart';
|
import 'package:pluriwave/estado/estado_idioma.dart';
|
||||||
import 'package:pluriwave/estado/estado_radio.dart';
|
import 'package:pluriwave/estado/estado_radio.dart';
|
||||||
|
import 'package:pluriwave/estado/estado_visualizador.dart';
|
||||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||||
import 'package:pluriwave/pantallas/pantalla_ajustes.dart';
|
import 'package:pluriwave/pantallas/pantalla_ajustes.dart';
|
||||||
import 'package:pluriwave/pantallas/pantalla_alarmas.dart';
|
import 'package:pluriwave/pantallas/pantalla_alarmas.dart';
|
||||||
@@ -222,6 +223,9 @@ void main() {
|
|||||||
ChangeNotifierProvider<EstadoEntitlement>(
|
ChangeNotifierProvider<EstadoEntitlement>(
|
||||||
create: (_) => EstadoEntitlement(prefs: null),
|
create: (_) => EstadoEntitlement(prefs: null),
|
||||||
),
|
),
|
||||||
|
ChangeNotifierProvider<EstadoVisualizador>(
|
||||||
|
create: (_) => EstadoVisualizador(prefs: null),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
child: testApp(const PantallaAjustes()),
|
child: testApp(const PantallaAjustes()),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||||
|
import 'package:pluriwave/widgets/visualizador_audio.dart';
|
||||||
|
|
||||||
|
/// Play sensitive-permission guard for the waveform visualizer.
|
||||||
|
///
|
||||||
|
/// `MainActivity.startVisualizerWhenAllowed` asks for `RECORD_AUDIO` the
|
||||||
|
/// instant Dart subscribes to the `pluriwave/audio_visualizer` EventChannel.
|
||||||
|
/// The home screen's "Escuchar" hero mounts this widget, so before this the
|
||||||
|
/// microphone dialog appeared on the user's FIRST play, with no context at
|
||||||
|
/// all, in a radio app — the worst possible moment to ask.
|
||||||
|
///
|
||||||
|
/// The subscription (and therefore the permission request) is now gated on
|
||||||
|
/// an explicit opt-in the user gives in Settings, after reading what the
|
||||||
|
/// permission is for. These tests pin the Dart half of that: no opt-in, no
|
||||||
|
/// channel subscription.
|
||||||
|
void main() {
|
||||||
|
const canal = MethodChannel('pluriwave/audio_visualizer');
|
||||||
|
|
||||||
|
late List<MethodCall> llamadas;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
llamadas = <MethodCall>[];
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(canal, (call) async {
|
||||||
|
llamadas.add(call);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(canal, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> montar(
|
||||||
|
WidgetTester tester, {
|
||||||
|
required Stream<EstadoReproduccion> estadoStream,
|
||||||
|
required Stream<int?> sessionStream,
|
||||||
|
required bool capturaRealHabilitada,
|
||||||
|
}) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: VisualizadorAudio(
|
||||||
|
estadoStream: estadoStream,
|
||||||
|
androidAudioSessionIdStream: sessionStream,
|
||||||
|
barras: 12,
|
||||||
|
capturaRealHabilitada: capturaRealHabilitada,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'sin el opt-in, reproducir NO suscribe el EventChannel (y por tanto no '
|
||||||
|
'dispara la peticion de RECORD_AUDIO)',
|
||||||
|
(tester) async {
|
||||||
|
final estado = StreamController<EstadoReproduccion>.broadcast();
|
||||||
|
final sesion = StreamController<int?>.broadcast();
|
||||||
|
addTearDown(estado.close);
|
||||||
|
addTearDown(sesion.close);
|
||||||
|
|
||||||
|
await montar(
|
||||||
|
tester,
|
||||||
|
estadoStream: estado.stream,
|
||||||
|
sessionStream: sesion.stream,
|
||||||
|
capturaRealHabilitada: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
sesion.add(42);
|
||||||
|
estado.add(EstadoReproduccion.reproduciendo);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
llamadas.where((c) => c.method == 'listen'),
|
||||||
|
isEmpty,
|
||||||
|
reason:
|
||||||
|
'subscribing is what makes MainActivity call requestPermissions '
|
||||||
|
'for RECORD_AUDIO; with no opt-in it must never happen',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'con el opt-in, reproducir SI suscribe el EventChannel (la onda real '
|
||||||
|
'sigue disponible para quien la pide)',
|
||||||
|
(tester) async {
|
||||||
|
final estado = StreamController<EstadoReproduccion>.broadcast();
|
||||||
|
final sesion = StreamController<int?>.broadcast();
|
||||||
|
addTearDown(estado.close);
|
||||||
|
addTearDown(sesion.close);
|
||||||
|
|
||||||
|
await montar(
|
||||||
|
tester,
|
||||||
|
estadoStream: estado.stream,
|
||||||
|
sessionStream: sesion.stream,
|
||||||
|
capturaRealHabilitada: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
sesion.add(42);
|
||||||
|
estado.add(EstadoReproduccion.reproduciendo);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
|
expect(llamadas.where((c) => c.method == 'listen'), hasLength(1));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('el visualizador sigue dibujando sus barras sin el opt-in', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final estado = StreamController<EstadoReproduccion>.broadcast();
|
||||||
|
final sesion = StreamController<int?>.broadcast();
|
||||||
|
addTearDown(estado.close);
|
||||||
|
addTearDown(sesion.close);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: VisualizadorAudio(
|
||||||
|
estadoStream: estado.stream,
|
||||||
|
androidAudioSessionIdStream: sesion.stream,
|
||||||
|
barras: 12,
|
||||||
|
barrasDiscretas: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
for (var i = 0; i < 12; i++) {
|
||||||
|
expect(find.byKey(Key('visualizador-barra-$i')), findsOneWidget);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user