merge: alarm-import recovery, dismissible paywall and complete config export
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Failing after 2m41s

Three fixes that all came out of on-device testing:
- e57f7bb restores alarms after a backup import. The import wrote the JSON
  to prefs but EstadoAlarmas never re-read it, so imported alarms were
  invisible, were overwritten by the next edit, and were never scheduled
  natively — they would not have rung.
- a2bed18 makes the premium sheet dismissible (close button, 'not now',
  back gesture) and replaces the bare title with the five concrete things
  the purchase unlocks. A purchase sheet with no way out is a Play policy
  risk, not just bad UX.
- 4ca2813 closes the last export gap: the equalizer on/off toggle now
  travels with the backup (schema v4, additive; an old backup without the
  field leaves the current toggle untouched).
This commit is contained in:
2026-08-28 23:35:37 +02:00
39 changed files with 1489 additions and 31 deletions
+14
View File
@@ -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<void> importarConfiguracion({
required PresetEcualizador principal,
required Map<String, PresetEcualizador> porEmisora,
Map<String, PresetEcualizador>? presetsDispositivo,
Map<String, PresetEcualizador>? 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();
}
+23 -6
View File
@@ -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<Map<String, dynamic>> 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<void> importarConfig(Map<String, dynamic> 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<String, dynamic>) {
// 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));
}
}
+9 -1
View File
@@ -904,5 +904,13 @@
"restaurarCompras": "استعادة المشتريات",
"compraError": "تعذّر إتمام عملية الشراء. حاول مرة أخرى.",
"restauracionSinCompras": "لم نجد أي عملية شراء سابقة في هذا الحساب.",
"premiumActivo": "النسخة المميزة مفعّلة"
"premiumActivo": "النسخة المميزة مفعّلة",
"premiumHojaTitulo": "افتح PluriWave Premium",
"premiumBeneficioSinAnuncios": "بدون إعلانات في التطبيق بالكامل",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "تسجيل المحطات",
"premiumBeneficioVacaciones": "فترات إجازة للمنبهات",
"premiumBeneficioAlarmasIlimitadas": "منبهات غير محدودة (تسمح الخطة المجانية بحتى 5)",
"premiumPagoUnico": "دفعة واحدة، للأبد. ليس اشتراكًا.",
"premiumAhoraNo": "ليس الآن"
}
+9 -1
View File
@@ -904,5 +904,13 @@
"restaurarCompras": "কেনাকাটা পুনরুদ্ধার করুন",
"compraError": "কেনাকাটা সম্পূর্ণ করা যায়নি। আবার চেষ্টা করুন।",
"restauracionSinCompras": "এই অ্যাকাউন্টে আমরা আগের কোনো কেনাকাটা খুঁজে পাইনি।",
"premiumActivo": "প্রিমিয়াম সক্রিয়"
"premiumActivo": "প্রিমিয়াম সক্রিয়",
"premiumHojaTitulo": "PluriWave Premium আনলক করুন",
"premiumBeneficioSinAnuncios": "পুরো অ্যাপে কোনো বিজ্ঞাপন নেই",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "স্টেশন রেকর্ডিং",
"premiumBeneficioVacaciones": "অ্যালার্মের জন্য ছুটির সময়কাল",
"premiumBeneficioAlarmasIlimitadas": "সীমাহীন অ্যালার্ম (ফ্রি প্ল্যানে সর্বোচ্চ ৫টি অনুমোদিত)",
"premiumPagoUnico": "একবারের পেমেন্ট, চিরকালের জন্য। এটি সাবস্ক্রিপশন নয়।",
"premiumAhoraNo": "এখন নয়"
}
+9 -1
View File
@@ -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"
}
+9 -1
View File
@@ -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"
}
+9 -1
View File
@@ -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"
}
+9 -1
View File
@@ -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"
}
+9 -1
View File
@@ -904,5 +904,13 @@
"restaurarCompras": "खरीदारी पुनर्स्थापित करें",
"compraError": "खरीद पूरी नहीं हो सकी। कृपया फिर से प्रयास करें।",
"restauracionSinCompras": "इस खाते में हमें कोई पिछली खरीद नहीं मिली।",
"premiumActivo": "प्रीमियम सक्रिय"
"premiumActivo": "प्रीमियम सक्रिय",
"premiumHojaTitulo": "PluriWave Premium अनलॉक करें",
"premiumBeneficioSinAnuncios": "पूरे ऐप में कोई विज्ञापन नहीं",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "स्टेशन रिकॉर्डिंग",
"premiumBeneficioVacaciones": "अलार्म के लिए छुट्टी की अवधि",
"premiumBeneficioAlarmasIlimitadas": "असीमित अलार्म (मुफ़्त प्लान में अधिकतम 5 की अनुमति है)",
"premiumPagoUnico": "एकमुश्त भुगतान, हमेशा के लिए। यह सदस्यता नहीं है।",
"premiumAhoraNo": "अभी नहीं"
}
+9 -1
View File
@@ -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"
}
+9 -1
View File
@@ -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"
}
+9 -1
View File
@@ -904,5 +904,13 @@
"restaurarCompras": "購入を復元",
"compraError": "購入を完了できませんでした。もう一度お試しください。",
"restauracionSinCompras": "このアカウントでは以前の購入が見つかりませんでした。",
"premiumActivo": "プレミアム有効"
"premiumActivo": "プレミアム有効",
"premiumHojaTitulo": "PluriWave Premiumのロックを解除",
"premiumBeneficioSinAnuncios": "アプリ全体で広告なし",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "放送局の録音",
"premiumBeneficioVacaciones": "アラームの休暇期間設定",
"premiumBeneficioAlarmasIlimitadas": "アラーム数無制限(無料プランは5個まで)",
"premiumPagoUnico": "買い切りの一度きりの支払いで永久に使用可能。サブスクリプションではありません。",
"premiumAhoraNo": "後で"
}
+9 -1
View File
@@ -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"
}
+9 -1
View File
@@ -904,5 +904,13 @@
"restaurarCompras": "Восстановить покупки",
"compraError": "Не удалось завершить покупку. Попробуйте ещё раз.",
"restauracionSinCompras": "Мы не нашли предыдущих покупок на этом аккаунте.",
"premiumActivo": "Премиум активен"
"premiumActivo": "Премиум активен",
"premiumHojaTitulo": "Разблокировать PluriWave Premium",
"premiumBeneficioSinAnuncios": "Никакой рекламы во всём приложении",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "Запись радиостанций",
"premiumBeneficioVacaciones": "Периоды отпуска для будильников",
"premiumBeneficioAlarmasIlimitadas": "Неограниченное количество будильников (бесплатный план позволяет до 5)",
"premiumPagoUnico": "Единоразовая покупка, навсегда. Это не подписка.",
"premiumAhoraNo": "Не сейчас"
}
+9 -1
View File
@@ -904,5 +904,13 @@
"restaurarCompras": "恢复购买",
"compraError": "无法完成购买,请重试。",
"restauracionSinCompras": "未在此账户中找到以前的购买记录。",
"premiumActivo": "高级版已解锁"
"premiumActivo": "高级版已解锁",
"premiumHojaTitulo": "解锁 PluriWave Premium",
"premiumBeneficioSinAnuncios": "全应用无广告",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "电台录音",
"premiumBeneficioVacaciones": "闹钟的假期时间段",
"premiumBeneficioAlarmasIlimitadas": "无限闹钟(免费版最多支持5个)",
"premiumPagoUnico": "一次性付费,永久使用,不是订阅。",
"premiumAhoraNo": "以后再说"
}
+48
View File
@@ -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
+25
View File
@@ -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 => 'ليس الآن';
}
+26
View File
@@ -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 => 'এখন নয়';
}
+25
View File
@@ -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';
}
+26
View File
@@ -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';
}
+27
View File
@@ -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';
}
+28
View File
@@ -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';
}
+26
View File
@@ -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 => 'अभी नहीं';
}
+26
View File
@@ -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';
}
+28
View File
@@ -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';
}
+24
View File
@@ -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 => '後で';
}
+26
View File
@@ -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';
}
+27
View File
@@ -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 => 'Не сейчас';
}
+24
View File
@@ -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 => '以后再说';
}
@@ -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<void> aplicarImportacionConfig(
EstadoRadio estado,
EstadoAlarmas alarmas,
Map<String, dynamic> 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<EstadoRadio>();
final alarmas = context.read<EstadoAlarmas>();
final messenger = ScaffoldMessenger.of(context);
await estado.importarConfig(json);
await aplicarImportacionConfig(estado, alarmas, json);
messenger.showSnackBar(
SnackBar(content: Text(l10n.backupImportSuccess)),
);
+35 -10
View File
@@ -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 (v3multi-device EQ).
static const int versionActual = 3;
/// Current backup schema version (v4equalizer 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<String, PresetEcualizador>? presetsPorDispositivo,
Map<String, PresetEcualizador>? 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 = <String, dynamic>{
'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;
}
+69 -1
View File
@@ -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),
),
],
),
);
}
}
+284
View File
@@ -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<File> archivoCustomVacio() async {
final file = File('${tempDir.path}/emisoras_custom.json');
if (!file.existsSync()) {
await file.writeAsString('[]');
}
return file;
}
Map<String, dynamic> 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<String, dynamic> backupCon({
required List<AlarmaMusical> alarmas,
List<Map<String, dynamic>> vacaciones = const [],
List<Map<String, dynamic>> 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': <int>[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');
});
}
+77
View File
@@ -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';
@@ -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<EstadoRadio> 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<String, dynamic> backupBase({
required int version,
bool? ecualizadorActivo,
}) {
final data = <String, dynamic>{
'version': version,
'gruposFavoritos': [],
'favoritos': [],
'emisorasCustom': [],
'presetsEcualizador': {},
'alarmas': null,
'emisoraPreferidaUuid': null,
'ordenListas': 'nombre',
'timerSuenoPresetsSegundos': <int>[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);
});
});
}
+11
View File
@@ -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<void> guardarConfiguracion(ConfiguracionEcualizador config) async {
_config = config;
}
@override
Future<void> guardarPorEmisora(String uuid, PresetEcualizador preset) async {
final mapa = Map<String, PresetEcualizador>.from(_config.porEmisora);
+14
View File
@@ -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.
@@ -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);
});
});
}
+150
View File
@@ -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<void> bombearComoHoja(
WidgetTester tester, {
required _PuertoComprasFalso compras,
}) async {
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<EstadoEntitlement>(
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',
() {