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 9272961..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) ────────────────────────────────────────────────────── @@ -945,7 +957,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/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/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/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/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/pubspec.yaml b/pubspec.yaml index 0db4e1f..a7bc836 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+154 +version: 1.3.0+155 environment: sdk: ^3.7.0 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'); + }); +} 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/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/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); }); }); 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); + }); + }); } 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', () {