Compare commits
10
Commits
524b8f0035
...
9681a47e83
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9681a47e83 | ||
|
|
4ca2813267 | ||
|
|
a2bed18937 | ||
|
|
e57f7bb17b | ||
|
|
fdddd95199 | ||
|
|
1bfd5a2348 | ||
|
|
4ea5d2056c | ||
|
|
b5940b2758 | ||
|
|
9efa6d8937 | ||
|
|
186ff45105 |
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -904,5 +904,13 @@
|
||||
"restaurarCompras": "استعادة المشتريات",
|
||||
"compraError": "تعذّر إتمام عملية الشراء. حاول مرة أخرى.",
|
||||
"restauracionSinCompras": "لم نجد أي عملية شراء سابقة في هذا الحساب.",
|
||||
"premiumActivo": "النسخة المميزة مفعّلة"
|
||||
"premiumActivo": "النسخة المميزة مفعّلة",
|
||||
"premiumHojaTitulo": "افتح PluriWave Premium",
|
||||
"premiumBeneficioSinAnuncios": "بدون إعلانات في التطبيق بالكامل",
|
||||
"premiumBeneficioAndroidAuto": "Android Auto",
|
||||
"premiumBeneficioGrabacion": "تسجيل المحطات",
|
||||
"premiumBeneficioVacaciones": "فترات إجازة للمنبهات",
|
||||
"premiumBeneficioAlarmasIlimitadas": "منبهات غير محدودة (تسمح الخطة المجانية بحتى 5)",
|
||||
"premiumPagoUnico": "دفعة واحدة، للأبد. ليس اشتراكًا.",
|
||||
"premiumAhoraNo": "ليس الآن"
|
||||
}
|
||||
|
||||
+9
-1
@@ -904,5 +904,13 @@
|
||||
"restaurarCompras": "কেনাকাটা পুনরুদ্ধার করুন",
|
||||
"compraError": "কেনাকাটা সম্পূর্ণ করা যায়নি। আবার চেষ্টা করুন।",
|
||||
"restauracionSinCompras": "এই অ্যাকাউন্টে আমরা আগের কোনো কেনাকাটা খুঁজে পাইনি।",
|
||||
"premiumActivo": "প্রিমিয়াম সক্রিয়"
|
||||
"premiumActivo": "প্রিমিয়াম সক্রিয়",
|
||||
"premiumHojaTitulo": "PluriWave Premium আনলক করুন",
|
||||
"premiumBeneficioSinAnuncios": "পুরো অ্যাপে কোনো বিজ্ঞাপন নেই",
|
||||
"premiumBeneficioAndroidAuto": "Android Auto",
|
||||
"premiumBeneficioGrabacion": "স্টেশন রেকর্ডিং",
|
||||
"premiumBeneficioVacaciones": "অ্যালার্মের জন্য ছুটির সময়কাল",
|
||||
"premiumBeneficioAlarmasIlimitadas": "সীমাহীন অ্যালার্ম (ফ্রি প্ল্যানে সর্বোচ্চ ৫টি অনুমোদিত)",
|
||||
"premiumPagoUnico": "একবারের পেমেন্ট, চিরকালের জন্য। এটি সাবস্ক্রিপশন নয়।",
|
||||
"premiumAhoraNo": "এখন নয়"
|
||||
}
|
||||
|
||||
+9
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -904,5 +904,13 @@
|
||||
"restaurarCompras": "खरीदारी पुनर्स्थापित करें",
|
||||
"compraError": "खरीद पूरी नहीं हो सकी। कृपया फिर से प्रयास करें।",
|
||||
"restauracionSinCompras": "इस खाते में हमें कोई पिछली खरीद नहीं मिली।",
|
||||
"premiumActivo": "प्रीमियम सक्रिय"
|
||||
"premiumActivo": "प्रीमियम सक्रिय",
|
||||
"premiumHojaTitulo": "PluriWave Premium अनलॉक करें",
|
||||
"premiumBeneficioSinAnuncios": "पूरे ऐप में कोई विज्ञापन नहीं",
|
||||
"premiumBeneficioAndroidAuto": "Android Auto",
|
||||
"premiumBeneficioGrabacion": "स्टेशन रिकॉर्डिंग",
|
||||
"premiumBeneficioVacaciones": "अलार्म के लिए छुट्टी की अवधि",
|
||||
"premiumBeneficioAlarmasIlimitadas": "असीमित अलार्म (मुफ़्त प्लान में अधिकतम 5 की अनुमति है)",
|
||||
"premiumPagoUnico": "एकमुश्त भुगतान, हमेशा के लिए। यह सदस्यता नहीं है।",
|
||||
"premiumAhoraNo": "अभी नहीं"
|
||||
}
|
||||
|
||||
+9
-1
@@ -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
@@ -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
@@ -904,5 +904,13 @@
|
||||
"restaurarCompras": "購入を復元",
|
||||
"compraError": "購入を完了できませんでした。もう一度お試しください。",
|
||||
"restauracionSinCompras": "このアカウントでは以前の購入が見つかりませんでした。",
|
||||
"premiumActivo": "プレミアム有効"
|
||||
"premiumActivo": "プレミアム有効",
|
||||
"premiumHojaTitulo": "PluriWave Premiumのロックを解除",
|
||||
"premiumBeneficioSinAnuncios": "アプリ全体で広告なし",
|
||||
"premiumBeneficioAndroidAuto": "Android Auto",
|
||||
"premiumBeneficioGrabacion": "放送局の録音",
|
||||
"premiumBeneficioVacaciones": "アラームの休暇期間設定",
|
||||
"premiumBeneficioAlarmasIlimitadas": "アラーム数無制限(無料プランは5個まで)",
|
||||
"premiumPagoUnico": "買い切りの一度きりの支払いで永久に使用可能。サブスクリプションではありません。",
|
||||
"premiumAhoraNo": "後で"
|
||||
}
|
||||
|
||||
+9
-1
@@ -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
@@ -904,5 +904,13 @@
|
||||
"restaurarCompras": "Восстановить покупки",
|
||||
"compraError": "Не удалось завершить покупку. Попробуйте ещё раз.",
|
||||
"restauracionSinCompras": "Мы не нашли предыдущих покупок на этом аккаунте.",
|
||||
"premiumActivo": "Премиум активен"
|
||||
"premiumActivo": "Премиум активен",
|
||||
"premiumHojaTitulo": "Разблокировать PluriWave Premium",
|
||||
"premiumBeneficioSinAnuncios": "Никакой рекламы во всём приложении",
|
||||
"premiumBeneficioAndroidAuto": "Android Auto",
|
||||
"premiumBeneficioGrabacion": "Запись радиостанций",
|
||||
"premiumBeneficioVacaciones": "Периоды отпуска для будильников",
|
||||
"premiumBeneficioAlarmasIlimitadas": "Неограниченное количество будильников (бесплатный план позволяет до 5)",
|
||||
"premiumPagoUnico": "Единоразовая покупка, навсегда. Это не подписка.",
|
||||
"premiumAhoraNo": "Не сейчас"
|
||||
}
|
||||
|
||||
+9
-1
@@ -904,5 +904,13 @@
|
||||
"restaurarCompras": "恢复购买",
|
||||
"compraError": "无法完成购买,请重试。",
|
||||
"restauracionSinCompras": "未在此账户中找到以前的购买记录。",
|
||||
"premiumActivo": "高级版已解锁"
|
||||
"premiumActivo": "高级版已解锁",
|
||||
"premiumHojaTitulo": "解锁 PluriWave Premium",
|
||||
"premiumBeneficioSinAnuncios": "全应用无广告",
|
||||
"premiumBeneficioAndroidAuto": "Android Auto",
|
||||
"premiumBeneficioGrabacion": "电台录音",
|
||||
"premiumBeneficioVacaciones": "闹钟的假期时间段",
|
||||
"premiumBeneficioAlarmasIlimitadas": "无限闹钟(免费版最多支持5个)",
|
||||
"premiumPagoUnico": "一次性付费,永久使用,不是订阅。",
|
||||
"premiumAhoraNo": "以后再说"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 => 'ليس الآن';
|
||||
}
|
||||
|
||||
@@ -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 => 'এখন নয়';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 => 'अभी नहीं';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 => '後で';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 => 'Не сейчас';
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
|
||||
@@ -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<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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
() {
|
||||
|
||||
Reference in New Issue
Block a user