fix: alinear permisos, paywall y grabacion con lo que la app hace de verdad
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 3m13s

Revision previa al envio a produccion. Cada punto se verifico en el codigo
antes de tocarlo; lo que ya estaba bien se dejo como estaba.

Ubicacion: se declaraba precision fina sin usarla

El unico consumidor de ubicacion pide `LocationAccuracy.low` y se queda solo
con el codigo ISO del pais, asi que `ACCESS_FINE_LOCATION` no aportaba nada. Y
contradecia la declaracion de Seguridad de los datos ya aprobada en Play, que
dice ubicacion APROXIMADA: declarar una cosa y pedir otra es precisamente lo
que se penaliza en revision.

Verificado que los manifiestos de geolocator_android y geocoding_android no
declaran permisos propios, asi que el merge no lo reinyecta y no hace falta
`tools:node="remove"`. El plugin construye su peticion en tiempo de ejecucion a
partir de lo declarado, de modo que con COARSE pide COARSE. Sin cambio
funcional: la deteccion de pais sigue igual.

El paywall vendia Android Auto como exclusivo, y ya no lo es

La etiqueta era literalmente "Android Auto", a secas. Pero el tier gratuito
recibio una carpeta navegable con emisoras reproducibles cuando hubo que
cumplir las guias del coche, asi que esa frase dejo de ser cierta. Ahora dice
que PRO añade el catalogo completo, favoritos, mis emisoras y musica local, y
aclara que gratis tiene las destacadas. Un paywall que promete lo que el tier
gratuito ya tiene expone a reclamacion y a que se cite en revision.

Microfono: se pide al activar el visualizador, no antes

Con una explicacion previa en los 13 idiomas, en vez de aparecer sin contexto.

Grabacion: uso privado de verdad, no solo en el aviso

La pantalla de grabaciones entregaba el fichero a cualquier aplicacion con
`Share.shareXFiles`. La intencion era abrirlo en un reproductor del propio
telefono, no redistribuirlo, y una cosa es copia privada y la otra no. Ahora
usa el `openFile` que ya existia -- FileProvider + ACTION_VIEW -- y avisa
cuando ningun reproductor del dispositivo puede abrirla, en vez de fallar en
silencio. Se añade ademas el aviso de uso privado en esa pantalla.

`recordingActionShare` la usaban DOS botones con significados distintos: el de
grabaciones, que mandaba el audio, y el del reproductor, que comparte el nombre
y la url de la emisora. Una clave, dos sentidos, y esa ambiguedad basto para
que al leer el codigo pareciera que solo se compartian enlaces. Separadas en
`stationActionShare` y `recordingActionOpenIn`.

La grabacion sigue siendo PRO. Lo que reduce el riesgo es que la copia no salga
del dispositivo, no regalar la funcion: los anuncios tambien son monetizacion.

Suite completa: 1587 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos
preexistentes.
This commit is contained in:
2026-09-18 17:06:59 +02:00
parent 69eea0f2a1
commit acf2ebb55f
52 changed files with 1631 additions and 265 deletions
+8 -1
View File
@@ -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
+7
View File
@@ -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()),
+27 -10
View File
@@ -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
+53
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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": "开启"
} }
+46 -4
View File
@@ -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
+28 -2
View File
@@ -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 => 'تفعيل';
} }
+28 -2
View File
@@ -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 => 'চালু করুন';
} }
+28 -2
View File
@@ -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';
} }
+28 -2
View File
@@ -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';
} }
+28 -2
View File
@@ -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';
} }
+28 -2
View File
@@ -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';
} }
+28 -2
View File
@@ -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 => 'चालू करें';
} }
+28 -2
View File
@@ -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';
} }
+28 -2
View File
@@ -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';
} }
+26 -2
View File
@@ -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 => 'オンにする';
} }
+28 -2
View File
@@ -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';
} }
+28 -2
View File
@@ -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 => 'Включить';
} }
+25 -2
View File
@@ -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 => '开启';
} }
@@ -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);
}
}
+21
View File
@@ -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,
+53 -13
View File
@@ -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',
+9
View File
@@ -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,
), ),
], ],
), ),
+10 -1
View File
@@ -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}'),
), ),
), ),
+148 -134
View File
@@ -40,150 +40,164 @@ class HojaPremium extends StatelessWidget {
), ),
child: PluriGlassSurface( child: PluriGlassSurface(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Column( // The sheet's height is data-dependent: five benefit bullets whose
mainAxisSize: MainAxisSize.min, // wrapped line count varies per locale, plus an optional
crossAxisAlignment: CrossAxisAlignment.stretch, // purchase/restore result banner. Together they already overflow a
children: [ // short viewport by a couple of pixels, and a translation one word
Row( // longer would make it worse. Scrolling is the only shape that
children: [ // cannot overflow, and it costs nothing when everything fits.
Icon( child: SingleChildScrollView(
Icons.workspace_premium_rounded, child: Column(
color: PluriWaveTokens.brand, mainAxisSize: MainAxisSize.min,
), crossAxisAlignment: CrossAxisAlignment.stretch,
const SizedBox(width: 10), children: [
Expanded( Row(
child: Text( children: [
l10n.premiumHojaTitulo, Icon(
style: Theme.of(context).textTheme.titleLarge?.copyWith( Icons.workspace_premium_rounded,
fontWeight: FontWeight.w900, color: PluriWaveTokens.brand,
),
), ),
), const SizedBox(width: 10),
// Explicit, obvious dismiss affordance (fix/import-alarmas-y- Expanded(
// paywall): a purchase sheet the user cannot easily escape is child: Text(
// a dark pattern and a Play policy risk. Reachable without l10n.premiumHojaTitulo,
// buying or restoring, same weight as any other icon button. style: Theme.of(context).textTheme.titleLarge?.copyWith(
IconButton( fontWeight: FontWeight.w900,
key: const ValueKey('hoja-premium-cerrar'),
icon: const Icon(Icons.close_rounded),
tooltip: l10n.closeAction,
onPressed: () => Navigator.of(context).maybePop(),
),
],
),
const SizedBox(height: 12),
// Concrete, honest value list — accuracy is non-negotiable here:
// these five are the ONLY things premium unlocks. The phone
// equalizer stays free for everyone and must NEVER appear here;
// only its Android Auto surface is affected, as a consequence of
// Auto itself being gated.
_BeneficioPremium(texto: l10n.premiumBeneficioSinAnuncios),
_BeneficioPremium(texto: l10n.premiumBeneficioAndroidAuto),
_BeneficioPremium(texto: l10n.premiumBeneficioGrabacion),
_BeneficioPremium(texto: l10n.premiumBeneficioVacaciones),
_BeneficioPremium(texto: l10n.premiumBeneficioAlarmasIlimitadas),
const SizedBox(height: 12),
Text(
l10n.premiumPagoUnico,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 20),
// FIX 3 (code review): user-facing feedback for a failed
// purchase/restore, or a restore that found nothing — before
// this, `resultadoUsuario` had ZERO UI, so the spinner just
// stopped with no feedback at all. Never the raw
// `EventoCompra.mensaje` developer string — always the mapped,
// generic localized message.
if (entitlement.resultadoUsuario != null)
Padding(
key: const ValueKey('hoja-premium-resultado'),
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
entitlement.resultadoUsuario ==
ResultadoEntitlementUsuario.error
? Icons.error_outline_rounded
: Icons.info_outline_rounded,
size: 18,
color:
entitlement.resultadoUsuario ==
ResultadoEntitlementUsuario.error
? Theme.of(context).colorScheme.error
: Theme.of(context).textTheme.bodyMedium?.color,
),
const SizedBox(width: 8),
Expanded(
child: Text(
entitlement.resultadoUsuario ==
ResultadoEntitlementUsuario.error
? l10n.compraError
: l10n.restauracionSinCompras,
style: Theme.of(context).textTheme.bodyMedium,
), ),
), ),
IconButton( ),
key: const ValueKey('hoja-premium-resultado-descartar'), // Explicit, obvious dismiss affordance (fix/import-alarmas-y-
icon: const Icon(Icons.close_rounded, size: 18), // paywall): a purchase sheet the user cannot easily escape is
onPressed: () => entitlement.consumirResultadoUsuario(), // a dark pattern and a Play policy risk. Reachable without
padding: EdgeInsets.zero, // buying or restoring, same weight as any other icon button.
constraints: const BoxConstraints(), IconButton(
visualDensity: VisualDensity.compact, key: const ValueKey('hoja-premium-cerrar'),
), icon: const Icon(Icons.close_rounded),
], tooltip: l10n.closeAction,
), onPressed: () => Navigator.of(context).maybePop(),
),
],
), ),
if (entitlement.esPremium) const SizedBox(height: 12),
Padding( // Concrete, honest value list — accuracy is non-negotiable here:
key: const ValueKey('hoja-premium-activo'), // these five are the ONLY things premium unlocks. The phone
padding: const EdgeInsets.only(bottom: 12), // equalizer stays free for everyone and must NEVER appear here;
child: Text( // only its Android Auto surface is affected, as a consequence of
l10n.premiumActivo, // Auto itself being gated.
style: Theme.of(context).textTheme.bodyMedium, //
// 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.premiumBeneficioAndroidAuto),
_BeneficioPremium(texto: l10n.premiumBeneficioGrabacion),
_BeneficioPremium(texto: l10n.premiumBeneficioVacaciones),
_BeneficioPremium(texto: l10n.premiumBeneficioAlarmasIlimitadas),
const SizedBox(height: 12),
Text(
l10n.premiumPagoUnico,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 20),
// FIX 3 (code review): user-facing feedback for a failed
// purchase/restore, or a restore that found nothing — before
// this, `resultadoUsuario` had ZERO UI, so the spinner just
// stopped with no feedback at all. Never the raw
// `EventoCompra.mensaje` developer string — always the mapped,
// generic localized message.
if (entitlement.resultadoUsuario != null)
Padding(
key: const ValueKey('hoja-premium-resultado'),
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
entitlement.resultadoUsuario ==
ResultadoEntitlementUsuario.error
? Icons.error_outline_rounded
: Icons.info_outline_rounded,
size: 18,
color:
entitlement.resultadoUsuario ==
ResultadoEntitlementUsuario.error
? Theme.of(context).colorScheme.error
: Theme.of(context).textTheme.bodyMedium?.color,
),
const SizedBox(width: 8),
Expanded(
child: Text(
entitlement.resultadoUsuario ==
ResultadoEntitlementUsuario.error
? l10n.compraError
: l10n.restauracionSinCompras,
style: Theme.of(context).textTheme.bodyMedium,
),
),
IconButton(
key: const ValueKey('hoja-premium-resultado-descartar'),
icon: const Icon(Icons.close_rounded, size: 18),
onPressed: () => entitlement.consumirResultadoUsuario(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
visualDensity: VisualDensity.compact,
),
],
),
), ),
) if (entitlement.esPremium)
else Padding(
FilledButton.icon( key: const ValueKey('hoja-premium-activo'),
key: const ValueKey('hoja-premium-comprar'), padding: const EdgeInsets.only(bottom: 12),
child: Text(
l10n.premiumActivo,
style: Theme.of(context).textTheme.bodyMedium,
),
)
else
FilledButton.icon(
key: const ValueKey('hoja-premium-comprar'),
onPressed:
entitlement.compraEnCurso
? null
: () => entitlement.comprar(),
icon:
entitlement.compraEnCurso
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.lock_open_rounded),
label: Text(l10n.desbloquearPremium),
),
const SizedBox(height: 10),
OutlinedButton(
key: const ValueKey('hoja-premium-restaurar'),
onPressed: onPressed:
entitlement.compraEnCurso entitlement.compraEnCurso
? null ? null
: () => entitlement.comprar(), : () => entitlement.restaurar(),
icon: child: Text(l10n.restaurarCompras),
entitlement.compraEnCurso
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.lock_open_rounded),
label: Text(l10n.desbloquearPremium),
),
const SizedBox(height: 10),
OutlinedButton(
key: const ValueKey('hoja-premium-restaurar'),
onPressed:
entitlement.compraEnCurso
? null
: () => entitlement.restaurar(),
child: Text(l10n.restaurarCompras),
),
if (!entitlement.esPremium) ...[
const SizedBox(height: 4),
// Clearly-labelled, always-reachable decline — same weight as
// any other secondary action, never made harder to find than
// buying (hard constraint: no dark patterns, no guilt-shaming
// decline copy).
TextButton(
key: const ValueKey('hoja-premium-ahora-no'),
onPressed: () => Navigator.of(context).maybePop(),
child: Text(l10n.premiumAhoraNo),
), ),
if (!entitlement.esPremium) ...[
const SizedBox(height: 4),
// Clearly-labelled, always-reachable decline — same weight as
// any other secondary action, never made harder to find than
// buying (hard constraint: no dark patterns, no guilt-shaming
// decline copy).
TextButton(
key: const ValueKey('hoja-premium-ahora-no'),
onPressed: () => Navigator.of(context).maybePop(),
child: Text(l10n.premiumAhoraNo),
),
],
], ],
], ),
), ),
), ),
); );
+35 -1
View File
@@ -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());
@@ -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);
});
});
}
+3 -14
View File
@@ -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.
+54
View File
@@ -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'),
+9 -1
View File
@@ -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
+139 -21
View File
@@ -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 ──────────────────────────────────────────────────────────
+4
View File
@@ -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),
), ),
+13
View File
@@ -220,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 {
@@ -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);
}
});
}