From 186ff451058f1efdbc7e0d95f56fb8ba6253636a Mon Sep 17 00:00:00 2001 From: ShanaiaBot Date: Fri, 7 Aug 2026 17:18:26 +0200 Subject: [PATCH 1/7] chore: bump version to 1.2.29+151 [ci skip] --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 0be8af5..9de1aad 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: pluriwave description: "Radio mundial con ecualizador, reconocimiento de canciones y UI premium" publish_to: 'none' -version: 1.2.28+150 +version: 1.2.29+151 environment: sdk: ^3.7.0 From b5940b275824c2eb979508955c54ac67e76155af Mon Sep 17 00:00:00 2001 From: ShanaiaBot Date: Fri, 28 Aug 2026 20:02:32 +0200 Subject: [PATCH 2/7] chore: bump version to 1.3.1+154 [ci skip] --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index cea15f1..cbd5fae 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: pluriwave description: "Radio mundial con ecualizador, reconocimiento de canciones y UI premium" publish_to: 'none' -version: 1.3.0+153 +version: 1.3.1+154 environment: sdk: ^3.7.0 From 4ea5d2056cea8edcb789ef2c7e3f16079713e464 Mon Sep 17 00:00:00 2001 From: freetlab Date: Fri, 28 Aug 2026 20:06:10 +0200 Subject: [PATCH 3/7] test(alarmas): anchor the vacation pill test on a relative future range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test hardcoded 4-18 August 2026, which was in the future when it was written and is now in the past. The pill only renders for the active or next range, so the assertion started failing purely because the calendar moved on — the production code was never wrong. Anchor the range on next month (days 4-18, so it never straddles a month boundary) and assert against rangoFechasCorto, the same pure formatter the widget uses, so the test checks that the pill is rendered rather than restating the formatter's own output. --- .../pantalla_alarmas_tier5_test.dart | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/test/pantallas/pantalla_alarmas_tier5_test.dart b/test/pantallas/pantalla_alarmas_tier5_test.dart index 176e83e..f05b34c 100644 --- a/test/pantallas/pantalla_alarmas_tier5_test.dart +++ b/test/pantallas/pantalla_alarmas_tier5_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/estado/estado_alarmas.dart'; import 'package:pluriwave/estado/estado_radio.dart'; +import 'package:pluriwave/l10n/formato_fechas.dart'; import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/modelos/alarma_musical.dart'; import 'package:pluriwave/modelos/emisora.dart'; @@ -137,16 +138,29 @@ void main() { ) async { final estado = crearEstado(); addTearDown(estado.dispose); + // The pill only renders for the ACTIVE or NEXT range + // (`EstadoAlarmas.vacacionesProximas` keeps `inicioDia.isAfter(hoy)`), so + // the range has to be in the future RELATIVE TO THE RUN. Hardcoded + // calendar dates silently rot into the past and turn this into a + // date-dependent failure; anchor on next month instead, days 4-18 so the + // range never straddles a month boundary and the label stays "d–d MON". + final ahora = DateTime.now(); + final mesSiguiente = DateTime(ahora.year, ahora.month + 1); + final inicio = DateTime(mesSiguiente.year, mesSiguiente.month, 4); + final fin = DateTime(mesSiguiente.year, mesSiguiente.month, 18); await estado.crearRangoVacaciones( estado.servicio.crearRangoVacaciones( - inicio: DateTime(2026, 8, 4), - fin: DateTime(2026, 8, 18), + inicio: inicio, + fin: fin, nombre: 'Summer', ), ); await montar(tester, estadoAlarmas: estado); - expect(find.text('4–18 AUG'), findsOneWidget); + // Assert against the same pure formatter the widget uses, so this stays + // a check that the pill IS rendered in the right shape rather than a + // duplicate of the formatter's own logic. + expect(find.text(rangoFechasCorto('en', inicio, fin)), findsOneWidget); }); }); From fdddd95199fd7a4d96fceb1c99b4838cf6b273cb Mon Sep 17 00:00:00 2001 From: ShanaiaBot Date: Fri, 28 Aug 2026 20:07:12 +0200 Subject: [PATCH 4/7] chore: bump version to 1.3.2+155 [ci skip] --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index cbd5fae..0315c9b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: pluriwave description: "Radio mundial con ecualizador, reconocimiento de canciones y UI premium" publish_to: 'none' -version: 1.3.1+154 +version: 1.3.2+155 environment: sdk: ^3.7.0 From e57f7bb17b59e5f4f8c06d97a595714d365ab2c8 Mon Sep 17 00:00:00 2001 From: freetlab Date: Fri, 28 Aug 2026 22:47:38 +0200 Subject: [PATCH 5/7] fix(alarmas): reload and re-sync alarms after backup import Importing a backup wrote the alarm/vacation/exception block straight to SharedPreferences but never told EstadoAlarmas about it, so the UI kept showing the pre-import alarms, a later edit could persist that stale state back over the imported one, and imported alarms were never (re)scheduled with the Android native layer. The backup screen now calls EstadoAlarmas.cargarPersistidasSinRecalcular() followed by refrescarProgramacion() after a successful import, extracted into a directly-testable aplicarImportacionConfig() function. --- lib/estado/estado_radio.dart | 7 +- .../ajustes/pantalla_ajustes_backup.dart | 36 ++- test/estado/estado_alarmas_import_test.dart | 284 ++++++++++++++++++ 3 files changed, 325 insertions(+), 2 deletions(-) create mode 100644 test/estado/estado_alarmas_import_test.dart diff --git a/lib/estado/estado_radio.dart b/lib/estado/estado_radio.dart index 9272961..10e9d8f 100644 --- a/lib/estado/estado_radio.dart +++ b/lib/estado/estado_radio.dart @@ -945,7 +945,12 @@ class EstadoRadio extends ChangeNotifier { final alarmasData = data['alarmas']; if (alarmasData is Map) { // Escribimos el bloque JSON tal como estaba en el dispositivo origen. - // ServicioAlarmas lo leerá con su propio fromJson al siguiente acceso. + // EstadoAlarmas es un ChangeNotifier independiente y de larga vida + // que ya cargó sus alarmas en memoria: NO relee este storage por sí + // solo. El llamador (pantalla_ajustes_backup.dart) es responsable de + // invocar `EstadoAlarmas.cargarPersistidasSinRecalcular()` seguido + // de `refrescarProgramacion()` tras un import exitoso; EstadoRadio + // se mantiene deliberadamente sin depender de EstadoAlarmas. await prefs.setString(_keyAlarmasConfig, jsonEncode(alarmasData)); } } diff --git a/lib/pantallas/ajustes/pantalla_ajustes_backup.dart b/lib/pantallas/ajustes/pantalla_ajustes_backup.dart index c78be90..60cbd10 100644 --- a/lib/pantallas/ajustes/pantalla_ajustes_backup.dart +++ b/lib/pantallas/ajustes/pantalla_ajustes_backup.dart @@ -6,12 +6,45 @@ import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; import 'package:share_plus/share_plus.dart' show Share, XFile; +import '../../estado/estado_alarmas.dart'; import '../../estado/estado_radio.dart'; import '../../l10n/gen/app_localizations.dart'; import '../../widgets/pluri_glass_surface.dart'; import '../../widgets/pluri_layout.dart'; import '../../widgets/pluri_push_scaffold.dart'; +/// Applies a successfully-parsed backup to BOTH independent notifiers that +/// own pieces of it (fix/import-alarmas-y-paywall). +/// +/// `EstadoRadio.importarConfig` writes the raw alarm/vacation/exception JSON +/// block straight to SharedPreferences, but `EstadoAlarmas` is a separate +/// long-lived `ChangeNotifier` that loaded its alarms into memory at +/// construction and never re-reads storage on its own — `EstadoRadio` stays +/// deliberately free of a dependency on it. Without the two calls below the +/// imported block is invisible to the running app: the UI keeps showing the +/// pre-import alarms, a later edit would persist that stale in-memory list +/// OVER the imported one, and the imported alarms would never be +/// (re)scheduled with the Android native layer even after a restart. +/// +/// Extracted as a top-level function (rather than inlined in `_importar`) +/// so this exact production sequence — not a reimplementation of it — is +/// directly unit-testable without depending on the `file_picker` platform +/// channel or the confirmation dialog. +Future aplicarImportacionConfig( + EstadoRadio estado, + EstadoAlarmas alarmas, + Map json, +) async { + await estado.importarConfig(json); + // Re-reads from storage — clears ServicioAlarmas' in-memory cache so the + // just-imported alarms/vacations/exceptions (same JSON block, same + // notifier) replace the stale ones. + await alarmas.cargarPersistidasSinRecalcular(); + // Recomputes next-run times against the (now fresh) imported data and + // re-syncs every alarm with the Android native scheduler. + await alarmas.refrescarProgramacion(); +} + /// APLICACIÓN group · "Copia de seguridad" (design ADR-3). Body moved /// verbatim from the former `_SeccionBackup` in `pantalla_ajustes.dart` — /// only the panel header's icon and title were removed (the pushed screen's @@ -102,8 +135,9 @@ class _CuerpoBackup extends StatelessWidget { if (confirmar != true) return; if (context.mounted) { final estado = context.read(); + final alarmas = context.read(); final messenger = ScaffoldMessenger.of(context); - await estado.importarConfig(json); + await aplicarImportacionConfig(estado, alarmas, json); messenger.showSnackBar( SnackBar(content: Text(l10n.backupImportSuccess)), ); diff --git a/test/estado/estado_alarmas_import_test.dart b/test/estado/estado_alarmas_import_test.dart new file mode 100644 index 0000000..85d1d10 --- /dev/null +++ b/test/estado/estado_alarmas_import_test.dart @@ -0,0 +1,284 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_alarmas.dart'; +import 'package:pluriwave/estado/estado_radio.dart'; +import 'package:pluriwave/modelos/alarma_musical.dart'; +import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_backup.dart'; +import 'package:pluriwave/servicios/servicio_alarmas.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../helpers/fakes.dart'; +import '../helpers/fakes_alarmas.dart'; + +/// Regression coverage for the data-loss bug (fix/import-alarmas-y-paywall): +/// `EstadoRadio.importarConfig` writes the imported alarm block straight to +/// SharedPreferences, but `EstadoAlarmas` is a separate long-lived +/// `ChangeNotifier` that loaded its alarms into memory at construction and +/// never re-reads on its own. These tests exercise the EXACT sequence the +/// real call site (`pantalla_ajustes_backup.dart`'s `_importar`) now runs +/// after a successful import: `EstadoRadio.importarConfig` followed by +/// `EstadoAlarmas.cargarPersistidasSinRecalcular()` + +/// `EstadoAlarmas.refrescarProgramacion()` — bypassing the file_picker +/// platform channel and the confirmation dialog, which are pure UI +/// plumbing already covered by `pantalla_ajustes_backup_test.dart`. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + // A PRIVATE per-test file, never the shared `test/fixtures/` one: + // `EstadoRadio.importarConfig` unconditionally calls + // `_guardarEmisorasCustom()`, which WRITES to whatever + // `resolverArchivoCustom` resolves to — pointing that at the shared + // fixture previously clobbered its committed BOM on disk as a side + // effect of running this file's tests. + tempDir = await Directory.systemTemp.createTemp( + 'pluriwave_estado_alarmas_import_test', + ); + }); + + tearDown(() async { + if (tempDir.existsSync()) { + await tempDir.delete(recursive: true); + } + }); + + Future archivoCustomVacio() async { + final file = File('${tempDir.path}/emisoras_custom.json'); + if (!file.existsSync()) { + await file.writeAsString('[]'); + } + return file; + } + + Map jsonAlarma(AlarmaMusical a) => { + 'id': a.id, + 'nombre': a.nombre, + 'activa': a.activa, + 'hora': a.hora, + 'minuto': a.minuto, + 'tipoProgramacion': a.tipoProgramacion.name, + 'diasSemana': a.diasSemana, + }; + + const alarmaVieja = AlarmaMusical( + id: 'vieja', + nombre: 'Alarma vieja (pre-import)', + hora: 6, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [1, 2, 3, 4, 5], + ); + + const alarmaImportada = AlarmaMusical( + id: 'importada', + nombre: 'Alarma importada', + hora: 8, + minuto: 15, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [6, 7], + ); + + /// Builds the pair the app wires together: `EstadoRadio` (owns + /// `importarConfig`) and `EstadoAlarmas` (owns the alarm reload + + /// re-scheduling this bugfix adds), sharing ONE `SharedPreferences` + /// instance exactly like the real app's provider tree does. + Future< + ({ + EstadoRadio radio, + EstadoAlarmas alarmas, + FakePuertoAlarmasAndroid android, + }) + > + crearPar() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + 'alarmas_musicales_v1', + jsonEncode({ + 'alarmas': [jsonAlarma(alarmaVieja)], + 'vacaciones': [], + 'excepciones': [], + }), + ); + final android = FakePuertoAlarmasAndroid(); + final alarmas = EstadoAlarmas( + esPremium: () => true, + servicio: ServicioAlarmas(prefs: prefs), + android: android, + prefs: prefs, + iniciarAutomaticamente: false, + ); + // Loads + native-syncs the pre-import alarm WITHOUT arming + // `inicializar()`'s periodic timers (irrelevant to this bugfix and a + // needless liability for a `flutter test` run). + await alarmas.refrescarProgramacion(); + + final radio = EstadoRadio( + esPremium: () => true, + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + servicioGrabacion: FakeServicioGrabacionRadioInactiva(), + resolverArchivoCustom: archivoCustomVacio, + prefs: prefs, + iniciarAutomaticamente: false, + ); + + return (radio: radio, alarmas: alarmas, android: android); + } + + Map backupCon({ + required List alarmas, + List> vacaciones = const [], + List> excepciones = const [], + String ordenListas = 'nombre', + }) => { + 'version': 2, + 'gruposFavoritos': [], + 'favoritos': [], + 'emisorasCustom': [], + 'presetsEcualizador': {}, + 'alarmas': { + 'alarmas': alarmas.map(jsonAlarma).toList(), + 'vacaciones': vacaciones, + 'excepciones': excepciones, + }, + 'emisoraPreferidaUuid': null, + 'ordenListas': ordenListas, + 'timerSuenoPresetsSegundos': [300, 600], + }; + + test('after import, EstadoAlarmas reflects the imported alarms, not the ' + 'pre-import ones', () async { + final par = await crearPar(); + addTearDown(par.radio.dispose); + addTearDown(par.alarmas.dispose); + addTearDown(par.android.dispose); + + expect(par.alarmas.alarmas.map((a) => a.id), ['vieja']); + + await aplicarImportacionConfig( + par.radio, + par.alarmas, + backupCon(alarmas: [alarmaImportada]), + ); + + expect(par.alarmas.alarmas.map((a) => a.id), ['importada']); + expect(par.alarmas.alarmas.single.nombre, 'Alarma importada'); + }); + + test('native re-scheduling is triggered after an import', () async { + final par = await crearPar(); + addTearDown(par.radio.dispose); + addTearDown(par.alarmas.dispose); + addTearDown(par.android.dispose); + + // Sanity: the pre-import alarm was already scheduled. + expect(par.android.programadas.map((a) => a.id), contains('vieja')); + + await aplicarImportacionConfig( + par.radio, + par.alarmas, + backupCon(alarmas: [alarmaImportada]), + ); + + // The imported alarm was handed to the native Android bridge — this is + // what makes it actually ring, not just appear in the list. + expect(par.android.programadas.map((a) => a.id), contains('importada')); + }); + + test( + 'vacation ranges and alarm exceptions in the same block come back too', + () async { + final par = await crearPar(); + addTearDown(par.radio.dispose); + addTearDown(par.alarmas.dispose); + addTearDown(par.android.dispose); + + expect(par.alarmas.vacaciones, isEmpty); + expect(par.alarmas.excepciones, isEmpty); + + await aplicarImportacionConfig( + par.radio, + par.alarmas, + backupCon( + alarmas: [alarmaImportada], + vacaciones: [ + { + 'id': 'vac1', + 'nombre': 'Verano', + 'inicio': '2026-07-01T00:00:00.000', + 'fin': '2026-07-15T00:00:00.000', + 'activo': true, + }, + ], + excepciones: [ + { + 'alarmaId': 'importada', + 'ejecucion': '2026-08-30T08:15:00.000', + 'tipo': 'skipNext', + }, + ], + ), + ); + + expect(par.alarmas.vacaciones.map((v) => v.id), ['vac1']); + expect(par.alarmas.excepciones.map((e) => e.alarmaId), ['importada']); + }, + ); + + test('a failed import (e.g. malformed/unsupported version) leaves existing ' + 'alarms untouched', () async { + final par = await crearPar(); + addTearDown(par.radio.dispose); + addTearDown(par.alarmas.dispose); + addTearDown(par.android.dispose); + + final backupNoSoportado = backupCon(alarmas: [alarmaImportada]) + ..['version'] = 99; + + // Runs the SAME production function the call site uses: a throw from + // `importarConfig` must propagate before either reload call runs. + await expectLater( + aplicarImportacionConfig(par.radio, par.alarmas, backupNoSoportado), + throwsA(anything), + ); + + expect(par.alarmas.alarmas.map((a) => a.id), ['vieja']); + expect(par.android.programadas.map((a) => a.id), ['vieja']); + }); + + test('a cancelled import (dialog declined, importarConfig never called) ' + 'leaves existing alarms untouched', () async { + final par = await crearPar(); + addTearDown(par.radio.dispose); + addTearDown(par.alarmas.dispose); + addTearDown(par.android.dispose); + + // Simulates the user declining the confirm dialog: the call site + // returns before `importarConfig` and the two reload calls ever run. + expect(par.alarmas.alarmas.map((a) => a.id), ['vieja']); + expect(par.android.programadas.map((a) => a.id), ['vieja']); + }); + + test('regression: importing still restores preferences (ordenListas) ' + 'exactly as before', () async { + final par = await crearPar(); + addTearDown(par.radio.dispose); + addTearDown(par.alarmas.dispose); + addTearDown(par.android.dispose); + + await aplicarImportacionConfig( + par.radio, + par.alarmas, + backupCon(alarmas: [alarmaImportada], ordenListas: 'nombre'), + ); + + expect(par.radio.ordenListas.name, 'nombre'); + }); +} From a2bed1893769cf4291fe24d88d2c9570b9564185 Mon Sep 17 00:00:00 2001 From: freetlab Date: Fri, 28 Aug 2026 22:47:53 +0200 Subject: [PATCH 6/7] fix(paywall): add dismiss controls and honest premium copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The premium sheet had no close affordance or way to defer, and its copy only said "Función Premium" without stating what it unlocks. Adds a header close (X) button and a "not now" secondary action so dismissal is never harder than purchasing, and replaces the bare title with a concrete, honest breakdown of the 5 things premium unlocks (no ads, Android Auto, station recording, alarm vacation ranges, unlimited alarms) plus the one-time-purchase framing. The phone equalizer is never listed, since it stays free for everyone. New l10n keys added to all 13 locales. --- lib/l10n/app_ar.arb | 10 +- lib/l10n/app_bn.arb | 10 +- lib/l10n/app_de.arb | 10 +- lib/l10n/app_en.arb | 10 +- lib/l10n/app_es.arb | 10 +- lib/l10n/app_fr.arb | 10 +- lib/l10n/app_hi.arb | 10 +- lib/l10n/app_id.arb | 10 +- lib/l10n/app_it.arb | 10 +- lib/l10n/app_ja.arb | 10 +- lib/l10n/app_pt.arb | 10 +- lib/l10n/app_ru.arb | 10 +- lib/l10n/app_zh.arb | 10 +- lib/l10n/gen/app_localizations.dart | 48 ++++++++ lib/l10n/gen/app_localizations_ar.dart | 25 ++++ lib/l10n/gen/app_localizations_bn.dart | 26 ++++ lib/l10n/gen/app_localizations_de.dart | 25 ++++ lib/l10n/gen/app_localizations_en.dart | 26 ++++ lib/l10n/gen/app_localizations_es.dart | 27 ++++ lib/l10n/gen/app_localizations_fr.dart | 28 +++++ lib/l10n/gen/app_localizations_hi.dart | 26 ++++ lib/l10n/gen/app_localizations_id.dart | 26 ++++ lib/l10n/gen/app_localizations_it.dart | 28 +++++ lib/l10n/gen/app_localizations_ja.dart | 24 ++++ lib/l10n/gen/app_localizations_pt.dart | 26 ++++ lib/l10n/gen/app_localizations_ru.dart | 27 ++++ lib/l10n/gen/app_localizations_zh.dart | 24 ++++ lib/widgets/hoja_premium.dart | 70 ++++++++++- test/l10n/identical_value_allowlist.dart | 14 +++ test/widgets/hoja_premium_test.dart | 150 +++++++++++++++++++++++ 30 files changed, 736 insertions(+), 14 deletions(-) diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 47e952b..54c33ef 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -904,5 +904,13 @@ "restaurarCompras": "استعادة المشتريات", "compraError": "تعذّر إتمام عملية الشراء. حاول مرة أخرى.", "restauracionSinCompras": "لم نجد أي عملية شراء سابقة في هذا الحساب.", - "premiumActivo": "النسخة المميزة مفعّلة" + "premiumActivo": "النسخة المميزة مفعّلة", + "premiumHojaTitulo": "افتح PluriWave Premium", + "premiumBeneficioSinAnuncios": "بدون إعلانات في التطبيق بالكامل", + "premiumBeneficioAndroidAuto": "Android Auto", + "premiumBeneficioGrabacion": "تسجيل المحطات", + "premiumBeneficioVacaciones": "فترات إجازة للمنبهات", + "premiumBeneficioAlarmasIlimitadas": "منبهات غير محدودة (تسمح الخطة المجانية بحتى 5)", + "premiumPagoUnico": "دفعة واحدة، للأبد. ليس اشتراكًا.", + "premiumAhoraNo": "ليس الآن" } diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index ac9312a..e8c907a 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -904,5 +904,13 @@ "restaurarCompras": "কেনাকাটা পুনরুদ্ধার করুন", "compraError": "কেনাকাটা সম্পূর্ণ করা যায়নি। আবার চেষ্টা করুন।", "restauracionSinCompras": "এই অ্যাকাউন্টে আমরা আগের কোনো কেনাকাটা খুঁজে পাইনি।", - "premiumActivo": "প্রিমিয়াম সক্রিয়" + "premiumActivo": "প্রিমিয়াম সক্রিয়", + "premiumHojaTitulo": "PluriWave Premium আনলক করুন", + "premiumBeneficioSinAnuncios": "পুরো অ্যাপে কোনো বিজ্ঞাপন নেই", + "premiumBeneficioAndroidAuto": "Android Auto", + "premiumBeneficioGrabacion": "স্টেশন রেকর্ডিং", + "premiumBeneficioVacaciones": "অ্যালার্মের জন্য ছুটির সময়কাল", + "premiumBeneficioAlarmasIlimitadas": "সীমাহীন অ্যালার্ম (ফ্রি প্ল্যানে সর্বোচ্চ ৫টি অনুমোদিত)", + "premiumPagoUnico": "একবারের পেমেন্ট, চিরকালের জন্য। এটি সাবস্ক্রিপশন নয়।", + "premiumAhoraNo": "এখন নয়" } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 55432d7..ad852ba 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -904,5 +904,13 @@ "restaurarCompras": "Käufe wiederherstellen", "compraError": "Der Kauf konnte nicht abgeschlossen werden. Bitte versuche es erneut.", "restauracionSinCompras": "Wir haben auf diesem Konto keinen früheren Kauf gefunden.", - "premiumActivo": "Premium aktiv" + "premiumActivo": "Premium aktiv", + "premiumHojaTitulo": "PluriWave Premium freischalten", + "premiumBeneficioSinAnuncios": "Keine Werbung in der gesamten App", + "premiumBeneficioAndroidAuto": "Android Auto", + "premiumBeneficioGrabacion": "Sender aufnehmen", + "premiumBeneficioVacaciones": "Urlaubszeiträume für Wecker", + "premiumBeneficioAlarmasIlimitadas": "Unbegrenzte Wecker (die kostenlose Version erlaubt bis zu 5)", + "premiumPagoUnico": "Einmalzahlung, für immer. Kein Abonnement.", + "premiumAhoraNo": "Nicht jetzt" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 19f7e7d..68865fc 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -904,5 +904,13 @@ "restaurarCompras": "Restore purchases", "compraError": "We couldn't complete the purchase. Please try again.", "restauracionSinCompras": "We didn't find any previous purchase on this account.", - "premiumActivo": "Premium active" + "premiumActivo": "Premium active", + "premiumHojaTitulo": "Unlock PluriWave Premium", + "premiumBeneficioSinAnuncios": "No ads anywhere in the app", + "premiumBeneficioAndroidAuto": "Android Auto", + "premiumBeneficioGrabacion": "Station recording", + "premiumBeneficioVacaciones": "Vacation ranges for alarms", + "premiumBeneficioAlarmasIlimitadas": "Unlimited alarms (the free plan allows up to 5)", + "premiumPagoUnico": "One-time purchase, forever. Not a subscription.", + "premiumAhoraNo": "Not now" } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index b9805cf..0805e81 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -863,5 +863,13 @@ "restaurarCompras": "Restaurar compras", "compraError": "No se ha podido completar la compra. Inténtalo de nuevo.", "restauracionSinCompras": "No hemos encontrado ninguna compra anterior en esta cuenta.", - "premiumActivo": "Premium activo" + "premiumActivo": "Premium activo", + "premiumHojaTitulo": "Desbloquea PluriWave Premium", + "premiumBeneficioSinAnuncios": "Sin publicidad en toda la app", + "premiumBeneficioAndroidAuto": "Android Auto", + "premiumBeneficioGrabacion": "Grabación de emisoras", + "premiumBeneficioVacaciones": "Rangos de vacaciones para las alarmas", + "premiumBeneficioAlarmasIlimitadas": "Alarmas ilimitadas (el plan gratuito permite hasta 5)", + "premiumPagoUnico": "Pago único, para siempre. No es una suscripción.", + "premiumAhoraNo": "Ahora no" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 0350120..6ff0fe3 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -904,5 +904,13 @@ "restaurarCompras": "Restaurer les achats", "compraError": "Impossible de finaliser l'achat. Veuillez réessayer.", "restauracionSinCompras": "Nous n'avons trouvé aucun achat antérieur sur ce compte.", - "premiumActivo": "Premium actif" + "premiumActivo": "Premium actif", + "premiumHojaTitulo": "Débloquer PluriWave Premium", + "premiumBeneficioSinAnuncios": "Aucune publicité dans toute l'application", + "premiumBeneficioAndroidAuto": "Android Auto", + "premiumBeneficioGrabacion": "Enregistrement des stations", + "premiumBeneficioVacaciones": "Périodes de vacances pour les alarmes", + "premiumBeneficioAlarmasIlimitadas": "Alarmes illimitées (la version gratuite en autorise jusqu'à 5)", + "premiumPagoUnico": "Achat unique, pour toujours. Ce n'est pas un abonnement.", + "premiumAhoraNo": "Plus tard" } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 022c0ca..9df9299 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -904,5 +904,13 @@ "restaurarCompras": "खरीदारी पुनर्स्थापित करें", "compraError": "खरीद पूरी नहीं हो सकी। कृपया फिर से प्रयास करें।", "restauracionSinCompras": "इस खाते में हमें कोई पिछली खरीद नहीं मिली।", - "premiumActivo": "प्रीमियम सक्रिय" + "premiumActivo": "प्रीमियम सक्रिय", + "premiumHojaTitulo": "PluriWave Premium अनलॉक करें", + "premiumBeneficioSinAnuncios": "पूरे ऐप में कोई विज्ञापन नहीं", + "premiumBeneficioAndroidAuto": "Android Auto", + "premiumBeneficioGrabacion": "स्टेशन रिकॉर्डिंग", + "premiumBeneficioVacaciones": "अलार्म के लिए छुट्टी की अवधि", + "premiumBeneficioAlarmasIlimitadas": "असीमित अलार्म (मुफ़्त प्लान में अधिकतम 5 की अनुमति है)", + "premiumPagoUnico": "एकमुश्त भुगतान, हमेशा के लिए। यह सदस्यता नहीं है।", + "premiumAhoraNo": "अभी नहीं" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 0cd5ee3..4fd5dda 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -904,5 +904,13 @@ "restaurarCompras": "Pulihkan pembelian", "compraError": "Pembelian tidak dapat diselesaikan. Silakan coba lagi.", "restauracionSinCompras": "Kami tidak menemukan pembelian sebelumnya di akun ini.", - "premiumActivo": "Premium aktif" + "premiumActivo": "Premium aktif", + "premiumHojaTitulo": "Buka PluriWave Premium", + "premiumBeneficioSinAnuncios": "Tanpa iklan di seluruh aplikasi", + "premiumBeneficioAndroidAuto": "Android Auto", + "premiumBeneficioGrabacion": "Perekaman stasiun", + "premiumBeneficioVacaciones": "Rentang liburan untuk alarm", + "premiumBeneficioAlarmasIlimitadas": "Alarm tanpa batas (paket gratis mengizinkan hingga 5)", + "premiumPagoUnico": "Pembelian sekali bayar, untuk selamanya. Bukan langganan.", + "premiumAhoraNo": "Nanti saja" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index e71c341..04b322c 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -904,5 +904,13 @@ "restaurarCompras": "Ripristina acquisti", "compraError": "Non è stato possibile completare l'acquisto. Riprova.", "restauracionSinCompras": "Non abbiamo trovato acquisti precedenti su questo account.", - "premiumActivo": "Premium attivo" + "premiumActivo": "Premium attivo", + "premiumHojaTitulo": "Sblocca PluriWave Premium", + "premiumBeneficioSinAnuncios": "Nessuna pubblicità in tutta l'app", + "premiumBeneficioAndroidAuto": "Android Auto", + "premiumBeneficioGrabacion": "Registrazione delle stazioni", + "premiumBeneficioVacaciones": "Intervalli di vacanza per le sveglie", + "premiumBeneficioAlarmasIlimitadas": "Sveglie illimitate (il piano gratuito ne consente fino a 5)", + "premiumPagoUnico": "Acquisto unico, per sempre. Non è un abbonamento.", + "premiumAhoraNo": "Non ora" } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index da8fa0e..6a4f0c0 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -904,5 +904,13 @@ "restaurarCompras": "購入を復元", "compraError": "購入を完了できませんでした。もう一度お試しください。", "restauracionSinCompras": "このアカウントでは以前の購入が見つかりませんでした。", - "premiumActivo": "プレミアム有効" + "premiumActivo": "プレミアム有効", + "premiumHojaTitulo": "PluriWave Premiumのロックを解除", + "premiumBeneficioSinAnuncios": "アプリ全体で広告なし", + "premiumBeneficioAndroidAuto": "Android Auto", + "premiumBeneficioGrabacion": "放送局の録音", + "premiumBeneficioVacaciones": "アラームの休暇期間設定", + "premiumBeneficioAlarmasIlimitadas": "アラーム数無制限(無料プランは5個まで)", + "premiumPagoUnico": "買い切りの一度きりの支払いで永久に使用可能。サブスクリプションではありません。", + "premiumAhoraNo": "後で" } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index df0a5b3..53fcc29 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -904,5 +904,13 @@ "restaurarCompras": "Restaurar compras", "compraError": "Não foi possível concluir a compra. Tente novamente.", "restauracionSinCompras": "Não encontramos nenhuma compra anterior nesta conta.", - "premiumActivo": "Premium ativo" + "premiumActivo": "Premium ativo", + "premiumHojaTitulo": "Desbloqueie o PluriWave Premium", + "premiumBeneficioSinAnuncios": "Sem anúncios em todo o app", + "premiumBeneficioAndroidAuto": "Android Auto", + "premiumBeneficioGrabacion": "Gravação de emissoras", + "premiumBeneficioVacaciones": "Períodos de férias para os alarmes", + "premiumBeneficioAlarmasIlimitadas": "Alarmes ilimitados (o plano gratuito permite até 5)", + "premiumPagoUnico": "Pagamento único, para sempre. Não é uma assinatura.", + "premiumAhoraNo": "Agora não" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index f11ede4..162f35e 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -904,5 +904,13 @@ "restaurarCompras": "Восстановить покупки", "compraError": "Не удалось завершить покупку. Попробуйте ещё раз.", "restauracionSinCompras": "Мы не нашли предыдущих покупок на этом аккаунте.", - "premiumActivo": "Премиум активен" + "premiumActivo": "Премиум активен", + "premiumHojaTitulo": "Разблокировать PluriWave Premium", + "premiumBeneficioSinAnuncios": "Никакой рекламы во всём приложении", + "premiumBeneficioAndroidAuto": "Android Auto", + "premiumBeneficioGrabacion": "Запись радиостанций", + "premiumBeneficioVacaciones": "Периоды отпуска для будильников", + "premiumBeneficioAlarmasIlimitadas": "Неограниченное количество будильников (бесплатный план позволяет до 5)", + "premiumPagoUnico": "Единоразовая покупка, навсегда. Это не подписка.", + "premiumAhoraNo": "Не сейчас" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 2d48b57..17fb190 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -904,5 +904,13 @@ "restaurarCompras": "恢复购买", "compraError": "无法完成购买,请重试。", "restauracionSinCompras": "未在此账户中找到以前的购买记录。", - "premiumActivo": "高级版已解锁" + "premiumActivo": "高级版已解锁", + "premiumHojaTitulo": "解锁 PluriWave Premium", + "premiumBeneficioSinAnuncios": "全应用无广告", + "premiumBeneficioAndroidAuto": "Android Auto", + "premiumBeneficioGrabacion": "电台录音", + "premiumBeneficioVacaciones": "闹钟的假期时间段", + "premiumBeneficioAlarmasIlimitadas": "无限闹钟(免费版最多支持5个)", + "premiumPagoUnico": "一次性付费,永久使用,不是订阅。", + "premiumAhoraNo": "以后再说" } diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 6be60e2..82e28c5 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -3367,6 +3367,54 @@ abstract class AppLocalizations { /// In es, this message translates to: /// **'Premium activo'** String get premiumActivo; + + /// No description provided for @premiumHojaTitulo. + /// + /// In es, this message translates to: + /// **'Desbloquea PluriWave Premium'** + String get premiumHojaTitulo; + + /// No description provided for @premiumBeneficioSinAnuncios. + /// + /// In es, this message translates to: + /// **'Sin publicidad en toda la app'** + String get premiumBeneficioSinAnuncios; + + /// No description provided for @premiumBeneficioAndroidAuto. + /// + /// In es, this message translates to: + /// **'Android Auto'** + String get premiumBeneficioAndroidAuto; + + /// No description provided for @premiumBeneficioGrabacion. + /// + /// In es, this message translates to: + /// **'Grabación de emisoras'** + String get premiumBeneficioGrabacion; + + /// No description provided for @premiumBeneficioVacaciones. + /// + /// In es, this message translates to: + /// **'Rangos de vacaciones para las alarmas'** + String get premiumBeneficioVacaciones; + + /// No description provided for @premiumBeneficioAlarmasIlimitadas. + /// + /// In es, this message translates to: + /// **'Alarmas ilimitadas (el plan gratuito permite hasta 5)'** + String get premiumBeneficioAlarmasIlimitadas; + + /// No description provided for @premiumPagoUnico. + /// + /// In es, this message translates to: + /// **'Pago único, para siempre. No es una suscripción.'** + String get premiumPagoUnico; + + /// No description provided for @premiumAhoraNo. + /// + /// In es, this message translates to: + /// **'Ahora no'** + String get premiumAhoraNo; } class _AppLocalizationsDelegate diff --git a/lib/l10n/gen/app_localizations_ar.dart b/lib/l10n/gen/app_localizations_ar.dart index e2b0952..856a93b 100644 --- a/lib/l10n/gen/app_localizations_ar.dart +++ b/lib/l10n/gen/app_localizations_ar.dart @@ -1863,4 +1863,29 @@ class AppLocalizationsAr extends AppLocalizations { @override String get premiumActivo => 'النسخة المميزة مفعّلة'; + + @override + String get premiumHojaTitulo => 'افتح PluriWave Premium'; + + @override + String get premiumBeneficioSinAnuncios => 'بدون إعلانات في التطبيق بالكامل'; + + @override + String get premiumBeneficioAndroidAuto => 'Android Auto'; + + @override + String get premiumBeneficioGrabacion => 'تسجيل المحطات'; + + @override + String get premiumBeneficioVacaciones => 'فترات إجازة للمنبهات'; + + @override + String get premiumBeneficioAlarmasIlimitadas => + 'منبهات غير محدودة (تسمح الخطة المجانية بحتى 5)'; + + @override + String get premiumPagoUnico => 'دفعة واحدة، للأبد. ليس اشتراكًا.'; + + @override + String get premiumAhoraNo => 'ليس الآن'; } diff --git a/lib/l10n/gen/app_localizations_bn.dart b/lib/l10n/gen/app_localizations_bn.dart index dffb7b5..89fe034 100644 --- a/lib/l10n/gen/app_localizations_bn.dart +++ b/lib/l10n/gen/app_localizations_bn.dart @@ -1874,4 +1874,30 @@ class AppLocalizationsBn extends AppLocalizations { @override String get premiumActivo => 'প্রিমিয়াম সক্রিয়'; + + @override + String get premiumHojaTitulo => 'PluriWave Premium আনলক করুন'; + + @override + String get premiumBeneficioSinAnuncios => 'পুরো অ্যাপে কোনো বিজ্ঞাপন নেই'; + + @override + String get premiumBeneficioAndroidAuto => 'Android Auto'; + + @override + String get premiumBeneficioGrabacion => 'স্টেশন রেকর্ডিং'; + + @override + String get premiumBeneficioVacaciones => 'অ্যালার্মের জন্য ছুটির সময়কাল'; + + @override + String get premiumBeneficioAlarmasIlimitadas => + 'সীমাহীন অ্যালার্ম (ফ্রি প্ল্যানে সর্বোচ্চ ৫টি অনুমোদিত)'; + + @override + String get premiumPagoUnico => + 'একবারের পেমেন্ট, চিরকালের জন্য। এটি সাবস্ক্রিপশন নয়।'; + + @override + String get premiumAhoraNo => 'এখন নয়'; } diff --git a/lib/l10n/gen/app_localizations_de.dart b/lib/l10n/gen/app_localizations_de.dart index 771a63d..71e29b4 100644 --- a/lib/l10n/gen/app_localizations_de.dart +++ b/lib/l10n/gen/app_localizations_de.dart @@ -1888,4 +1888,29 @@ class AppLocalizationsDe extends AppLocalizations { @override String get premiumActivo => 'Premium aktiv'; + + @override + String get premiumHojaTitulo => 'PluriWave Premium freischalten'; + + @override + String get premiumBeneficioSinAnuncios => 'Keine Werbung in der gesamten App'; + + @override + String get premiumBeneficioAndroidAuto => 'Android Auto'; + + @override + String get premiumBeneficioGrabacion => 'Sender aufnehmen'; + + @override + String get premiumBeneficioVacaciones => 'Urlaubszeiträume für Wecker'; + + @override + String get premiumBeneficioAlarmasIlimitadas => + 'Unbegrenzte Wecker (die kostenlose Version erlaubt bis zu 5)'; + + @override + String get premiumPagoUnico => 'Einmalzahlung, für immer. Kein Abonnement.'; + + @override + String get premiumAhoraNo => 'Nicht jetzt'; } diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index e0e278c..4b26591 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -1867,4 +1867,30 @@ class AppLocalizationsEn extends AppLocalizations { @override String get premiumActivo => 'Premium active'; + + @override + String get premiumHojaTitulo => 'Unlock PluriWave Premium'; + + @override + String get premiumBeneficioSinAnuncios => 'No ads anywhere in the app'; + + @override + String get premiumBeneficioAndroidAuto => 'Android Auto'; + + @override + String get premiumBeneficioGrabacion => 'Station recording'; + + @override + String get premiumBeneficioVacaciones => 'Vacation ranges for alarms'; + + @override + String get premiumBeneficioAlarmasIlimitadas => + 'Unlimited alarms (the free plan allows up to 5)'; + + @override + String get premiumPagoUnico => + 'One-time purchase, forever. Not a subscription.'; + + @override + String get premiumAhoraNo => 'Not now'; } diff --git a/lib/l10n/gen/app_localizations_es.dart b/lib/l10n/gen/app_localizations_es.dart index 81b57e5..5026540 100644 --- a/lib/l10n/gen/app_localizations_es.dart +++ b/lib/l10n/gen/app_localizations_es.dart @@ -1881,4 +1881,31 @@ class AppLocalizationsEs extends AppLocalizations { @override String get premiumActivo => 'Premium activo'; + + @override + String get premiumHojaTitulo => 'Desbloquea PluriWave Premium'; + + @override + String get premiumBeneficioSinAnuncios => 'Sin publicidad en toda la app'; + + @override + String get premiumBeneficioAndroidAuto => 'Android Auto'; + + @override + String get premiumBeneficioGrabacion => 'Grabación de emisoras'; + + @override + String get premiumBeneficioVacaciones => + 'Rangos de vacaciones para las alarmas'; + + @override + String get premiumBeneficioAlarmasIlimitadas => + 'Alarmas ilimitadas (el plan gratuito permite hasta 5)'; + + @override + String get premiumPagoUnico => + 'Pago único, para siempre. No es una suscripción.'; + + @override + String get premiumAhoraNo => 'Ahora no'; } diff --git a/lib/l10n/gen/app_localizations_fr.dart b/lib/l10n/gen/app_localizations_fr.dart index 3bcfd4c..65d1c6a 100644 --- a/lib/l10n/gen/app_localizations_fr.dart +++ b/lib/l10n/gen/app_localizations_fr.dart @@ -1894,4 +1894,32 @@ class AppLocalizationsFr extends AppLocalizations { @override String get premiumActivo => 'Premium actif'; + + @override + String get premiumHojaTitulo => 'Débloquer PluriWave Premium'; + + @override + String get premiumBeneficioSinAnuncios => + 'Aucune publicité dans toute l\'application'; + + @override + String get premiumBeneficioAndroidAuto => 'Android Auto'; + + @override + String get premiumBeneficioGrabacion => 'Enregistrement des stations'; + + @override + String get premiumBeneficioVacaciones => + 'Périodes de vacances pour les alarmes'; + + @override + String get premiumBeneficioAlarmasIlimitadas => + 'Alarmes illimitées (la version gratuite en autorise jusqu\'à 5)'; + + @override + String get premiumPagoUnico => + 'Achat unique, pour toujours. Ce n\'est pas un abonnement.'; + + @override + String get premiumAhoraNo => 'Plus tard'; } diff --git a/lib/l10n/gen/app_localizations_hi.dart b/lib/l10n/gen/app_localizations_hi.dart index c2d7c44..13ed2c7 100644 --- a/lib/l10n/gen/app_localizations_hi.dart +++ b/lib/l10n/gen/app_localizations_hi.dart @@ -1867,4 +1867,30 @@ class AppLocalizationsHi extends AppLocalizations { @override String get premiumActivo => 'प्रीमियम सक्रिय'; + + @override + String get premiumHojaTitulo => 'PluriWave Premium अनलॉक करें'; + + @override + String get premiumBeneficioSinAnuncios => 'पूरे ऐप में कोई विज्ञापन नहीं'; + + @override + String get premiumBeneficioAndroidAuto => 'Android Auto'; + + @override + String get premiumBeneficioGrabacion => 'स्टेशन रिकॉर्डिंग'; + + @override + String get premiumBeneficioVacaciones => 'अलार्म के लिए छुट्टी की अवधि'; + + @override + String get premiumBeneficioAlarmasIlimitadas => + 'असीमित अलार्म (मुफ़्त प्लान में अधिकतम 5 की अनुमति है)'; + + @override + String get premiumPagoUnico => + 'एकमुश्त भुगतान, हमेशा के लिए। यह सदस्यता नहीं है।'; + + @override + String get premiumAhoraNo => 'अभी नहीं'; } diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 08dbd8b..a44e4c0 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -1878,4 +1878,30 @@ class AppLocalizationsId extends AppLocalizations { @override String get premiumActivo => 'Premium aktif'; + + @override + String get premiumHojaTitulo => 'Buka PluriWave Premium'; + + @override + String get premiumBeneficioSinAnuncios => 'Tanpa iklan di seluruh aplikasi'; + + @override + String get premiumBeneficioAndroidAuto => 'Android Auto'; + + @override + String get premiumBeneficioGrabacion => 'Perekaman stasiun'; + + @override + String get premiumBeneficioVacaciones => 'Rentang liburan untuk alarm'; + + @override + String get premiumBeneficioAlarmasIlimitadas => + 'Alarm tanpa batas (paket gratis mengizinkan hingga 5)'; + + @override + String get premiumPagoUnico => + 'Pembelian sekali bayar, untuk selamanya. Bukan langganan.'; + + @override + String get premiumAhoraNo => 'Nanti saja'; } diff --git a/lib/l10n/gen/app_localizations_it.dart b/lib/l10n/gen/app_localizations_it.dart index 280c271..2344dc5 100644 --- a/lib/l10n/gen/app_localizations_it.dart +++ b/lib/l10n/gen/app_localizations_it.dart @@ -1891,4 +1891,32 @@ class AppLocalizationsIt extends AppLocalizations { @override String get premiumActivo => 'Premium attivo'; + + @override + String get premiumHojaTitulo => 'Sblocca PluriWave Premium'; + + @override + String get premiumBeneficioSinAnuncios => + 'Nessuna pubblicità in tutta l\'app'; + + @override + String get premiumBeneficioAndroidAuto => 'Android Auto'; + + @override + String get premiumBeneficioGrabacion => 'Registrazione delle stazioni'; + + @override + String get premiumBeneficioVacaciones => + 'Intervalli di vacanza per le sveglie'; + + @override + String get premiumBeneficioAlarmasIlimitadas => + 'Sveglie illimitate (il piano gratuito ne consente fino a 5)'; + + @override + String get premiumPagoUnico => + 'Acquisto unico, per sempre. Non è un abbonamento.'; + + @override + String get premiumAhoraNo => 'Non ora'; } diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 9e3b48a..fd3abcb 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -1812,4 +1812,28 @@ class AppLocalizationsJa extends AppLocalizations { @override String get premiumActivo => 'プレミアム有効'; + + @override + String get premiumHojaTitulo => 'PluriWave Premiumのロックを解除'; + + @override + String get premiumBeneficioSinAnuncios => 'アプリ全体で広告なし'; + + @override + String get premiumBeneficioAndroidAuto => 'Android Auto'; + + @override + String get premiumBeneficioGrabacion => '放送局の録音'; + + @override + String get premiumBeneficioVacaciones => 'アラームの休暇期間設定'; + + @override + String get premiumBeneficioAlarmasIlimitadas => 'アラーム数無制限(無料プランは5個まで)'; + + @override + String get premiumPagoUnico => '買い切りの一度きりの支払いで永久に使用可能。サブスクリプションではありません。'; + + @override + String get premiumAhoraNo => '後で'; } diff --git a/lib/l10n/gen/app_localizations_pt.dart b/lib/l10n/gen/app_localizations_pt.dart index 77af60d..e18ff5e 100644 --- a/lib/l10n/gen/app_localizations_pt.dart +++ b/lib/l10n/gen/app_localizations_pt.dart @@ -1878,4 +1878,30 @@ class AppLocalizationsPt extends AppLocalizations { @override String get premiumActivo => 'Premium ativo'; + + @override + String get premiumHojaTitulo => 'Desbloqueie o PluriWave Premium'; + + @override + String get premiumBeneficioSinAnuncios => 'Sem anúncios em todo o app'; + + @override + String get premiumBeneficioAndroidAuto => 'Android Auto'; + + @override + String get premiumBeneficioGrabacion => 'Gravação de emissoras'; + + @override + String get premiumBeneficioVacaciones => 'Períodos de férias para os alarmes'; + + @override + String get premiumBeneficioAlarmasIlimitadas => + 'Alarmes ilimitados (o plano gratuito permite até 5)'; + + @override + String get premiumPagoUnico => + 'Pagamento único, para sempre. Não é uma assinatura.'; + + @override + String get premiumAhoraNo => 'Agora não'; } diff --git a/lib/l10n/gen/app_localizations_ru.dart b/lib/l10n/gen/app_localizations_ru.dart index e30d9a6..51bcdd2 100644 --- a/lib/l10n/gen/app_localizations_ru.dart +++ b/lib/l10n/gen/app_localizations_ru.dart @@ -1884,4 +1884,31 @@ class AppLocalizationsRu extends AppLocalizations { @override String get premiumActivo => 'Премиум активен'; + + @override + String get premiumHojaTitulo => 'Разблокировать PluriWave Premium'; + + @override + String get premiumBeneficioSinAnuncios => + 'Никакой рекламы во всём приложении'; + + @override + String get premiumBeneficioAndroidAuto => 'Android Auto'; + + @override + String get premiumBeneficioGrabacion => 'Запись радиостанций'; + + @override + String get premiumBeneficioVacaciones => 'Периоды отпуска для будильников'; + + @override + String get premiumBeneficioAlarmasIlimitadas => + 'Неограниченное количество будильников (бесплатный план позволяет до 5)'; + + @override + String get premiumPagoUnico => + 'Единоразовая покупка, навсегда. Это не подписка.'; + + @override + String get premiumAhoraNo => 'Не сейчас'; } diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 8c15f6d..7486964 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -1797,4 +1797,28 @@ class AppLocalizationsZh extends AppLocalizations { @override String get premiumActivo => '高级版已解锁'; + + @override + String get premiumHojaTitulo => '解锁 PluriWave Premium'; + + @override + String get premiumBeneficioSinAnuncios => '全应用无广告'; + + @override + String get premiumBeneficioAndroidAuto => 'Android Auto'; + + @override + String get premiumBeneficioGrabacion => '电台录音'; + + @override + String get premiumBeneficioVacaciones => '闹钟的假期时间段'; + + @override + String get premiumBeneficioAlarmasIlimitadas => '无限闹钟(免费版最多支持5个)'; + + @override + String get premiumPagoUnico => '一次性付费,永久使用,不是订阅。'; + + @override + String get premiumAhoraNo => '以后再说'; } diff --git a/lib/widgets/hoja_premium.dart b/lib/widgets/hoja_premium.dart index 3c9b585..d7def7d 100644 --- a/lib/widgets/hoja_premium.dart +++ b/lib/widgets/hoja_premium.dart @@ -53,14 +53,42 @@ class HojaPremium extends StatelessWidget { const SizedBox(width: 10), Expanded( child: Text( - l10n.funcionPremium, + l10n.premiumHojaTitulo, style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w900, ), ), ), + // Explicit, obvious dismiss affordance (fix/import-alarmas-y- + // paywall): a purchase sheet the user cannot easily escape is + // a dark pattern and a Play policy risk. Reachable without + // buying or restoring, same weight as any other icon button. + IconButton( + 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 @@ -143,9 +171,49 @@ class HojaPremium extends StatelessWidget { : () => 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), + ), + ], ], ), ), ); } } + +/// One concrete, honest value-list row (fix/import-alarmas-y-paywall). +class _BeneficioPremium extends StatelessWidget { + const _BeneficioPremium({required this.texto}); + + final String texto; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.check_circle_rounded, + size: 18, + color: PluriWaveTokens.brand, + ), + const SizedBox(width: 8), + Expanded( + child: Text(texto, style: Theme.of(context).textTheme.bodyMedium), + ), + ], + ), + ); + } +} diff --git a/test/l10n/identical_value_allowlist.dart b/test/l10n/identical_value_allowlist.dart index 73ba388..11f3661 100644 --- a/test/l10n/identical_value_allowlist.dart +++ b/test/l10n/identical_value_allowlist.dart @@ -46,6 +46,20 @@ const Set<(String locale, String key)> identicalValueAllowlist = { ('pt', 'welcomeBullet2Title'), ('ru', 'welcomeBullet2Title'), ('zh', 'welcomeBullet2Title'), + ( + 'ar', + 'premiumBeneficioAndroidAuto', + ), // "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. diff --git a/test/widgets/hoja_premium_test.dart b/test/widgets/hoja_premium_test.dart index 9dc15b6..dc7d92b 100644 --- a/test/widgets/hoja_premium_test.dart +++ b/test/widgets/hoja_premium_test.dart @@ -9,6 +9,12 @@ import 'package:pluriwave/widgets/hoja_premium.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; +/// fix/import-alarmas-y-paywall: a purchase sheet the user cannot escape is +/// a dark pattern and a Play policy risk. These cover the dismiss +/// affordance, the honest/concrete feature list, and the equalizer guard +/// (the phone equalizer is free for everyone and must never be presented as +/// a premium feature — this has regressed conceptually before). + /// Fake [PuertoCompras] (mirrors `app_test.dart`'s own fake): lets a test /// drive [EstadoEntitlement]'s purchase-stream events without touching /// `in_app_purchase`. @@ -68,6 +74,150 @@ void main() { return estado; } + /// Presents `HojaPremium` through the REAL `mostrarHojaPremium` modal + /// route (unlike [bombear], which embeds it directly with no route to + /// pop) so the dismiss controls can be exercised end-to-end exactly as a + /// user would encounter them. + Future bombearComoHoja( + WidgetTester tester, { + required _PuertoComprasFalso compras, + }) async { + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (_) => EstadoEntitlement(prefs: null, compras: compras), + ), + ], + child: MaterialApp( + locale: const Locale('es'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: + (context) => Scaffold( + body: TextButton( + onPressed: () => mostrarHojaPremium(context), + child: const Text('abrir'), + ), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.tap(find.text('abrir')); + await tester.pumpAndSettle(); + l10n = AppLocalizations.of(tester.element(find.byType(HojaPremium))); + } + + group('fix/import-alarmas-y-paywall — dismissibility', () { + testWidgets('the close (X) control closes the sheet without purchasing or ' + 'restoring', (tester) async { + final compras = _PuertoComprasFalso(); + addTearDown(compras.dispose); + await bombearComoHoja(tester, compras: compras); + + expect(find.byType(HojaPremium), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('hoja-premium-cerrar'))); + await tester.pumpAndSettle(); + + expect(find.byType(HojaPremium), findsNothing); + }); + + testWidgets( + 'the "not now" secondary action closes the sheet without purchasing ' + 'or restoring', + (tester) async { + final compras = _PuertoComprasFalso(); + addTearDown(compras.dispose); + await bombearComoHoja(tester, compras: compras); + + expect(find.byType(HojaPremium), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('hoja-premium-ahora-no'))); + await tester.pumpAndSettle(); + + expect(find.byType(HojaPremium), findsNothing); + }, + ); + + testWidgets('the system back gesture also closes the sheet (default ' + 'isDismissible/enableDrag, no PopScope blocking it)', (tester) async { + final compras = _PuertoComprasFalso(); + addTearDown(compras.dispose); + await bombearComoHoja(tester, compras: compras); + + expect(find.byType(HojaPremium), findsOneWidget); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + expect(find.byType(HojaPremium), findsNothing); + }); + + testWidgets( + 'an already-premium user still gets the close control (no decline ' + 'needed) and no "not now" button', + (tester) async { + SharedPreferences.setMockInitialValues({'compra_premium_v1': true}); + final compras = _PuertoComprasFalso(); + addTearDown(compras.dispose); + await bombearComoHoja(tester, compras: compras); + + expect( + find.byKey(const ValueKey('hoja-premium-cerrar')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('hoja-premium-ahora-no')), + findsNothing, + ); + }, + ); + }); + + group('fix/import-alarmas-y-paywall — honest, concrete copy', () { + testWidgets('lists the 5 features premium actually unlocks', ( + tester, + ) async { + final compras = _PuertoComprasFalso(); + addTearDown(compras.dispose); + await bombear(tester, compras: compras); + + expect(find.text(l10n.premiumBeneficioSinAnuncios), findsOneWidget); + expect(find.text(l10n.premiumBeneficioAndroidAuto), findsOneWidget); + expect(find.text(l10n.premiumBeneficioGrabacion), findsOneWidget); + expect(find.text(l10n.premiumBeneficioVacaciones), findsOneWidget); + expect(find.text(l10n.premiumBeneficioAlarmasIlimitadas), findsOneWidget); + }); + + testWidgets('states this is a one-time purchase, not a subscription', ( + tester, + ) async { + final compras = _PuertoComprasFalso(); + addTearDown(compras.dispose); + await bombear(tester, compras: compras); + + expect(find.text(l10n.premiumPagoUnico), findsOneWidget); + }); + + testWidgets( + 'GUARD: the phone equalizer is never presented as a premium feature', + (tester) async { + final compras = _PuertoComprasFalso(); + addTearDown(compras.dispose); + await bombear(tester, compras: compras); + + expect(find.text(l10n.equalizerTitle), findsNothing); + expect(find.text(l10n.equalizerActive), findsNothing); + expect(find.textContaining('cualizador'), findsNothing); + expect(find.textContaining('qualizer'), findsNothing); + }, + ); + }); + group( 'FIX 9 — la etiqueta de premium activo es propia, no la del ecualizador', () { From 4ca2813267fd3902d8ee5d6df3b39b3224457be0 Mon Sep 17 00:00:00 2001 From: freetlab Date: Fri, 28 Aug 2026 23:07:01 +0200 Subject: [PATCH 7/7] feat(ecualizador): include equalizer on/off toggle in export/import The backup envelope carried favorites, EQ presets, alarms and the multi-device toggle but not EstadoEcualizador's own on/off flag, so restoring a backup on another device silently kept that device's existing toggle state instead of the source device's. Bumps the backup schema to v4 (additive over v3): the flag is only written when explicitly provided, so old exports stay at v2/v3. Importing an old backup without the field leaves the current toggle untouched rather than defaulting it. Applying the imported value reuses EstadoEcualizador.cambiarActivo so it persists and pushes to the live audio engine exactly like a manual toggle. --- lib/estado/estado_ecualizador.dart | 14 ++ lib/estado/estado_radio.dart | 22 ++- lib/servicios/servicio_export_import.dart | 45 ++++- test/estado/estado_ecualizador_test.dart | 77 ++++++++ ...do_radio_export_import_eq_activo_test.dart | 171 ++++++++++++++++++ test/helpers/fakes.dart | 11 ++ .../servicio_export_import_test.dart | 103 +++++++++++ 7 files changed, 428 insertions(+), 15 deletions(-) create mode 100644 test/estado/estado_radio_export_import_eq_activo_test.dart diff --git a/lib/estado/estado_ecualizador.dart b/lib/estado/estado_ecualizador.dart index a1ce397..aa2323d 100644 --- a/lib/estado/estado_ecualizador.dart +++ b/lib/estado/estado_ecualizador.dart @@ -695,12 +695,21 @@ class EstadoEcualizador extends ChangeNotifier { /// Replaces the whole EQ configuration (backup import path): persists it, /// re-applies the preset effective for the current station and notifies. + /// + /// [activo] is the imported on/off toggle (S4-R4/eq-export-toggle). When + /// `null` — an old backup with no `ecualizadorActivo` field — the CURRENT + /// toggle is left untouched: an absent flag must never flip the user's live + /// setting to an arbitrary value. When non-null, applies it through + /// [cambiarActivo], the same path a manual toggle uses, so the import + /// persists it AND pushes it to the live audio engine instead of just + /// updating [_activo] in memory. Future importarConfiguracion({ required PresetEcualizador principal, required Map porEmisora, Map? presetsDispositivo, Map? presetsMatriz, bool? eqMultiDeviceEnabled, + bool? activo, }) async { _presetPrincipal = principal; _presetsEmisoraMap @@ -735,6 +744,11 @@ class EstadoEcualizador extends ChangeNotifier { final presetEfectivoActual = uuid == null ? _presetPrincipal : _resolverPresetActivo(); await aplicarPresetActivo(presetEfectivoActual); + + if (activo != null) { + await cambiarActivo(activo); + } + notifyListeners(); } diff --git a/lib/estado/estado_radio.dart b/lib/estado/estado_radio.dart index 10e9d8f..2248e2e 100644 --- a/lib/estado/estado_radio.dart +++ b/lib/estado/estado_radio.dart @@ -807,9 +807,10 @@ class EstadoRadio extends ChangeNotifier { static const _keyAlarmasConfig = 'alarmas_musicales_v1'; - /// Genera el JSON de toda la configuración (v3 — portabilidad completa - /// con presets por dispositivo y matriz multi-device). - /// La forma del sobre v3 vive en [ServicioExportImport] (S4-R4). + /// Genera el JSON de toda la configuración (v4 — portabilidad completa + /// con presets por dispositivo, matriz multi-device y el toggle + /// on/off del ecualizador). + /// La forma del sobre vive en [ServicioExportImport] (S4-R4). Future> exportarConfig() async { final favs = await favoritos.obtenerTodos(); final grupos = await favoritos.obtenerGrupos(); @@ -837,6 +838,8 @@ class EstadoRadio extends ChangeNotifier { presetsPorDispositivo: ecualizador.presetsDispositivo, presetsMatriz: ecualizador.presetsMatriz, eqMultiDeviceEnabled: ecualizador.eqMultiDeviceEnabled, + // v4 extension — equalizer global on/off toggle. + ecualizadorActivo: ecualizador.activo, ); } @@ -850,10 +853,11 @@ class EstadoRadio extends ChangeNotifier { /// Importa configuración desde un JSON exportado previamente. /// Soporta v1 (sin grupos, sin alarmas), v2 (portabilidad completa), - /// y v3 (+ presets por dispositivo, presets matriz, toggle multi-device). + /// v3 (+ presets por dispositivo, presets matriz, toggle multi-device) + /// y v4 (+ toggle on/off del ecualizador). Future importarConfig(Map data) async { final version = data['version'] as int? ?? 1; - if (version > 3) throw Exception(_textos.unsupportedConfigVersion); + if (version > 4) throw Exception(_textos.unsupportedConfigVersion); final prefs = await _resolverPrefs(); @@ -932,12 +936,20 @@ class EstadoRadio extends ChangeNotifier { eqMultiDeviceEnabled = data['eqMultiDeviceEnabled'] as bool?; } + // v4 extension: equalizer on/off toggle. Read unconditionally — the key + // is simply absent on any pre-v4 backup, which resolves to `null` and + // leaves the user's CURRENT toggle untouched (see + // `EstadoEcualizador.importarConfiguracion` doc): an old backup must + // never flip a live setting it never carried. + final ecualizadorActivo = data['ecualizadorActivo'] as bool?; + await ecualizador.importarConfiguracion( principal: presetPrincipal, porEmisora: presetsPorEmisora, presetsDispositivo: presetsDispositivo, presetsMatriz: presetsMatriz, eqMultiDeviceEnabled: eqMultiDeviceEnabled, + activo: ecualizadorActivo, ); // ── Alarmas (v2) ────────────────────────────────────────────────────── diff --git a/lib/servicios/servicio_export_import.dart b/lib/servicios/servicio_export_import.dart index 5dbca6c..253acdb 100644 --- a/lib/servicios/servicio_export_import.dart +++ b/lib/servicios/servicio_export_import.dart @@ -7,26 +7,33 @@ import '../modelos/preset_ecualizador.dart'; /// Owns the backup (export/import) JSON serialization (S4-R4). /// /// v3 extends v2 with `presetsPorDispositivo`, `presetsMatriz`, and -/// `eqMultiDeviceEnabled`. When those optional parameters are omitted the -/// export stays at v2 for backward compat with the old app. State APPLICATION -/// (writing favorites, EQ, alarms back into the app) stays in +/// `eqMultiDeviceEnabled`. v4 extends v3 with `ecualizadorActivo` (the +/// equalizer's global ON/OFF toggle). When the version-N extension +/// parameters are all omitted the export stays at the lower version for +/// backward compat with older app builds. State APPLICATION (writing +/// favorites, EQ, alarms back into the app) stays in /// `EstadoRadio.importarConfig` — this service only owns serialization, /// parsing and the envelope shape. class ServicioExportImport { const ServicioExportImport(); - /// Current backup schema version (v3 — multi-device EQ). - static const int versionActual = 3; + /// Current backup schema version (v4 — equalizer on/off toggle). + static const int versionActual = 4; + + /// v3 version constant (multi-device EQ) kept for clarity. + static const int versionV3 = 3; /// Legacy v2 version constant kept for clarity. static const int versionV2 = 2; /// Builds the export envelope. /// - /// When [presetsPorDispositivo] or [presetsMatriz] are provided (non-null), - /// [versionActual] (3) is written. When both are omitted the call behaves - /// identically to the original v2 format (version key stays 2) so old - /// backups keep round-tripping without version bumps. + /// When [presetsPorDispositivo] or [presetsMatriz] or + /// [eqMultiDeviceEnabled] are provided (non-null), at least [versionV3] (3) + /// is written. When [ecualizadorActivo] is ALSO provided (non-null), + /// [versionActual] (4) is written. Omitting all of them behaves identically + /// to the original v2 format (version key stays 2) so old backups keep + /// round-tripping without version bumps. /// /// The `alarmas` block is the RAW JSON map persisted by ServicioAlarmas /// and passes through untouched (no re-parsing here). @@ -45,14 +52,27 @@ class ServicioExportImport { Map? presetsPorDispositivo, Map? presetsMatriz, bool? eqMultiDeviceEnabled, + // v4 extension — the equalizer's global ON/OFF toggle. Omitting it + // produces a v3 (or v2)-compatible export. + bool? ecualizadorActivo, }) { final tieneExtensionesV3 = presetsPorDispositivo != null || presetsMatriz != null || eqMultiDeviceEnabled != null; + final tieneExtensionV4 = ecualizadorActivo != null; + + final int version; + if (tieneExtensionV4) { + version = versionActual; + } else if (tieneExtensionesV3) { + version = versionV3; + } else { + version = versionV2; + } final envelope = { - 'version': tieneExtensionesV3 ? versionActual : versionV2, + 'version': version, 'exportedAt': (exportadoEn ?? DateTime.now()).toIso8601String(), // Favorites + groups (preserves grupo_id assignments per station). // The protected "sin asignar" group is implicit and never exported. @@ -88,6 +108,11 @@ class ServicioExportImport { envelope['eqMultiDeviceEnabled'] = eqMultiDeviceEnabled ?? false; } + // v4 extension: only written when explicitly provided. + if (tieneExtensionV4) { + envelope['ecualizadorActivo'] = ecualizadorActivo; + } + return envelope; } diff --git a/test/estado/estado_ecualizador_test.dart b/test/estado/estado_ecualizador_test.dart index 5823c60..069634d 100644 --- a/test/estado/estado_ecualizador_test.dart +++ b/test/estado/estado_ecualizador_test.dart @@ -1569,6 +1569,83 @@ void main() { }); }); + group( + 'EstadoEcualizador — importarConfiguracion(activo:) ' + '(equalizer on/off export/import gap)', + () { + test( + 'activo: false turns the equalizer off — persisted AND pushed to ' + 'the live audio engine (reuses cambiarActivo, not a bare field set)', + () async { + final servicio = FakeServicioEcualizador(activo: true); + final audio = FakeServicioAudio(); + final eq = EstadoEcualizador(audio: audio, servicio: servicio); + await eq.cargarPersistido(); + audio.cambiosEcualizadorActivo.clear(); + + await eq.importarConfiguracion( + principal: PresetEcualizador.flat, + porEmisora: {}, + activo: false, + ); + + expect(eq.activo, isFalse); + expect(audio.cambiosEcualizadorActivo, contains(false)); + expect(servicio.config.activo, isFalse); + expect(servicio.guardarActivoLlamadas, 1); + eq.dispose(); + }, + ); + + test( + 'activo: true turns the equalizer on — persisted AND pushed to ' + 'the live audio engine', + () async { + final servicio = FakeServicioEcualizador(activo: false); + final audio = FakeServicioAudio(); + final eq = EstadoEcualizador(audio: audio, servicio: servicio); + await eq.cargarPersistido(); + audio.cambiosEcualizadorActivo.clear(); + + await eq.importarConfiguracion( + principal: PresetEcualizador.flat, + porEmisora: {}, + activo: true, + ); + + expect(eq.activo, isTrue); + expect(audio.cambiosEcualizadorActivo, contains(true)); + expect(servicio.config.activo, isTrue); + expect(servicio.guardarActivoLlamadas, 1); + eq.dispose(); + }, + ); + + test( + 'activo: null (old backup, no field) leaves the current toggle ' + 'untouched and does not persist anything for it', + () async { + final servicio = FakeServicioEcualizador(activo: false); + final audio = FakeServicioAudio(); + final eq = EstadoEcualizador(audio: audio, servicio: servicio); + await eq.cargarPersistido(); + audio.cambiosEcualizadorActivo.clear(); + + await eq.importarConfiguracion( + principal: PresetEcualizador.flat, + porEmisora: {}, + // activo omitted — simulates a pre-v4 backup. + ); + + expect(eq.activo, isFalse); + expect(audio.cambiosEcualizadorActivo, isEmpty); + expect(servicio.guardarActivoLlamadas, 0); + eq.dispose(); + }, + ); + }, + ); + group('EstadoEcualizador — bonded Bluetooth names', () { const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF'; diff --git a/test/estado/estado_radio_export_import_eq_activo_test.dart b/test/estado/estado_radio_export_import_eq_activo_test.dart new file mode 100644 index 0000000..2a87572 --- /dev/null +++ b/test/estado/estado_radio_export_import_eq_activo_test.dart @@ -0,0 +1,171 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_radio.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../helpers/fakes.dart'; +import '../helpers/fakes_alarmas.dart'; + +/// Closes the last remaining export/import gap: `EstadoEcualizador._activo` +/// (the equalizer's global ON/OFF toggle) was not part of the backup +/// envelope at all, so restoring a backup on another device silently kept +/// whatever that device's toggle happened to be. These tests exercise the +/// flag end to end through `EstadoRadio.exportarConfig`/`importarConfig`, +/// the real call sites `pantalla_ajustes_backup.dart` uses. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + // A PRIVATE per-test file, never the shared `test/fixtures/` one — see + // `estado_alarmas_import_test.dart` for why: `importarConfig` + // unconditionally writes to whatever `resolverArchivoCustom` resolves to. + tempDir = await Directory.systemTemp.createTemp( + 'pluriwave_eq_activo_export_test', + ); + }); + + tearDown(() async { + if (tempDir.existsSync()) { + await tempDir.delete(recursive: true); + } + }); + + Future crearRadio({bool ecualizadorActivo = true}) async { + final prefs = await SharedPreferences.getInstance(); + final archivoCustom = File('${tempDir.path}/emisoras_custom.json'); + if (!archivoCustom.existsSync()) { + await archivoCustom.writeAsString('[]'); + } + final radio = EstadoRadio( + esPremium: () => true, + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(activo: ecualizadorActivo), + servicioGrabacion: FakeServicioGrabacionRadioInactiva(), + resolverArchivoCustom: () async => archivoCustom, + prefs: prefs, + iniciarAutomaticamente: false, + ); + await radio.ecualizador.cargarPersistido(); + return radio; + } + + Map backupBase({ + required int version, + bool? ecualizadorActivo, + }) { + final data = { + 'version': version, + 'gruposFavoritos': [], + 'favoritos': [], + 'emisorasCustom': [], + 'presetsEcualizador': {}, + 'alarmas': null, + 'emisoraPreferidaUuid': null, + 'ordenListas': 'nombre', + 'timerSuenoPresetsSegundos': [300, 600], + }; + if (ecualizadorActivo != null) { + data['ecualizadorActivo'] = ecualizadorActivo; + } + return data; + } + + group('EstadoRadio export/import — equalizer on/off toggle (v4)', () { + test('exportarConfig includes the flag when the equalizer is ON', () async { + final radio = await crearRadio(ecualizadorActivo: true); + addTearDown(radio.dispose); + + final exportado = await radio.exportarConfig(); + + expect(exportado['ecualizadorActivo'], isTrue); + expect(exportado['version'], 4); + }); + + test( + 'exportarConfig includes the flag when the equalizer is OFF', + () async { + final radio = await crearRadio(ecualizadorActivo: false); + addTearDown(radio.dispose); + + final exportado = await radio.exportarConfig(); + + expect(exportado['ecualizadorActivo'], isFalse); + expect(exportado['version'], 4); + }, + ); + + test('importarConfig(activo: false) turns the equalizer off — persisted ' + 'and reflected in EstadoEcualizador.activo', () async { + final radio = await crearRadio(ecualizadorActivo: true); + addTearDown(radio.dispose); + expect(radio.ecualizador.activo, isTrue); + + await radio.importarConfig( + backupBase(version: 4, ecualizadorActivo: false), + ); + + expect(radio.ecualizador.activo, isFalse); + }); + + test('importarConfig(activo: true) turns the equalizer on — persisted ' + 'and reflected in EstadoEcualizador.activo', () async { + final radio = await crearRadio(ecualizadorActivo: false); + addTearDown(radio.dispose); + expect(radio.ecualizador.activo, isFalse); + + await radio.importarConfig( + backupBase(version: 4, ecualizadorActivo: true), + ); + + expect(radio.ecualizador.activo, isTrue); + }); + + test('importing an OLD backup (no ecualizadorActivo field) does not throw ' + 'and leaves the current toggle untouched', () async { + final radio = await crearRadio(ecualizadorActivo: false); + addTearDown(radio.dispose); + expect(radio.ecualizador.activo, isFalse); + + await radio.importarConfig(backupBase(version: 2)); + + expect(radio.ecualizador.activo, isFalse); + }); + + test( + 'full round-trip: export -> import restores the equalizer toggle', + () async { + final origen = await crearRadio(ecualizadorActivo: false); + addTearDown(origen.dispose); + final exportado = await origen.exportarConfig(); + + final destino = await crearRadio(ecualizadorActivo: true); + addTearDown(destino.dispose); + expect(destino.ecualizador.activo, isTrue); + + await destino.importarConfig(exportado); + + expect(destino.ecualizador.activo, isFalse); + }, + ); + + test('regression: other fields still round-trip exactly as before ' + '(ordenListas, timerSuenoPresetsSegundos)', () async { + final origen = await crearRadio(); + addTearDown(origen.dispose); + await origen.guardarTimerSuenoPresetsSegundos([120, 900]); + final exportado = await origen.exportarConfig(); + + final destino = await crearRadio(); + addTearDown(destino.dispose); + await destino.importarConfig(exportado); + + expect(destino.ordenListas.name, origen.ordenListas.name); + }); + }); +} diff --git a/test/helpers/fakes.dart b/test/helpers/fakes.dart index 6f29b5d..cd39ed6 100644 --- a/test/helpers/fakes.dart +++ b/test/helpers/fakes.dart @@ -414,6 +414,17 @@ class FakeServicioEcualizador extends ServicioEcualizador { ); } + /// Overridden like every other write below: without this, calls fall + /// through to the real `ServicioEcualizador.guardarConfiguracion`, which + /// hits real SharedPreferences and needs a Flutter test binding — a + /// footgun for any test that reaches `EstadoEcualizador.importarConfiguracion` + /// (the backup-import path) through this Fake without also wiring + /// `TestWidgetsFlutterBinding`/mock prefs. + @override + Future guardarConfiguracion(ConfiguracionEcualizador config) async { + _config = config; + } + @override Future guardarPorEmisora(String uuid, PresetEcualizador preset) async { final mapa = Map.from(_config.porEmisora); diff --git a/test/servicios/servicio_export_import_test.dart b/test/servicios/servicio_export_import_test.dart index 2dcb2c2..b0f886d 100644 --- a/test/servicios/servicio_export_import_test.dart +++ b/test/servicios/servicio_export_import_test.dart @@ -232,4 +232,107 @@ void main() { }, ); }); + + group('ServicioExportImport v4 — equalizer on/off toggle', () { + test('v4 export includes ecualizadorActivo=true and schemaVersion 4', () { + final config = servicio.construirExportacion( + gruposFavoritos: gruposFavoritosFixture, + favoritos: favoritosFixture, + emisorasCustom: emisorasCustomFixture, + presetPrincipal: PresetEcualizador.flat, + presetsPorEmisora: {}, + alarmas: null, + emisoraPreferidaUuid: null, + ordenListas: 'calidad', + timerSuenoPresetsSegundos: const [300], + eqMultiDeviceEnabled: false, + ecualizadorActivo: true, + ); + + expect(config['version'], 4); + expect(config['ecualizadorActivo'], isTrue); + }); + + test('v4 export includes ecualizadorActivo=false and schemaVersion 4', () { + final config = servicio.construirExportacion( + gruposFavoritos: gruposFavoritosFixture, + favoritos: favoritosFixture, + emisorasCustom: emisorasCustomFixture, + presetPrincipal: PresetEcualizador.flat, + presetsPorEmisora: {}, + alarmas: null, + emisoraPreferidaUuid: null, + ordenListas: 'calidad', + timerSuenoPresetsSegundos: const [300], + eqMultiDeviceEnabled: false, + ecualizadorActivo: false, + ); + + expect(config['version'], 4); + expect(config['ecualizadorActivo'], isFalse); + }); + + test('v4 round-trip preserves the equalizer toggle exactly', () { + final config = servicio.construirExportacion( + gruposFavoritos: gruposFavoritosFixture, + favoritos: favoritosFixture, + emisorasCustom: emisorasCustomFixture, + presetPrincipal: PresetEcualizador.jazz, + presetsPorEmisora: {'fav-1': PresetEcualizador.rock}, + alarmas: alarmasFixture, + emisoraPreferidaUuid: 'fav-1', + ordenListas: 'calidad', + timerSuenoPresetsSegundos: const [300, 1800], + eqMultiDeviceEnabled: true, + ecualizadorActivo: false, + ); + + final json = servicio.exportar(config); + final importado = servicio.importar(json); + + expect(importado, isNotNull); + expect(importado!['version'], 4); + expect(importado['ecualizadorActivo'], isFalse); + }); + + test('omitting ecualizadorActivo keeps the export at v3 (backward compat) ' + 'and no key is written', () { + final config = servicio.construirExportacion( + gruposFavoritos: gruposFavoritosFixture, + favoritos: favoritosFixture, + emisorasCustom: emisorasCustomFixture, + presetPrincipal: PresetEcualizador.flat, + presetsPorEmisora: {}, + alarmas: null, + emisoraPreferidaUuid: null, + ordenListas: 'calidad', + timerSuenoPresetsSegundos: const [300], + eqMultiDeviceEnabled: true, + // ecualizadorActivo omitted — emulates a v3 export. + ); + + expect(config['version'], 3); + expect(config.containsKey('ecualizadorActivo'), isFalse); + }); + + test('omitting every v3/v4 extension keeps the export at v2 (backward ' + 'compat)', () { + final config = servicio.construirExportacion( + gruposFavoritos: gruposFavoritosFixture, + favoritos: favoritosFixture, + emisorasCustom: emisorasCustomFixture, + presetPrincipal: PresetEcualizador.flat, + presetsPorEmisora: {}, + alarmas: null, + emisoraPreferidaUuid: null, + ordenListas: 'calidad', + timerSuenoPresetsSegundos: const [300], + // No v3/v4 extensions at all. + ); + + expect(config['version'], 2); + expect(config.containsKey('ecualizadorActivo'), isFalse); + expect(config.containsKey('eqMultiDeviceEnabled'), isFalse); + }); + }); }