diff --git a/lib/app.dart b/lib/app.dart index c4141ca..b9fa4bd 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -35,6 +35,30 @@ import 'servicios/navegacion_auto.dart'; import 'servicios/servicio_alarmas_android.dart'; import 'servicios/servicio_dispositivo_audio.dart'; +/// Extracted out of `_PaginaPrincipalState.build` (FIX 1, code review) so +/// the banner + status-bar-inset composition is unit-testable in isolation +/// — `_PaginaPrincipal` itself is library-private and constructs real +/// platform-backed services (see `app_test.dart`'s own comments), so it +/// cannot be safely widget-tested directly. Mirrors this file's existing +/// `@visibleForTesting` top-level extraction convention +/// (`main.dart`'s `orientacionesPara`/`aplicarPoliticaOrientacion`). +/// +/// `BannerAnuncioSuperior` owns its OWN top `SafeArea` internally now (see +/// `banner_anuncio_superior.dart`) — this function deliberately does NOT +/// wrap it in one, since `SafeArea` reserves `MediaQuery.padding.top` even +/// around a zero-size collapsed child, which used to leave a permanent +/// blank status-bar-height strip for premium users and for free users +/// before the first ad finished loading. +@visibleForTesting +Widget construirCuerpoPrincipal({required Widget contenido}) { + return Column( + children: [ + const BannerAnuncioSuperior(), + Expanded(child: SafeArea(top: false, child: contenido)), + ], + ); +} + class PluriWaveApp extends StatelessWidget { const PluriWaveApp({super.key, this.prefs, this.fuenteAuto, this.compras}); @@ -256,36 +280,32 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> // (design.md ADR-6): a `Column` sibling, NEVER a `Stack`/overlay — the // banner RESERVES its own space above the existing body instead of // covering any of it. `BannerAnuncioSuperior` itself collapses to - // `SizedBox.shrink()` (zero layout impact) for premium/unloaded. - body: Column( - children: [ - const SafeArea(bottom: false, child: BannerAnuncioSuperior()), - Expanded( - child: SafeArea( - top: false, - child: AnimatedSwitcher( - duration: context.pluriMotion.normal, - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, - transitionBuilder: - (child, animation) => FadeTransition( - opacity: animation, - child: SlideTransition( - position: Tween( - begin: const Offset(0.035, 0), - end: Offset.zero, - ).animate(animation), - child: child, - ), - ), - child: KeyedSubtree( - key: ValueKey(indice), - child: _paginas[indice], + // `SizedBox.shrink()` (zero layout impact) for premium/unloaded, and + // (FIX 1, code review) owns its OWN top `SafeArea` internally — this + // level no longer wraps it in an unconditional `SafeArea`, which used + // to reserve `MediaQuery.padding.top` even for a zero-size collapsed + // child, leaving a permanent blank status-bar-height strip. + body: construirCuerpoPrincipal( + contenido: AnimatedSwitcher( + duration: context.pluriMotion.normal, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: + (child, animation) => FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween( + begin: const Offset(0.035, 0), + end: Offset.zero, + ).animate(animation), + child: child, ), ), - ), + child: KeyedSubtree( + key: ValueKey(indice), + child: _paginas[indice], ), - ], + ), ), bottomNavigationBar: SafeArea( top: false, diff --git a/lib/estado/estado_entitlement.dart b/lib/estado/estado_entitlement.dart index 7f2e043..cc1e804 100644 --- a/lib/estado/estado_entitlement.dart +++ b/lib/estado/estado_entitlement.dart @@ -30,6 +30,26 @@ Future esPremiumPersistido({SharedPreferences? prefs}) async { return resueltas.getBool(_keyPremium) ?? false; } +/// User-facing, non-error-text outcomes [EstadoEntitlement] can expose (FIX +/// 3, code review): the UI layer (`hoja_premium.dart`) has no BuildContext +/// here, so this file never carries localized/user-facing STRINGS itself — +/// only this typed signal, mapped to a localized message by the widget. +/// Cleared back to `null` once consumed ([EstadoEntitlement.consumirResultadoUsuario]). +enum ResultadoEntitlementUsuario { + /// A purchase or restore attempt failed (network, billing error, product + /// not yet available in the store, etc). This NEVER carries the raw + /// exception/developer string from [EventoCompra.mensaje] — the UI maps + /// this enum value to ONE generic localized message, never the internal + /// diagnostic text. + error, + + /// [EstadoEntitlement.restaurar] completed successfully but found nothing + /// to restore. Distinct from [error]: an expected, non-error outcome + /// (Spec "Restore Purchases" — "finds nothing -> stays free tier with a + /// clear non-error result"). + restauracionSinCompras, +} + /// Cross-cutting entitlement notifier (Design ADR-1), idiomatic /// `EstadoIdioma`-shaped `ChangeNotifier`: UI layers `context.watch`/`read` /// this; headless callers (Android Auto) use [esPremiumPersistido] instead, @@ -56,10 +76,24 @@ class EstadoEntitlement extends ChangeNotifier { bool _esPremium = false; bool _compraEnCurso = false; + ResultadoEntitlementUsuario? _resultadoUsuario; bool get esPremium => _esPremium; bool get compraEnCurso => _compraEnCurso; + /// FIX 3 (code review): the user-facing signal for a failed purchase/ + /// restore, or a restore that found nothing. `null` when there is nothing + /// to show — see [consumirResultadoUsuario]. + ResultadoEntitlementUsuario? get resultadoUsuario => _resultadoUsuario; + + /// Clears [resultadoUsuario] once the UI has consumed/displayed it. + /// A no-op (no extra notification) if there is nothing to clear. + void consumirResultadoUsuario() { + if (_resultadoUsuario == null) return; + _resultadoUsuario = null; + notifyListeners(); + } + Future _cargar() async { final prefs = await _resolverPrefs(); final premium = prefs.getBool(_keyPremium) ?? false; @@ -80,6 +114,10 @@ class EstadoEntitlement extends ChangeNotifier { final compras = _compras; if (compras == null) return; _compraEnCurso = true; + // FIX 3 (code review): a fresh attempt clears any stale result left over + // from a previous failed attempt, so the UI never shows an outdated + // error/confirmation across two unrelated attempts. + _resultadoUsuario = null; notifyListeners(); await compras.comprar(); } @@ -89,6 +127,7 @@ class EstadoEntitlement extends ChangeNotifier { final compras = _compras; if (compras == null) return; _compraEnCurso = true; + _resultadoUsuario = null; notifyListeners(); await compras.restaurar(); } @@ -99,17 +138,31 @@ class EstadoEntitlement extends ChangeNotifier { case TipoEventoCompra.restaurada: await _desbloquear(); case TipoEventoCompra.cancelada: - case TipoEventoCompra.noEncontrada: - // Spec "Purchase cancelled or failed" / "Restore finds nothing": - // stays free tier, no error surfaced — just stop the in-flight - // spinner. + // Spec "Purchase cancelled or failed": a user-INITIATED cancel + // stays free tier with no error surfaced — just stop the in-flight + // spinner. Not a failure, so no [resultadoUsuario] either. _compraEnCurso = false; notifyListeners(); + case TipoEventoCompra.noEncontrada: + // FIX 3 (code review): "Restore finds nothing" is an expected, + // NON-error outcome (Spec "Restore Purchases") but `hoja_premium.dart` + // had zero feedback for it — the spinner just stopped with no + // confirmation. Distinct signal from [TipoEventoCompra.error]. + _compraEnCurso = false; + _resultadoUsuario = ResultadoEntitlementUsuario.restauracionSinCompras; + notifyListeners(); case TipoEventoCompra.error: // Fail-open (Design ADR-2): an error NEVER writes `false` over an // already-premium flag, and never invents a `true` for a free user // either — the persisted flag from `_cargar()` is left untouched. + // + // FIX 3 (code review): [EventoCompra.mensaje] (raw exception/ + // developer text, e.g. "Producto no encontrado en Play Console") is + // DELIBERATELY discarded here — only the typed enum crosses into + // [resultadoUsuario], never the raw string. `hoja_premium.dart` maps + // it to ONE generic localized message. _compraEnCurso = false; + _resultadoUsuario = ResultadoEntitlementUsuario.error; notifyListeners(); case TipoEventoCompra.pendiente: _compraEnCurso = true; diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index c7205d2..47e952b 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -901,5 +901,8 @@ "funcionPremium": "ميزة مميزة", "limiteAlarmasAlcanzado": "لقد وصلت إلى الحد المجاني وهو 5 منبهات.", "desbloquearPremium": "فتح النسخة المميزة", - "restaurarCompras": "استعادة المشتريات" + "restaurarCompras": "استعادة المشتريات", + "compraError": "تعذّر إتمام عملية الشراء. حاول مرة أخرى.", + "restauracionSinCompras": "لم نجد أي عملية شراء سابقة في هذا الحساب.", + "premiumActivo": "النسخة المميزة مفعّلة" } diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index fdfd695..ac9312a 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -901,5 +901,8 @@ "funcionPremium": "প্রিমিয়াম বৈশিষ্ট্য", "limiteAlarmasAlcanzado": "আপনি বিনামূল্যে ৫টি অ্যালার্মের সীমায় পৌঁছেছেন।", "desbloquearPremium": "প্রিমিয়াম আনলক করুন", - "restaurarCompras": "কেনাকাটা পুনরুদ্ধার করুন" + "restaurarCompras": "কেনাকাটা পুনরুদ্ধার করুন", + "compraError": "কেনাকাটা সম্পূর্ণ করা যায়নি। আবার চেষ্টা করুন।", + "restauracionSinCompras": "এই অ্যাকাউন্টে আমরা আগের কোনো কেনাকাটা খুঁজে পাইনি।", + "premiumActivo": "প্রিমিয়াম সক্রিয়" } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index b163332..55432d7 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -901,5 +901,8 @@ "funcionPremium": "Premium-Funktion", "limiteAlarmasAlcanzado": "Du hast das kostenlose Limit von 5 Weckern erreicht.", "desbloquearPremium": "Premium freischalten", - "restaurarCompras": "Käufe wiederherstellen" + "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" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index cd62474..19f7e7d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -901,5 +901,8 @@ "funcionPremium": "Premium Feature", "limiteAlarmasAlcanzado": "You've reached the free 5-alarm limit.", "desbloquearPremium": "Unlock Premium", - "restaurarCompras": "Restore purchases" + "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" } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 68a946d..b9805cf 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -860,5 +860,8 @@ "funcionPremium": "Función Premium", "limiteAlarmasAlcanzado": "Has alcanzado el límite de 5 alarmas gratuitas.", "desbloquearPremium": "Desbloquear Premium", - "restaurarCompras": "Restaurar compras" + "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" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 20c096d..0350120 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -901,5 +901,8 @@ "funcionPremium": "Fonctionnalité Premium", "limiteAlarmasAlcanzado": "Vous avez atteint la limite gratuite de 5 alarmes.", "desbloquearPremium": "Débloquer Premium", - "restaurarCompras": "Restaurer les achats" + "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" } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index f012e90..022c0ca 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -901,5 +901,8 @@ "funcionPremium": "प्रीमियम सुविधा", "limiteAlarmasAlcanzado": "आप मुफ़्त 5 अलार्म की सीमा तक पहुँच गए हैं।", "desbloquearPremium": "प्रीमियम अनलॉक करें", - "restaurarCompras": "खरीदारी पुनर्स्थापित करें" + "restaurarCompras": "खरीदारी पुनर्स्थापित करें", + "compraError": "खरीद पूरी नहीं हो सकी। कृपया फिर से प्रयास करें।", + "restauracionSinCompras": "इस खाते में हमें कोई पिछली खरीद नहीं मिली।", + "premiumActivo": "प्रीमियम सक्रिय" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 5249e3e..0cd5ee3 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -901,5 +901,8 @@ "funcionPremium": "Fitur Premium", "limiteAlarmasAlcanzado": "Anda telah mencapai batas gratis 5 alarm.", "desbloquearPremium": "Buka Premium", - "restaurarCompras": "Pulihkan pembelian" + "restaurarCompras": "Pulihkan pembelian", + "compraError": "Pembelian tidak dapat diselesaikan. Silakan coba lagi.", + "restauracionSinCompras": "Kami tidak menemukan pembelian sebelumnya di akun ini.", + "premiumActivo": "Premium aktif" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index c39c322..e71c341 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -901,5 +901,8 @@ "funcionPremium": "Funzione Premium", "limiteAlarmasAlcanzado": "Hai raggiunto il limite gratuito di 5 sveglie.", "desbloquearPremium": "Sblocca Premium", - "restaurarCompras": "Ripristina acquisti" + "restaurarCompras": "Ripristina acquisti", + "compraError": "Non è stato possibile completare l'acquisto. Riprova.", + "restauracionSinCompras": "Non abbiamo trovato acquisti precedenti su questo account.", + "premiumActivo": "Premium attivo" } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 8e11f79..da8fa0e 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -901,5 +901,8 @@ "funcionPremium": "プレミアム機能", "limiteAlarmasAlcanzado": "無料プランのアラーム上限(5件)に達しました。", "desbloquearPremium": "プレミアムを解除", - "restaurarCompras": "購入を復元" + "restaurarCompras": "購入を復元", + "compraError": "購入を完了できませんでした。もう一度お試しください。", + "restauracionSinCompras": "このアカウントでは以前の購入が見つかりませんでした。", + "premiumActivo": "プレミアム有効" } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index dc656fd..df0a5b3 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -901,5 +901,8 @@ "funcionPremium": "Recurso Premium", "limiteAlarmasAlcanzado": "Você atingiu o limite gratuito de 5 alarmes.", "desbloquearPremium": "Desbloquear Premium", - "restaurarCompras": "Restaurar compras" + "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" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 97853b3..f11ede4 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -901,5 +901,8 @@ "funcionPremium": "Премиум-функция", "limiteAlarmasAlcanzado": "Вы достигли бесплатного лимита в 5 будильников.", "desbloquearPremium": "Разблокировать Премиум", - "restaurarCompras": "Восстановить покупки" + "restaurarCompras": "Восстановить покупки", + "compraError": "Не удалось завершить покупку. Попробуйте ещё раз.", + "restauracionSinCompras": "Мы не нашли предыдущих покупок на этом аккаунте.", + "premiumActivo": "Премиум активен" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index a616371..2d48b57 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -901,5 +901,8 @@ "funcionPremium": "高级功能", "limiteAlarmasAlcanzado": "您已达到免费版 5 个闹钟的上限。", "desbloquearPremium": "解锁高级版", - "restaurarCompras": "恢复购买" + "restaurarCompras": "恢复购买", + "compraError": "无法完成购买,请重试。", + "restauracionSinCompras": "未在此账户中找到以前的购买记录。", + "premiumActivo": "高级版已解锁" } diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 7a58b48..6be60e2 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -3349,6 +3349,24 @@ abstract class AppLocalizations { /// In es, this message translates to: /// **'Restaurar compras'** String get restaurarCompras; + + /// No description provided for @compraError. + /// + /// In es, this message translates to: + /// **'No se ha podido completar la compra. Inténtalo de nuevo.'** + String get compraError; + + /// No description provided for @restauracionSinCompras. + /// + /// In es, this message translates to: + /// **'No hemos encontrado ninguna compra anterior en esta cuenta.'** + String get restauracionSinCompras; + + /// No description provided for @premiumActivo. + /// + /// In es, this message translates to: + /// **'Premium activo'** + String get premiumActivo; } class _AppLocalizationsDelegate diff --git a/lib/l10n/gen/app_localizations_ar.dart b/lib/l10n/gen/app_localizations_ar.dart index e25ec25..e2b0952 100644 --- a/lib/l10n/gen/app_localizations_ar.dart +++ b/lib/l10n/gen/app_localizations_ar.dart @@ -1853,4 +1853,14 @@ class AppLocalizationsAr extends AppLocalizations { @override String get restaurarCompras => 'استعادة المشتريات'; + + @override + String get compraError => 'تعذّر إتمام عملية الشراء. حاول مرة أخرى.'; + + @override + String get restauracionSinCompras => + 'لم نجد أي عملية شراء سابقة في هذا الحساب.'; + + @override + String get premiumActivo => 'النسخة المميزة مفعّلة'; } diff --git a/lib/l10n/gen/app_localizations_bn.dart b/lib/l10n/gen/app_localizations_bn.dart index 1384b8a..dffb7b5 100644 --- a/lib/l10n/gen/app_localizations_bn.dart +++ b/lib/l10n/gen/app_localizations_bn.dart @@ -1864,4 +1864,14 @@ class AppLocalizationsBn extends AppLocalizations { @override String get restaurarCompras => 'কেনাকাটা পুনরুদ্ধার করুন'; + + @override + String get compraError => 'কেনাকাটা সম্পূর্ণ করা যায়নি। আবার চেষ্টা করুন।'; + + @override + String get restauracionSinCompras => + 'এই অ্যাকাউন্টে আমরা আগের কোনো কেনাকাটা খুঁজে পাইনি।'; + + @override + String get premiumActivo => 'প্রিমিয়াম সক্রিয়'; } diff --git a/lib/l10n/gen/app_localizations_de.dart b/lib/l10n/gen/app_localizations_de.dart index 0343106..771a63d 100644 --- a/lib/l10n/gen/app_localizations_de.dart +++ b/lib/l10n/gen/app_localizations_de.dart @@ -1877,4 +1877,15 @@ class AppLocalizationsDe extends AppLocalizations { @override String get restaurarCompras => 'Käufe wiederherstellen'; + + @override + String get compraError => + 'Der Kauf konnte nicht abgeschlossen werden. Bitte versuche es erneut.'; + + @override + String get restauracionSinCompras => + 'Wir haben auf diesem Konto keinen früheren Kauf gefunden.'; + + @override + String get premiumActivo => 'Premium aktiv'; } diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index ffb473a..e0e278c 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -1856,4 +1856,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get restaurarCompras => 'Restore purchases'; + + @override + String get compraError => + 'We couldn\'t complete the purchase. Please try again.'; + + @override + String get restauracionSinCompras => + 'We didn\'t find any previous purchase on this account.'; + + @override + String get premiumActivo => 'Premium active'; } diff --git a/lib/l10n/gen/app_localizations_es.dart b/lib/l10n/gen/app_localizations_es.dart index 92a7822..81b57e5 100644 --- a/lib/l10n/gen/app_localizations_es.dart +++ b/lib/l10n/gen/app_localizations_es.dart @@ -1870,4 +1870,15 @@ class AppLocalizationsEs extends AppLocalizations { @override String get restaurarCompras => 'Restaurar compras'; + + @override + String get compraError => + 'No se ha podido completar la compra. Inténtalo de nuevo.'; + + @override + String get restauracionSinCompras => + 'No hemos encontrado ninguna compra anterior en esta cuenta.'; + + @override + String get premiumActivo => 'Premium activo'; } diff --git a/lib/l10n/gen/app_localizations_fr.dart b/lib/l10n/gen/app_localizations_fr.dart index fbb3fda..3bcfd4c 100644 --- a/lib/l10n/gen/app_localizations_fr.dart +++ b/lib/l10n/gen/app_localizations_fr.dart @@ -1883,4 +1883,15 @@ class AppLocalizationsFr extends AppLocalizations { @override String get restaurarCompras => 'Restaurer les achats'; + + @override + String get compraError => + 'Impossible de finaliser l\'achat. Veuillez réessayer.'; + + @override + String get restauracionSinCompras => + 'Nous n\'avons trouvé aucun achat antérieur sur ce compte.'; + + @override + String get premiumActivo => 'Premium actif'; } diff --git a/lib/l10n/gen/app_localizations_hi.dart b/lib/l10n/gen/app_localizations_hi.dart index 3a99156..c2d7c44 100644 --- a/lib/l10n/gen/app_localizations_hi.dart +++ b/lib/l10n/gen/app_localizations_hi.dart @@ -1857,4 +1857,14 @@ class AppLocalizationsHi extends AppLocalizations { @override String get restaurarCompras => 'खरीदारी पुनर्स्थापित करें'; + + @override + String get compraError => 'खरीद पूरी नहीं हो सकी। कृपया फिर से प्रयास करें।'; + + @override + String get restauracionSinCompras => + 'इस खाते में हमें कोई पिछली खरीद नहीं मिली।'; + + @override + String get premiumActivo => 'प्रीमियम सक्रिय'; } diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 1a9fb0b..08dbd8b 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -1867,4 +1867,15 @@ class AppLocalizationsId extends AppLocalizations { @override String get restaurarCompras => 'Pulihkan pembelian'; + + @override + String get compraError => + 'Pembelian tidak dapat diselesaikan. Silakan coba lagi.'; + + @override + String get restauracionSinCompras => + 'Kami tidak menemukan pembelian sebelumnya di akun ini.'; + + @override + String get premiumActivo => 'Premium aktif'; } diff --git a/lib/l10n/gen/app_localizations_it.dart b/lib/l10n/gen/app_localizations_it.dart index 5ff3ff5..280c271 100644 --- a/lib/l10n/gen/app_localizations_it.dart +++ b/lib/l10n/gen/app_localizations_it.dart @@ -1880,4 +1880,15 @@ class AppLocalizationsIt extends AppLocalizations { @override String get restaurarCompras => 'Ripristina acquisti'; + + @override + String get compraError => + 'Non è stato possibile completare l\'acquisto. Riprova.'; + + @override + String get restauracionSinCompras => + 'Non abbiamo trovato acquisti precedenti su questo account.'; + + @override + String get premiumActivo => 'Premium attivo'; } diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 297305c..9e3b48a 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -1803,4 +1803,13 @@ class AppLocalizationsJa extends AppLocalizations { @override String get restaurarCompras => '購入を復元'; + + @override + String get compraError => '購入を完了できませんでした。もう一度お試しください。'; + + @override + String get restauracionSinCompras => 'このアカウントでは以前の購入が見つかりませんでした。'; + + @override + String get premiumActivo => 'プレミアム有効'; } diff --git a/lib/l10n/gen/app_localizations_pt.dart b/lib/l10n/gen/app_localizations_pt.dart index 7e6039a..77af60d 100644 --- a/lib/l10n/gen/app_localizations_pt.dart +++ b/lib/l10n/gen/app_localizations_pt.dart @@ -1867,4 +1867,15 @@ class AppLocalizationsPt extends AppLocalizations { @override String get restaurarCompras => 'Restaurar compras'; + + @override + String get compraError => + 'Não foi possível concluir a compra. Tente novamente.'; + + @override + String get restauracionSinCompras => + 'Não encontramos nenhuma compra anterior nesta conta.'; + + @override + String get premiumActivo => 'Premium ativo'; } diff --git a/lib/l10n/gen/app_localizations_ru.dart b/lib/l10n/gen/app_localizations_ru.dart index 27d82c0..e30d9a6 100644 --- a/lib/l10n/gen/app_localizations_ru.dart +++ b/lib/l10n/gen/app_localizations_ru.dart @@ -1874,4 +1874,14 @@ class AppLocalizationsRu extends AppLocalizations { @override String get restaurarCompras => 'Восстановить покупки'; + + @override + String get compraError => 'Не удалось завершить покупку. Попробуйте ещё раз.'; + + @override + String get restauracionSinCompras => + 'Мы не нашли предыдущих покупок на этом аккаунте.'; + + @override + String get premiumActivo => 'Премиум активен'; } diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index e77ae59..8c15f6d 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -1788,4 +1788,13 @@ class AppLocalizationsZh extends AppLocalizations { @override String get restaurarCompras => '恢复购买'; + + @override + String get compraError => '无法完成购买,请重试。'; + + @override + String get restauracionSinCompras => '未在此账户中找到以前的购买记录。'; + + @override + String get premiumActivo => '高级版已解锁'; } diff --git a/lib/main.dart b/lib/main.dart index 6cd8b4d..ca1f338 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,12 +8,14 @@ import 'package:flutter/services.dart'; import 'package:google_mobile_ads/google_mobile_ads.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'app.dart'; +import 'estado/estado_entitlement.dart'; import 'servicios/arranque_audio.dart'; import 'servicios/musica_local_auto.dart'; import 'servicios/navegacion_auto.dart'; import 'servicios/servicio_audio.dart'; import 'servicios/servicio_audio_session.dart'; import 'servicios/servicio_compras.dart'; +import 'servicios/servicio_consentimiento.dart'; import 'servicios/servicio_presets_personalizados.dart'; import 'tema/pluriwave_tokens.dart'; @@ -110,7 +112,31 @@ Future main() async { // stream subscription and an ad-SDK warm-up are both safe to finish late // (mirrors `aplicarPoliticaOrientacion`'s "cosmetic, never gates startup" // rule immediately above). - unawaited(MobileAds.instance.initialize()); + // + // FIX 4 (code review): the Mobile Ads SDK is only initialized AFTER the + // GDPR/UMP consent flow resolves that ads may actually be requested + // (`ConsentInformation.canRequestAds()`) — serving personalized ads to + // EEA/UK users with no CMP violates Google's EU User Consent Policy. + // Premium users never even reach the consent form (`resolverConsentimientoAnuncios` + // short-circuits for them — they get zero ads regardless). This whole + // chain is deliberately `unawaited`: consent/ads are exactly as + // "cosmetic, never gates startup" as `aplicarPoliticaOrientacion` above, + // and any failure inside it degrades to "no ads", never a crash or a + // blocked UI. + unawaited( + esPremiumPersistido() + .then( + (premium) => resolverConsentimientoAnuncios( + esPremium: premium, + consentimiento: ServicioConsentimientoUmp(), + ), + ) + .then((puedeSolicitarAnuncios) async { + if (puedeSolicitarAnuncios) { + await MobileAds.instance.initialize(); + } + }), + ); final compras = ServicioComprasPlayBilling(); // S3-R4: single SharedPreferences instance resolved once at startup and diff --git a/lib/servicios/servicio_anuncios.dart b/lib/servicios/servicio_anuncios.dart index b65b47e..f5d9fdf 100644 --- a/lib/servicios/servicio_anuncios.dart +++ b/lib/servicios/servicio_anuncios.dart @@ -18,12 +18,10 @@ const _interstitialAdUnitIdReal = 'ca-app-pub-6038935671414339/4189478248'; /// Real id in release builds only; test id everywhere else (debug/profile, /// including internal-testing-track builds run via `flutter run --release` /// on a personal device — see the "never tap your own ads" note above). -const bannerAdUnitId = kReleaseMode - ? _bannerAdUnitIdReal - : bannerAdUnitIdPrueba; -const interstitialAdUnitId = kReleaseMode - ? _interstitialAdUnitIdReal - : interstitialAdUnitIdPrueba; +const bannerAdUnitId = + kReleaseMode ? _bannerAdUnitIdReal : bannerAdUnitIdPrueba; +const interstitialAdUnitId = + kReleaseMode ? _interstitialAdUnitIdReal : interstitialAdUnitIdPrueba; /// Ads port + AdMob adapter (Design "Interfaces / Contracts", ADR-6): owns /// the entitlement gate for both surfaces, the interstitial's session @@ -34,13 +32,16 @@ const interstitialAdUnitId = kReleaseMode /// and zero AdMob platform channels (Design Testing Strategy). class ServicioAnuncios { ServicioAnuncios({ - bool Function()? esPremium, + required bool Function() esPremium, DateTime Function()? ahora, Future Function()? mostrarInterstitialImpl, - }) : _esPremium = esPremium ?? (() => false), + Duration? timeoutIntentoInterstitial, + }) : _esPremium = esPremium, _ahora = ahora ?? DateTime.now, _mostrarInterstitialImpl = - mostrarInterstitialImpl ?? _mostrarInterstitialAdMob; + mostrarInterstitialImpl ?? _mostrarInterstitialAdMob, + _timeoutIntentoInterstitial = + timeoutIntentoInterstitial ?? timeoutIntentoInterstitialPorDefecto; /// Session-scoped cap (ad-display spec "Interstitial Frequency Cap"): at /// most 2 interstitials per process lifetime. @@ -50,9 +51,32 @@ class ServicioAnuncios { /// requirement). static const separacionMinima = Duration(minutes: 3); + /// FIX 2 (code review): bounds `InterstitialAd.load`'s callback wait + /// inside [_mostrarInterstitialAdMob] so a load callback that never fires + /// cannot hang a caller — every call site (`pantalla_alarmas.dart`, + /// `pantalla_favoritos.dart`, + /// `ajustes/pantalla_ajustes_emisoras_personalizadas.dart`) `await`s + /// [intentarInterstitial] before opening its form. + static const timeoutCargaInterstitialPorDefecto = Duration(seconds: 5); + + /// FIX 2 (code review): bounds the wait for the ad to actually PRESENT + /// (`onAdShowedFullScreenContent`) or fail + /// (`onAdFailedToShowFullScreenContent`) after `show()`. This method + /// deliberately never waits for the ad to be DISMISSED — the caller is + /// not blocked on ad dismissal at all, only on the ad actually rendering. + static const timeoutPresentacionInterstitialPorDefecto = Duration(seconds: 5); + + /// FIX 2 (code review): the overall bound applied around the INJECTED + /// [_mostrarInterstitialImpl] itself (production default: the sum of the + /// two timeouts above, plus headroom) — so ANY implementation, including + /// a future bug in an injected fake or a different ad SDK, can never hang + /// a caller indefinitely. Injectable so tests can use a short value. + static const timeoutIntentoInterstitialPorDefecto = Duration(seconds: 15); + final bool Function() _esPremium; final DateTime Function() _ahora; final Future Function() _mostrarInterstitialImpl; + final Duration _timeoutIntentoInterstitial; int _mostrados = 0; DateTime? _ultimoMostrado; @@ -83,7 +107,14 @@ class ServicioAnuncios { /// interruptions, not load attempts). Future intentarInterstitial() async { if (!_dentroDelCap()) return false; - final mostrado = await _mostrarInterstitialImpl(); + // FIX 2 (code review): bound the injected implementation itself — no + // caller may ever await this indefinitely, regardless of what + // [_mostrarInterstitialImpl] does internally. A timeout is treated + // exactly like "no ad shown": `false`, cap not consumed. + final mostrado = await _mostrarInterstitialImpl().timeout( + _timeoutIntentoInterstitial, + onTimeout: () => false, + ); if (mostrado) { _mostrados++; _ultimoMostrado = _ahora(); @@ -94,11 +125,20 @@ class ServicioAnuncios { static Future _mostrarInterstitialAdMob() async { try { final cargaCompleter = Completer(); + // FIX 2 (code review): a load callback that never fires used to hang + // this await forever. `expiradoCarga` guards a LATE callback that + // still arrives after the timeout — the ad is disposed instead of + // leaked, and never completes the already-abandoned completer. + var expiradoCarga = false; await InterstitialAd.load( adUnitId: interstitialAdUnitId, request: const AdRequest(), adLoadCallback: InterstitialAdLoadCallback( onAdLoaded: (ad) { + if (expiradoCarga) { + ad.dispose(); + return; + } if (!cargaCompleter.isCompleted) cargaCompleter.complete(ad); }, onAdFailedToLoad: (error) { @@ -107,23 +147,60 @@ class ServicioAnuncios { }, ), ); - final cargado = await cargaCompleter.future; + final InterstitialAd? cargado; + try { + cargado = await cargaCompleter.future.timeout( + timeoutCargaInterstitialPorDefecto, + ); + } on TimeoutException { + expiradoCarga = true; + return false; + } if (cargado == null) return false; - final cierreCompleter = Completer(); + // FIX 6 (code review): only a genuinely PRESENTED ad may consume the + // session cap. `onAdFailedToShowFullScreenContent` used to complete + // the same completer as a real dismissal and the method returned + // `true` unconditionally — a failed-to-show ad silently burned one of + // only 2 session slots. + // + // FIX 2 (code review): this method no longer waits for the ad to be + // DISMISSED at all — only for it to PRESENT or fail to present — and + // that wait is itself bounded, so a `fullScreenContentCallback` that + // never fires cannot hang the caller either. `expiradoPresentacion` + // guards a late callback the same way `expiradoCarga` does above. + var expiradoPresentacion = false; + final presentacionCompleter = Completer(); cargado.fullScreenContentCallback = FullScreenContentCallback( + onAdShowedFullScreenContent: (ad) { + if (!presentacionCompleter.isCompleted) { + presentacionCompleter.complete(true); + } + }, onAdDismissedFullScreenContent: (ad) { ad.dispose(); - if (!cierreCompleter.isCompleted) cierreCompleter.complete(); }, onAdFailedToShowFullScreenContent: (ad, error) { + if (expiradoPresentacion) { + ad.dispose(); + return; + } ad.dispose(); - if (!cierreCompleter.isCompleted) cierreCompleter.complete(); + if (!presentacionCompleter.isCompleted) { + presentacionCompleter.complete(false); + } }, ); await cargado.show(); - await cierreCompleter.future; - return true; + try { + return await presentacionCompleter.future.timeout( + timeoutPresentacionInterstitialPorDefecto, + ); + } on TimeoutException { + expiradoPresentacion = true; + await cargado.dispose(); + return false; + } } catch (e) { debugPrint('[PluriWave][anuncios] interstitial ERROR $e'); return false; diff --git a/lib/servicios/servicio_consentimiento.dart b/lib/servicios/servicio_consentimiento.dart new file mode 100644 index 0000000..a79c049 --- /dev/null +++ b/lib/servicios/servicio_consentimiento.dart @@ -0,0 +1,106 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart' show debugPrint; +import 'package:google_mobile_ads/google_mobile_ads.dart'; + +/// GDPR/UMP consent I/O abstraction (FIX 4, code review): every other file +/// depends on this port, never on the `google_mobile_ads` UMP classes +/// (`ConsentInformation`, `ConsentForm`) directly — matches +/// `PuertoCompras`'s injection shape, and keeps this testable with zero +/// AdMob/UMP platform channels in unit tests. +abstract class PuertoConsentimiento { + /// Requests consent info, loads-and-shows the consent form if required, + /// and resolves whether ads may be requested afterwards + /// (`ConsentInformation.canRequestAds()`). Implementations must NEVER + /// throw — any underlying failure degrades to `false` (no ads served), + /// never crashes or blocks the caller. + Future resolver(); +} + +/// The SOLE UMP call site (FIX 4) — every other file depends on +/// [PuertoConsentimiento] instead. +class ServicioConsentimientoUmp implements PuertoConsentimiento { + ServicioConsentimientoUmp({ + ConsentRequestParameters? parametros, + Duration? timeoutActualizacion, + }) : _parametros = parametros ?? ConsentRequestParameters(), + _timeoutActualizacion = + timeoutActualizacion ?? const Duration(seconds: 10); + + final ConsentRequestParameters _parametros; + final Duration _timeoutActualizacion; + + @override + Future resolver() async { + try { + // 1. Request an up-to-date consent status. FIX 2's lesson applies + // here too: bound the callback-based wait so a callback that never + // fires cannot hang startup. + final actualizacionCompleter = Completer(); + ConsentInformation.instance.requestConsentInfoUpdate( + _parametros, + () { + if (!actualizacionCompleter.isCompleted) { + actualizacionCompleter.complete(); + } + }, + (error) { + debugPrint( + '[PluriWave][consentimiento] requestConsentInfoUpdate ERROR ' + '${error.message}', + ); + if (!actualizacionCompleter.isCompleted) { + actualizacionCompleter.complete(); + } + }, + ); + await actualizacionCompleter.future.timeout( + _timeoutActualizacion, + onTimeout: () {}, + ); + + // 2. Load-and-show the consent form ONLY IF the UMP SDK itself + // determines it is required (EEA/UK traffic, no prior valid + // consent) — this single call is a no-op everywhere else. + await ConsentForm.loadAndShowConsentFormIfRequired((formError) { + if (formError != null) { + debugPrint( + '[PluriWave][consentimiento] ' + 'loadAndShowConsentFormIfRequired ERROR ${formError.message}', + ); + } + }); + + // 3. The only gate that matters for the caller: may ads be + // requested at all right now? + return await ConsentInformation.instance.canRequestAds(); + } catch (e) { + debugPrint('[PluriWave][consentimiento] ERROR $e'); + return false; + } + } +} + +/// Orchestrates the whole gate (FIX 4): premium users NEVER see a consent +/// form at all — they get zero ads regardless of consent — so +/// [PuertoConsentimiento] is never even touched for them. Free-tier users +/// get the real flow, with any failure degrading silently to "ads not +/// allowed" rather than crashing or blocking `main()`. +Future resolverConsentimientoAnuncios({ + required bool esPremium, + required PuertoConsentimiento consentimiento, +}) async { + if (esPremium) return false; + try { + return await consentimiento.resolver(); + } catch (e) { + // Defense in depth: [PuertoConsentimiento.resolver] is documented to + // never throw, but a caller-provided implementation (fake or future + // adapter) failing to honor that contract still may not crash or block + // `main()`. + debugPrint( + '[PluriWave][consentimiento] resolverConsentimientoAnuncios ERROR $e', + ); + return false; + } +} diff --git a/lib/widgets/banner_anuncio_superior.dart b/lib/widgets/banner_anuncio_superior.dart index 142b231..f5e9b93 100644 --- a/lib/widgets/banner_anuncio_superior.dart +++ b/lib/widgets/banner_anuncio_superior.dart @@ -14,7 +14,14 @@ import '../servicios/servicio_anuncios.dart'; /// body (`app.dart`'s `_PaginaPrincipalState.build`) — this widget itself /// never wraps its parent in a `Stack`/overlay. class BannerAnuncioSuperior extends StatefulWidget { - const BannerAnuncioSuperior({super.key}); + const BannerAnuncioSuperior({super.key, this.alIntentarCargar}); + + /// Test-only hook (FIX 7, code review): fires exactly once per REAL load + /// ATTEMPT (`BannerAd(...).load()` call), independent of the load's + /// eventual outcome — lets a widget test count load attempts without a + /// real AdMob platform channel. Always `null` in production. + @visibleForTesting + final VoidCallback? alIntentarCargar; @override State createState() => _BannerAnuncioSuperiorState(); @@ -24,16 +31,34 @@ class _BannerAnuncioSuperiorState extends State { BannerAd? _bannerAd; bool _cargado = false; + /// FIX 7 (code review): explicit "load already attempted" flag. Before + /// this, the guard was `_bannerAd == null`, which stays `null` until a + /// load actually SUCCEEDS — so every `notifyListeners()` from ANY + /// provider this widget watches (`EstadoEntitlement` during a + /// purchase/restore in progress) plus theme/locale/`MediaQuery` changes + /// re-ran `didChangeDependencies` and spawned ANOTHER `BannerAd` + + /// `load()` call. Only the LAST loaded ad was ever disposed, leaking + /// every in-flight duplicate before it. + /// + /// Retry policy (documented decision): a FAILED load is never retried + /// automatically — this flag is set once and never reset. Retrying on + /// every rebuild is exactly the bug this flag fixes; the next natural + /// retry opportunity is a fresh app session, which is an adequate cadence + /// for a non-critical, collapse-to-nothing UI element. + bool _cargaIntentada = false; + @override void didChangeDependencies() { super.didChangeDependencies(); final servicio = context.read(); - if (_bannerAd == null && servicio.debeMostrarBanner) { + if (!_cargaIntentada && servicio.debeMostrarBanner) { + _cargaIntentada = true; _cargarBanner(); } } void _cargarBanner() { + widget.alIntentarCargar?.call(); // Fire-and-forget: a failure (no plugin channel in `flutter test`, no // fill, offline) leaves `_bannerAd` `null` forever, which keeps this // widget collapsed — exactly the same degrade-to-shrink path a genuine @@ -76,10 +101,22 @@ class _BannerAnuncioSuperiorState extends State { // transition is dropped, never shown to a now-premium user. if (!_cargado || _bannerAd == null) return const SizedBox.shrink(); final ad = _bannerAd!; - return SizedBox( - width: ad.size.width.toDouble(), - height: ad.size.height.toDouble(), - child: AdWidget(ad: ad), + // FIX 1 (code review): the top-inset `SafeArea` now lives HERE, applied + // ONLY when an ad is actually about to render. `SafeArea` reserves + // `MediaQuery.padding.top` regardless of its child's own size — even a + // zero-size `SizedBox.shrink()` child — so the OLD unconditional + // `app.dart`-level `SafeArea(bottom: false, child: BannerAnuncioSuperior())` + // wrapper left a permanent blank status-bar-height strip both for + // premium users and for free users before the first ad finished + // loading. Collapsing (the two early returns above) now returns a + // TRULY zero-height widget, including no reserved padding. + return SafeArea( + bottom: false, + child: SizedBox( + width: ad.size.width.toDouble(), + height: ad.size.height.toDouble(), + child: AdWidget(ad: ad), + ), ); } } diff --git a/lib/widgets/hoja_premium.dart b/lib/widgets/hoja_premium.dart index 6ef5573..3c9b585 100644 --- a/lib/widgets/hoja_premium.dart +++ b/lib/widgets/hoja_premium.dart @@ -62,12 +62,58 @@ class HojaPremium extends StatelessWidget { ], ), const SizedBox(height: 20), + // FIX 3 (code review): user-facing feedback for a failed + // purchase/restore, or a restore that found nothing — before + // this, `resultadoUsuario` had ZERO UI, so the spinner just + // stopped with no feedback at all. Never the raw + // `EventoCompra.mensaje` developer string — always the mapped, + // generic localized message. + if (entitlement.resultadoUsuario != null) + Padding( + key: const ValueKey('hoja-premium-resultado'), + padding: const EdgeInsets.only(bottom: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + entitlement.resultadoUsuario == + ResultadoEntitlementUsuario.error + ? Icons.error_outline_rounded + : Icons.info_outline_rounded, + size: 18, + color: + entitlement.resultadoUsuario == + ResultadoEntitlementUsuario.error + ? Theme.of(context).colorScheme.error + : Theme.of(context).textTheme.bodyMedium?.color, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + entitlement.resultadoUsuario == + ResultadoEntitlementUsuario.error + ? l10n.compraError + : l10n.restauracionSinCompras, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + IconButton( + key: const ValueKey('hoja-premium-resultado-descartar'), + icon: const Icon(Icons.close_rounded, size: 18), + onPressed: () => entitlement.consumirResultadoUsuario(), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + visualDensity: VisualDensity.compact, + ), + ], + ), + ), if (entitlement.esPremium) Padding( key: const ValueKey('hoja-premium-activo'), padding: const EdgeInsets.only(bottom: 12), child: Text( - l10n.equalizerActive, + l10n.premiumActivo, style: Theme.of(context).textTheme.bodyMedium, ), ) diff --git a/test/app_test.dart b/test/app_test.dart index 31d5150..c3bc762 100644 --- a/test/app_test.dart +++ b/test/app_test.dart @@ -1,6 +1,12 @@ import 'dart:io'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/app.dart'; +import 'package:pluriwave/estado/estado_entitlement.dart'; +import 'package:pluriwave/servicios/servicio_anuncios.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; /// S1 (Tier 1 visual fidelity): the prototype (`t4`) never draws a global /// `AppBar` — every root owns its own 56px title row instead (see @@ -69,4 +75,64 @@ void main() { reason: 'the tutorial carousel must run before the what-is-new dialog', ); }); + + group( + 'construirCuerpoPrincipal — banner y la status bar (FIX 1, code review)', + () { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + Future bombear(WidgetTester tester, {required bool premium}) async { + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.only(top: 44)), + child: MaterialApp( + home: MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (_) => EstadoEntitlement(prefs: null), + ), + Provider( + create: (_) => ServicioAnuncios(esPremium: () => premium), + ), + ], + child: Scaffold( + body: construirCuerpoPrincipal( + contenido: const Align( + alignment: Alignment.topLeft, + child: Text('contenido'), + ), + ), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + } + + testWidgets( + 'usuario premium: el contenido arranca en y=0 -- edge-to-edge, sin ' + 'franja en blanco reservada para la status bar', + (tester) async { + await bombear(tester, premium: true); + + expect(tester.getTopLeft(find.text('contenido')).dy, 0); + }, + ); + + testWidgets( + 'usuario free con el banner aún sin cargar: el contenido arranca ' + 'igualmente en y=0 -- misma posición edge-to-edge que antes del ' + 'cambio, no una franja reservada de 44px hasta que el ad cargue', + (tester) async { + await bombear(tester, premium: false); + + expect(tester.getTopLeft(find.text('contenido')).dy, 0); + }, + ); + }, + ); } diff --git a/test/estado/estado_entitlement_test.dart b/test/estado/estado_entitlement_test.dart index de3ddef..d072980 100644 --- a/test/estado/estado_entitlement_test.dart +++ b/test/estado/estado_entitlement_test.dart @@ -181,6 +181,122 @@ void main() { expect(estado.esPremium, isFalse); }, ); + + group('resultadoUsuario (FIX 3, code review)', () { + test( + 'un error en el flujo de compra expone ResultadoEntitlementUsuario.error', + () async { + final compras = _PuertoComprasFalso(); + addTearDown(compras.dispose); + final estado = EstadoEntitlement( + prefs: await SharedPreferences.getInstance(), + compras: compras, + ); + addTearDown(estado.dispose); + await Future.delayed(Duration.zero); + + expect(estado.resultadoUsuario, isNull); + + unawaited(estado.comprar()); + await Future.delayed(Duration.zero); + compras.emitir(const EventoCompra(TipoEventoCompra.error)); + await Future.delayed(Duration.zero); + + expect(estado.resultadoUsuario, ResultadoEntitlementUsuario.error); + }, + ); + + test('restaurar() sin compra previa expone su propio resultado ' + '(restauracionSinCompras), distinto de un error', () async { + final compras = _PuertoComprasFalso(); + addTearDown(compras.dispose); + final estado = EstadoEntitlement( + prefs: await SharedPreferences.getInstance(), + compras: compras, + ); + addTearDown(estado.dispose); + await Future.delayed(Duration.zero); + + unawaited(estado.restaurar()); + await Future.delayed(Duration.zero); + compras.emitir(const EventoCompra(TipoEventoCompra.noEncontrada)); + await Future.delayed(Duration.zero); + + expect( + estado.resultadoUsuario, + ResultadoEntitlementUsuario.restauracionSinCompras, + ); + expect( + estado.resultadoUsuario, + isNot(ResultadoEntitlementUsuario.error), + ); + }); + + test('consumirResultadoUsuario() limpia la señal y notifica a los ' + 'listeners', () async { + final compras = _PuertoComprasFalso(); + addTearDown(compras.dispose); + final estado = EstadoEntitlement( + prefs: await SharedPreferences.getInstance(), + compras: compras, + ); + addTearDown(estado.dispose); + await Future.delayed(Duration.zero); + + unawaited(estado.comprar()); + await Future.delayed(Duration.zero); + compras.emitir(const EventoCompra(TipoEventoCompra.error)); + await Future.delayed(Duration.zero); + expect(estado.resultadoUsuario, isNotNull); + + var notificaciones = 0; + estado.addListener(() => notificaciones++); + estado.consumirResultadoUsuario(); + + expect(estado.resultadoUsuario, isNull); + expect(notificaciones, greaterThan(0)); + + // También se limpia (probado por separado) el resultado de una + // restauración sin compras. + unawaited(estado.restaurar()); + await Future.delayed(Duration.zero); + compras.emitir(const EventoCompra(TipoEventoCompra.noEncontrada)); + await Future.delayed(Duration.zero); + expect(estado.resultadoUsuario, isNotNull); + + estado.consumirResultadoUsuario(); + expect(estado.resultadoUsuario, isNull); + }); + + test( + 'nunca expone el texto interno/de desarrollador de EventoCompra.mensaje', + () async { + final compras = _PuertoComprasFalso(); + addTearDown(compras.dispose); + final estado = EstadoEntitlement( + prefs: await SharedPreferences.getInstance(), + compras: compras, + ); + addTearDown(estado.dispose); + await Future.delayed(Duration.zero); + + unawaited(estado.comprar()); + await Future.delayed(Duration.zero); + compras.emitir( + const EventoCompra( + TipoEventoCompra.error, + mensaje: 'Producto no encontrado en Play Console', + ), + ); + await Future.delayed(Duration.zero); + + // resultadoUsuario es un enum tipado -- estructuralmente incapaz + // de filtrar el string interno de EventoCompra.mensaje hacia la + // UI. + expect(estado.resultadoUsuario, ResultadoEntitlementUsuario.error); + }, + ); + }); }); group('esPremiumPersistido (headless, sin BuildContext)', () { diff --git a/test/servicios/servicio_anuncios_test.dart b/test/servicios/servicio_anuncios_test.dart index d91f8f4..c5f192d 100644 --- a/test/servicios/servicio_anuncios_test.dart +++ b/test/servicios/servicio_anuncios_test.dart @@ -1,3 +1,6 @@ +import 'dart:async'; +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/servicios/servicio_anuncios.dart'; @@ -10,10 +13,12 @@ void main() { required DateTime Function() ahora, required bool premium, Future Function()? mostrarInterstitialImpl, + Duration? timeoutIntentoInterstitial, }) => ServicioAnuncios( ahora: ahora, esPremium: () => premium, mostrarInterstitialImpl: mostrarInterstitialImpl ?? (() async => true), + timeoutIntentoInterstitial: timeoutIntentoInterstitial, ); group('intentarInterstitial — cap de frecuencia', () { @@ -87,6 +92,75 @@ void main() { ); }); + group('intentarInterstitial — fallo al mostrar no consume el cupo', () { + test('la implementación real de AdMob distingue presentación real de ' + 'fallo de renderizado (FIX 6, code review): ' + 'onAdShowedFullScreenContent debe estar instrumentado y el resultado ' + 'de show() no puede ser un true incondicional', () { + final source = + File('lib/servicios/servicio_anuncios.dart').readAsStringSync(); + expect( + source.contains('onAdShowedFullScreenContent'), + isTrue, + reason: + 'debe registrar si el anuncio realmente llegó a presentarse ' + '(onAdShowedFullScreenContent) para no devolver true tras un ' + 'onAdFailedToShowFullScreenContent', + ); + expect( + source.contains('await cierreCompleter.future;\n return true;'), + isFalse, + reason: + 'el resultado de _mostrarInterstitialAdMob ya no puede ser un ' + 'true incondicional tras el cierre -- debe reflejar si el ' + 'anuncio realmente se presentó', + ); + }); + }); + + group('intentarInterstitial — timeout acotado (FIX 2, code review)', () { + test('una implementación que nunca completa resuelve false dentro del ' + 'timeout inyectado y no consume el cupo', () async { + final ahora = DateTime(2026, 1, 1, 10, 0); + final nuncaCompleta = Completer(); + addTearDown(() { + if (!nuncaCompleta.isCompleted) nuncaCompleta.complete(false); + }); + final servicio = construir( + ahora: () => ahora, + premium: false, + mostrarInterstitialImpl: () => nuncaCompleta.future, + timeoutIntentoInterstitial: const Duration(milliseconds: 20), + ); + + final resultado = await servicio.intentarInterstitial().timeout( + const Duration(seconds: 2), + ); + + expect(resultado, isFalse); + // El cupo no se consumió: un intento posterior con una + // implementación que SÍ resuelve sigue mostrando el anuncio. + final servicioReal = construir(ahora: () => ahora, premium: false); + expect(await servicioReal.intentarInterstitial(), isTrue); + }); + + test('una implementación lenta pero que SÍ completa dentro del timeout ' + 'sigue resolviendo con su resultado real', () async { + final ahora = DateTime(2026, 1, 1, 10, 0); + final servicio = construir( + ahora: () => ahora, + premium: false, + mostrarInterstitialImpl: () async { + await Future.delayed(const Duration(milliseconds: 5)); + return true; + }, + timeoutIntentoInterstitial: const Duration(milliseconds: 200), + ); + + expect(await servicio.intentarInterstitial(), isTrue); + }); + }); + group('debeMostrarBanner', () { test('free: true', () { final servicio = construir( diff --git a/test/servicios/servicio_consentimiento_test.dart b/test/servicios/servicio_consentimiento_test.dart new file mode 100644 index 0000000..55a4ee8 --- /dev/null +++ b/test/servicios/servicio_consentimiento_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/servicios/servicio_consentimiento.dart'; + +/// Fake [PuertoConsentimiento]: never touches the UMP plugin, lets each +/// test drive the outcome of the consent flow directly. +class _PuertoConsentimientoFalso implements PuertoConsentimiento { + _PuertoConsentimientoFalso({this.resultado = true, this.lanzarError = false}); + + final bool resultado; + final bool lanzarError; + int llamadas = 0; + + @override + Future resolver() async { + llamadas++; + if (lanzarError) throw Exception('fallo simulado de UMP'); + return resultado; + } +} + +/// FIX 4 (code review): no GDPR/UMP consent flow existed at all before +/// this. [resolverConsentimientoAnuncios] is the pure/injectable +/// orchestration seam -- testable with zero AdMob/UMP platform channels, +/// mirroring `ServicioAnuncios`'s own testing strategy. +void main() { + group('resolverConsentimientoAnuncios', () { + test('usuario premium: nunca toca el puerto de consentimiento (cero ' + 'formularios para premium) y resuelve false', () async { + final puerto = _PuertoConsentimientoFalso(resultado: true); + + final permiso = await resolverConsentimientoAnuncios( + esPremium: true, + consentimiento: puerto, + ); + + expect(permiso, isFalse); + expect(puerto.llamadas, 0); + }); + + test('usuario free: delega al puerto de consentimiento y devuelve su ' + 'resultado (canRequestAds)', () async { + final puerto = _PuertoConsentimientoFalso(resultado: true); + + final permiso = await resolverConsentimientoAnuncios( + esPremium: false, + consentimiento: puerto, + ); + + expect(permiso, isTrue); + expect(puerto.llamadas, 1); + }); + + test('usuario free, consentimiento no otorgado: nunca se piden anuncios ' + '(false)', () async { + final puerto = _PuertoConsentimientoFalso(resultado: false); + + final permiso = await resolverConsentimientoAnuncios( + esPremium: false, + consentimiento: puerto, + ); + + expect(permiso, isFalse); + }); + + test('un fallo del puerto de consentimiento degrada silenciosamente a ' + 'false -- nunca se propaga ni bloquea al llamador', () async { + final puerto = _PuertoConsentimientoFalso(lanzarError: true); + + final permiso = await resolverConsentimientoAnuncios( + esPremium: false, + consentimiento: puerto, + ); + + expect(permiso, isFalse); + }); + }); +} diff --git a/test/widgets/banner_anuncio_superior_test.dart b/test/widgets/banner_anuncio_superior_test.dart index e20abae..2e4799c 100644 --- a/test/widgets/banner_anuncio_superior_test.dart +++ b/test/widgets/banner_anuncio_superior_test.dart @@ -1,11 +1,34 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/estado/estado_entitlement.dart'; import 'package:pluriwave/servicios/servicio_anuncios.dart'; +import 'package:pluriwave/servicios/servicio_compras.dart'; import 'package:pluriwave/widgets/banner_anuncio_superior.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; +/// Fake [PuertoCompras] (mirrors `app_test.dart`'s own fake): lets a test +/// drive [EstadoEntitlement]'s purchase-stream `notifyListeners()` calls +/// without touching `in_app_purchase`. +class _PuertoComprasFalso implements PuertoCompras { + final _eventos = StreamController.broadcast(); + + @override + Stream get eventos => _eventos.stream; + + @override + Future comprar() async {} + + @override + Future restaurar() async {} + + void emitir(EventoCompra evento) => _eventos.add(evento); + + Future dispose() => _eventos.close(); +} + /// Ad-display spec "Persistent Top Banner, Never Overlapping Content": the /// banner is entitlement-aware and reserves layout via a `Column` /// (`SizedBox.shrink()` collapses it to zero footprint) — never a `Stack` @@ -72,4 +95,66 @@ void main() { expect(tester.getSize(banner).height, 0); expect(find.text('contenido'), findsOneWidget); }); + + testWidgets('didChangeDependencies repetido (ej. notifyListeners de ' + 'EstadoEntitlement durante una compra en curso) dispara como mucho UN ' + 'intento de carga real (FIX 7, code review)', (tester) async { + final compras = _PuertoComprasFalso(); + addTearDown(compras.dispose); + var intentosDeCarga = 0; + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (_) => EstadoEntitlement(prefs: null, compras: compras), + ), + Provider( + create: (_) => ServicioAnuncios(esPremium: () => false), + ), + ], + child: MaterialApp( + home: Scaffold( + body: Column( + children: [ + BannerAnuncioSuperior( + alIntentarCargar: () => intentosDeCarga++, + ), + const Expanded(child: Center(child: Text('contenido'))), + ], + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect( + intentosDeCarga, + 1, + reason: 'el primer didChangeDependencies debe intentar UNA carga', + ); + + // Cada uno de estos eventos hace que EstadoEntitlement llame a + // notifyListeners() -- y como BannerAnuncioSuperior.build() hace + // context.watch(), cada notificación vuelve a + // ejecutar didChangeDependencies() en este widget. + compras.emitir(const EventoCompra(TipoEventoCompra.pendiente)); + await tester.pump(); + compras.emitir(const EventoCompra(TipoEventoCompra.cancelada)); + await tester.pump(); + compras.emitir(const EventoCompra(TipoEventoCompra.pendiente)); + await tester.pump(); + compras.emitir(const EventoCompra(TipoEventoCompra.error)); + await tester.pump(); + + expect( + intentosDeCarga, + 1, + reason: + 'cuatro notificaciones adicionales de EstadoEntitlement NO ' + 'deben disparar cuatro cargas más -- solo la primera cuenta', + ); + }); } diff --git a/test/widgets/hoja_premium_test.dart b/test/widgets/hoja_premium_test.dart new file mode 100644 index 0000000..9dc15b6 --- /dev/null +++ b/test/widgets/hoja_premium_test.dart @@ -0,0 +1,151 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_entitlement.dart'; +import 'package:pluriwave/l10n/gen/app_localizations.dart'; +import 'package:pluriwave/servicios/servicio_compras.dart'; +import 'package:pluriwave/widgets/hoja_premium.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Fake [PuertoCompras] (mirrors `app_test.dart`'s own fake): lets a test +/// drive [EstadoEntitlement]'s purchase-stream events without touching +/// `in_app_purchase`. +class _PuertoComprasFalso implements PuertoCompras { + final _eventos = StreamController.broadcast(); + + @override + Stream get eventos => _eventos.stream; + + @override + Future comprar() async {} + + @override + Future restaurar() async {} + + void emitir(EventoCompra evento) => _eventos.add(evento); + + Future dispose() => _eventos.close(); +} + +/// FIX 3 / FIX 9 (code review): the paywall must show localized feedback for +/// a failed purchase/restore, a distinct non-error confirmation when a +/// restore finds nothing, and its own dedicated "premium active" string +/// instead of reusing the equalizer's `equalizerActive` translation. +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + late AppLocalizations l10n; + + Future bombear( + WidgetTester tester, { + required _PuertoComprasFalso compras, + }) async { + late EstadoEntitlement estado; + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (_) { + estado = EstadoEntitlement(prefs: null, compras: compras); + return estado; + }, + ), + ], + child: MaterialApp( + locale: const Locale('es'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: HojaPremium()), + ), + ), + ); + await tester.pump(); + l10n = AppLocalizations.of(tester.element(find.byType(HojaPremium))); + return estado; + } + + group( + 'FIX 9 — la etiqueta de premium activo es propia, no la del ecualizador', + () { + testWidgets( + 'usuario premium: muestra l10n.premiumActivo, nunca l10n.equalizerActive', + (tester) async { + SharedPreferences.setMockInitialValues({'compra_premium_v1': true}); + final compras = _PuertoComprasFalso(); + addTearDown(compras.dispose); + await bombear(tester, compras: compras); + await tester.pump(); + + expect(find.text(l10n.premiumActivo), findsOneWidget); + expect(find.text(l10n.equalizerActive), findsNothing); + }, + ); + }, + ); + + group('FIX 3 — feedback de error/restauración en el paywall', () { + testWidgets('un error de compra muestra el mensaje localizado genérico ' + '(l10n.compraError), nunca el texto interno de EventoCompra.mensaje', ( + tester, + ) async { + final compras = _PuertoComprasFalso(); + addTearDown(compras.dispose); + await bombear(tester, compras: compras); + + compras.emitir( + const EventoCompra( + TipoEventoCompra.error, + mensaje: 'Producto no encontrado en Play Console', + ), + ); + await tester.pump(); + await tester.pump(); + + expect(find.text(l10n.compraError), findsOneWidget); + expect(find.text('Producto no encontrado en Play Console'), findsNothing); + }); + + testWidgets('una restauración sin compras muestra su propia confirmación ' + '(l10n.restauracionSinCompras), distinta del mensaje de error', ( + tester, + ) async { + final compras = _PuertoComprasFalso(); + addTearDown(compras.dispose); + await bombear(tester, compras: compras); + + compras.emitir(const EventoCompra(TipoEventoCompra.noEncontrada)); + await tester.pump(); + await tester.pump(); + + expect(find.text(l10n.restauracionSinCompras), findsOneWidget); + expect(find.text(l10n.compraError), findsNothing); + }); + + testWidgets( + 'descartar el mensaje de error llama a consumirResultadoUsuario() y ' + 'lo oculta de la UI', + (tester) async { + final compras = _PuertoComprasFalso(); + addTearDown(compras.dispose); + final estado = await bombear(tester, compras: compras); + + compras.emitir(const EventoCompra(TipoEventoCompra.error)); + await tester.pump(); + await tester.pump(); + expect(find.text(l10n.compraError), findsOneWidget); + + await tester.tap( + find.byKey(const ValueKey('hoja-premium-resultado-descartar')), + ); + await tester.pump(); + + expect(find.text(l10n.compraError), findsNothing); + expect(estado.resultadoUsuario, isNull); + }, + ); + }); +}