fix(iap): address code review defects in freemium/IAP change
Fixes 9 of 10 review findings (10th requires a manual Play Console step, no code change): 1. app.dart/banner_anuncio_superior.dart: move the top SafeArea inside BannerAnuncioSuperior so it only reserves status-bar height when an ad actually renders, restoring edge-to-edge layout for premium and free-unloaded users. 2. servicio_anuncios.dart: bound every interstitial await (load, presentation, and the injected implementation itself) with injectable timeouts so a callback that never fires can no longer hang a caller. 3. estado_entitlement.dart/hoja_premium.dart: expose a typed resultadoUsuario signal for purchase/restore failures and restore-found-nothing, with dedicated localized messages (compraError, restauracionSinCompras) across all 13 locales -- never the raw developer/exception string. 4. main.dart/servicio_consentimiento.dart: add a GDPR/UMP consent flow (ConsentInformation/ConsentForm) that gates Mobile Ads SDK init on canRequestAds(); premium users never see a consent form; failures degrade to no ads instead of crashing or blocking startup. 6. servicio_anuncios.dart: track real ad presentation (onAdShowedFullScreenContent) so a failed-to-show interstitial no longer consumes a session cap slot. 7. banner_anuncio_superior.dart: add an explicit load-attempted guard so repeated didChangeDependencies (e.g. entitlement notifyListeners during a purchase) can only ever trigger one banner load attempt. 8. servicio_anuncios.dart: make esPremium a required constructor parameter, matching the hardened contract already applied to EstadoAlarmas/EstadoGrabacion/EstadoRadio. 9. hoja_premium.dart: add a dedicated premiumActivo localized string instead of reusing the equalizer's equalizerActive translation, across all 13 locales. All fixes implemented RED-first (failing test before production code). Full suite: 1261 passed, 2 pre-existing skips, 0 failures. flutter analyze: 5 pre-existing issues only, 0 new. [version set]
This commit is contained in:
+47
-27
@@ -35,6 +35,30 @@ import 'servicios/navegacion_auto.dart';
|
|||||||
import 'servicios/servicio_alarmas_android.dart';
|
import 'servicios/servicio_alarmas_android.dart';
|
||||||
import 'servicios/servicio_dispositivo_audio.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 {
|
class PluriWaveApp extends StatelessWidget {
|
||||||
const PluriWaveApp({super.key, this.prefs, this.fuenteAuto, this.compras});
|
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
|
// (design.md ADR-6): a `Column` sibling, NEVER a `Stack`/overlay — the
|
||||||
// banner RESERVES its own space above the existing body instead of
|
// banner RESERVES its own space above the existing body instead of
|
||||||
// covering any of it. `BannerAnuncioSuperior` itself collapses to
|
// covering any of it. `BannerAnuncioSuperior` itself collapses to
|
||||||
// `SizedBox.shrink()` (zero layout impact) for premium/unloaded.
|
// `SizedBox.shrink()` (zero layout impact) for premium/unloaded, and
|
||||||
body: Column(
|
// (FIX 1, code review) owns its OWN top `SafeArea` internally — this
|
||||||
children: [
|
// level no longer wraps it in an unconditional `SafeArea`, which used
|
||||||
const SafeArea(bottom: false, child: BannerAnuncioSuperior()),
|
// to reserve `MediaQuery.padding.top` even for a zero-size collapsed
|
||||||
Expanded(
|
// child, leaving a permanent blank status-bar-height strip.
|
||||||
child: SafeArea(
|
body: construirCuerpoPrincipal(
|
||||||
top: false,
|
contenido: AnimatedSwitcher(
|
||||||
child: AnimatedSwitcher(
|
duration: context.pluriMotion.normal,
|
||||||
duration: context.pluriMotion.normal,
|
switchInCurve: Curves.easeOutCubic,
|
||||||
switchInCurve: Curves.easeOutCubic,
|
switchOutCurve: Curves.easeInCubic,
|
||||||
switchOutCurve: Curves.easeInCubic,
|
transitionBuilder:
|
||||||
transitionBuilder:
|
(child, animation) => FadeTransition(
|
||||||
(child, animation) => FadeTransition(
|
opacity: animation,
|
||||||
opacity: animation,
|
child: SlideTransition(
|
||||||
child: SlideTransition(
|
position: Tween<Offset>(
|
||||||
position: Tween<Offset>(
|
begin: const Offset(0.035, 0),
|
||||||
begin: const Offset(0.035, 0),
|
end: Offset.zero,
|
||||||
end: Offset.zero,
|
).animate(animation),
|
||||||
).animate(animation),
|
child: child,
|
||||||
child: child,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: KeyedSubtree(
|
|
||||||
key: ValueKey<int>(indice),
|
|
||||||
child: _paginas[indice],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
child: KeyedSubtree(
|
||||||
|
key: ValueKey<int>(indice),
|
||||||
|
child: _paginas[indice],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
bottomNavigationBar: SafeArea(
|
bottomNavigationBar: SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
|
|||||||
@@ -30,6 +30,26 @@ Future<bool> esPremiumPersistido({SharedPreferences? prefs}) async {
|
|||||||
return resueltas.getBool(_keyPremium) ?? false;
|
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
|
/// Cross-cutting entitlement notifier (Design ADR-1), idiomatic
|
||||||
/// `EstadoIdioma`-shaped `ChangeNotifier`: UI layers `context.watch`/`read`
|
/// `EstadoIdioma`-shaped `ChangeNotifier`: UI layers `context.watch`/`read`
|
||||||
/// this; headless callers (Android Auto) use [esPremiumPersistido] instead,
|
/// this; headless callers (Android Auto) use [esPremiumPersistido] instead,
|
||||||
@@ -56,10 +76,24 @@ class EstadoEntitlement extends ChangeNotifier {
|
|||||||
|
|
||||||
bool _esPremium = false;
|
bool _esPremium = false;
|
||||||
bool _compraEnCurso = false;
|
bool _compraEnCurso = false;
|
||||||
|
ResultadoEntitlementUsuario? _resultadoUsuario;
|
||||||
|
|
||||||
bool get esPremium => _esPremium;
|
bool get esPremium => _esPremium;
|
||||||
bool get compraEnCurso => _compraEnCurso;
|
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<void> _cargar() async {
|
Future<void> _cargar() async {
|
||||||
final prefs = await _resolverPrefs();
|
final prefs = await _resolverPrefs();
|
||||||
final premium = prefs.getBool(_keyPremium) ?? false;
|
final premium = prefs.getBool(_keyPremium) ?? false;
|
||||||
@@ -80,6 +114,10 @@ class EstadoEntitlement extends ChangeNotifier {
|
|||||||
final compras = _compras;
|
final compras = _compras;
|
||||||
if (compras == null) return;
|
if (compras == null) return;
|
||||||
_compraEnCurso = true;
|
_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();
|
notifyListeners();
|
||||||
await compras.comprar();
|
await compras.comprar();
|
||||||
}
|
}
|
||||||
@@ -89,6 +127,7 @@ class EstadoEntitlement extends ChangeNotifier {
|
|||||||
final compras = _compras;
|
final compras = _compras;
|
||||||
if (compras == null) return;
|
if (compras == null) return;
|
||||||
_compraEnCurso = true;
|
_compraEnCurso = true;
|
||||||
|
_resultadoUsuario = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
await compras.restaurar();
|
await compras.restaurar();
|
||||||
}
|
}
|
||||||
@@ -99,17 +138,31 @@ class EstadoEntitlement extends ChangeNotifier {
|
|||||||
case TipoEventoCompra.restaurada:
|
case TipoEventoCompra.restaurada:
|
||||||
await _desbloquear();
|
await _desbloquear();
|
||||||
case TipoEventoCompra.cancelada:
|
case TipoEventoCompra.cancelada:
|
||||||
case TipoEventoCompra.noEncontrada:
|
// Spec "Purchase cancelled or failed": a user-INITIATED cancel
|
||||||
// Spec "Purchase cancelled or failed" / "Restore finds nothing":
|
// stays free tier with no error surfaced — just stop the in-flight
|
||||||
// stays free tier, no error surfaced — just stop the in-flight
|
// spinner. Not a failure, so no [resultadoUsuario] either.
|
||||||
// spinner.
|
|
||||||
_compraEnCurso = false;
|
_compraEnCurso = false;
|
||||||
notifyListeners();
|
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:
|
case TipoEventoCompra.error:
|
||||||
// Fail-open (Design ADR-2): an error NEVER writes `false` over an
|
// Fail-open (Design ADR-2): an error NEVER writes `false` over an
|
||||||
// already-premium flag, and never invents a `true` for a free user
|
// already-premium flag, and never invents a `true` for a free user
|
||||||
// either — the persisted flag from `_cargar()` is left untouched.
|
// 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;
|
_compraEnCurso = false;
|
||||||
|
_resultadoUsuario = ResultadoEntitlementUsuario.error;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
case TipoEventoCompra.pendiente:
|
case TipoEventoCompra.pendiente:
|
||||||
_compraEnCurso = true;
|
_compraEnCurso = true;
|
||||||
|
|||||||
+4
-1
@@ -901,5 +901,8 @@
|
|||||||
"funcionPremium": "ميزة مميزة",
|
"funcionPremium": "ميزة مميزة",
|
||||||
"limiteAlarmasAlcanzado": "لقد وصلت إلى الحد المجاني وهو 5 منبهات.",
|
"limiteAlarmasAlcanzado": "لقد وصلت إلى الحد المجاني وهو 5 منبهات.",
|
||||||
"desbloquearPremium": "فتح النسخة المميزة",
|
"desbloquearPremium": "فتح النسخة المميزة",
|
||||||
"restaurarCompras": "استعادة المشتريات"
|
"restaurarCompras": "استعادة المشتريات",
|
||||||
|
"compraError": "تعذّر إتمام عملية الشراء. حاول مرة أخرى.",
|
||||||
|
"restauracionSinCompras": "لم نجد أي عملية شراء سابقة في هذا الحساب.",
|
||||||
|
"premiumActivo": "النسخة المميزة مفعّلة"
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -901,5 +901,8 @@
|
|||||||
"funcionPremium": "প্রিমিয়াম বৈশিষ্ট্য",
|
"funcionPremium": "প্রিমিয়াম বৈশিষ্ট্য",
|
||||||
"limiteAlarmasAlcanzado": "আপনি বিনামূল্যে ৫টি অ্যালার্মের সীমায় পৌঁছেছেন।",
|
"limiteAlarmasAlcanzado": "আপনি বিনামূল্যে ৫টি অ্যালার্মের সীমায় পৌঁছেছেন।",
|
||||||
"desbloquearPremium": "প্রিমিয়াম আনলক করুন",
|
"desbloquearPremium": "প্রিমিয়াম আনলক করুন",
|
||||||
"restaurarCompras": "কেনাকাটা পুনরুদ্ধার করুন"
|
"restaurarCompras": "কেনাকাটা পুনরুদ্ধার করুন",
|
||||||
|
"compraError": "কেনাকাটা সম্পূর্ণ করা যায়নি। আবার চেষ্টা করুন।",
|
||||||
|
"restauracionSinCompras": "এই অ্যাকাউন্টে আমরা আগের কোনো কেনাকাটা খুঁজে পাইনি।",
|
||||||
|
"premiumActivo": "প্রিমিয়াম সক্রিয়"
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -901,5 +901,8 @@
|
|||||||
"funcionPremium": "Premium-Funktion",
|
"funcionPremium": "Premium-Funktion",
|
||||||
"limiteAlarmasAlcanzado": "Du hast das kostenlose Limit von 5 Weckern erreicht.",
|
"limiteAlarmasAlcanzado": "Du hast das kostenlose Limit von 5 Weckern erreicht.",
|
||||||
"desbloquearPremium": "Premium freischalten",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -901,5 +901,8 @@
|
|||||||
"funcionPremium": "Premium Feature",
|
"funcionPremium": "Premium Feature",
|
||||||
"limiteAlarmasAlcanzado": "You've reached the free 5-alarm limit.",
|
"limiteAlarmasAlcanzado": "You've reached the free 5-alarm limit.",
|
||||||
"desbloquearPremium": "Unlock Premium",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -860,5 +860,8 @@
|
|||||||
"funcionPremium": "Función Premium",
|
"funcionPremium": "Función Premium",
|
||||||
"limiteAlarmasAlcanzado": "Has alcanzado el límite de 5 alarmas gratuitas.",
|
"limiteAlarmasAlcanzado": "Has alcanzado el límite de 5 alarmas gratuitas.",
|
||||||
"desbloquearPremium": "Desbloquear Premium",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -901,5 +901,8 @@
|
|||||||
"funcionPremium": "Fonctionnalité Premium",
|
"funcionPremium": "Fonctionnalité Premium",
|
||||||
"limiteAlarmasAlcanzado": "Vous avez atteint la limite gratuite de 5 alarmes.",
|
"limiteAlarmasAlcanzado": "Vous avez atteint la limite gratuite de 5 alarmes.",
|
||||||
"desbloquearPremium": "Débloquer Premium",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -901,5 +901,8 @@
|
|||||||
"funcionPremium": "प्रीमियम सुविधा",
|
"funcionPremium": "प्रीमियम सुविधा",
|
||||||
"limiteAlarmasAlcanzado": "आप मुफ़्त 5 अलार्म की सीमा तक पहुँच गए हैं।",
|
"limiteAlarmasAlcanzado": "आप मुफ़्त 5 अलार्म की सीमा तक पहुँच गए हैं।",
|
||||||
"desbloquearPremium": "प्रीमियम अनलॉक करें",
|
"desbloquearPremium": "प्रीमियम अनलॉक करें",
|
||||||
"restaurarCompras": "खरीदारी पुनर्स्थापित करें"
|
"restaurarCompras": "खरीदारी पुनर्स्थापित करें",
|
||||||
|
"compraError": "खरीद पूरी नहीं हो सकी। कृपया फिर से प्रयास करें।",
|
||||||
|
"restauracionSinCompras": "इस खाते में हमें कोई पिछली खरीद नहीं मिली।",
|
||||||
|
"premiumActivo": "प्रीमियम सक्रिय"
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -901,5 +901,8 @@
|
|||||||
"funcionPremium": "Fitur Premium",
|
"funcionPremium": "Fitur Premium",
|
||||||
"limiteAlarmasAlcanzado": "Anda telah mencapai batas gratis 5 alarm.",
|
"limiteAlarmasAlcanzado": "Anda telah mencapai batas gratis 5 alarm.",
|
||||||
"desbloquearPremium": "Buka Premium",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -901,5 +901,8 @@
|
|||||||
"funcionPremium": "Funzione Premium",
|
"funcionPremium": "Funzione Premium",
|
||||||
"limiteAlarmasAlcanzado": "Hai raggiunto il limite gratuito di 5 sveglie.",
|
"limiteAlarmasAlcanzado": "Hai raggiunto il limite gratuito di 5 sveglie.",
|
||||||
"desbloquearPremium": "Sblocca Premium",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -901,5 +901,8 @@
|
|||||||
"funcionPremium": "プレミアム機能",
|
"funcionPremium": "プレミアム機能",
|
||||||
"limiteAlarmasAlcanzado": "無料プランのアラーム上限(5件)に達しました。",
|
"limiteAlarmasAlcanzado": "無料プランのアラーム上限(5件)に達しました。",
|
||||||
"desbloquearPremium": "プレミアムを解除",
|
"desbloquearPremium": "プレミアムを解除",
|
||||||
"restaurarCompras": "購入を復元"
|
"restaurarCompras": "購入を復元",
|
||||||
|
"compraError": "購入を完了できませんでした。もう一度お試しください。",
|
||||||
|
"restauracionSinCompras": "このアカウントでは以前の購入が見つかりませんでした。",
|
||||||
|
"premiumActivo": "プレミアム有効"
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -901,5 +901,8 @@
|
|||||||
"funcionPremium": "Recurso Premium",
|
"funcionPremium": "Recurso Premium",
|
||||||
"limiteAlarmasAlcanzado": "Você atingiu o limite gratuito de 5 alarmes.",
|
"limiteAlarmasAlcanzado": "Você atingiu o limite gratuito de 5 alarmes.",
|
||||||
"desbloquearPremium": "Desbloquear Premium",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -901,5 +901,8 @@
|
|||||||
"funcionPremium": "Премиум-функция",
|
"funcionPremium": "Премиум-функция",
|
||||||
"limiteAlarmasAlcanzado": "Вы достигли бесплатного лимита в 5 будильников.",
|
"limiteAlarmasAlcanzado": "Вы достигли бесплатного лимита в 5 будильников.",
|
||||||
"desbloquearPremium": "Разблокировать Премиум",
|
"desbloquearPremium": "Разблокировать Премиум",
|
||||||
"restaurarCompras": "Восстановить покупки"
|
"restaurarCompras": "Восстановить покупки",
|
||||||
|
"compraError": "Не удалось завершить покупку. Попробуйте ещё раз.",
|
||||||
|
"restauracionSinCompras": "Мы не нашли предыдущих покупок на этом аккаунте.",
|
||||||
|
"premiumActivo": "Премиум активен"
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -901,5 +901,8 @@
|
|||||||
"funcionPremium": "高级功能",
|
"funcionPremium": "高级功能",
|
||||||
"limiteAlarmasAlcanzado": "您已达到免费版 5 个闹钟的上限。",
|
"limiteAlarmasAlcanzado": "您已达到免费版 5 个闹钟的上限。",
|
||||||
"desbloquearPremium": "解锁高级版",
|
"desbloquearPremium": "解锁高级版",
|
||||||
"restaurarCompras": "恢复购买"
|
"restaurarCompras": "恢复购买",
|
||||||
|
"compraError": "无法完成购买,请重试。",
|
||||||
|
"restauracionSinCompras": "未在此账户中找到以前的购买记录。",
|
||||||
|
"premiumActivo": "高级版已解锁"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3349,6 +3349,24 @@ abstract class AppLocalizations {
|
|||||||
/// In es, this message translates to:
|
/// In es, this message translates to:
|
||||||
/// **'Restaurar compras'**
|
/// **'Restaurar compras'**
|
||||||
String get restaurarCompras;
|
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
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -1853,4 +1853,14 @@ class AppLocalizationsAr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get restaurarCompras => 'استعادة المشتريات';
|
String get restaurarCompras => 'استعادة المشتريات';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get compraError => 'تعذّر إتمام عملية الشراء. حاول مرة أخرى.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get restauracionSinCompras =>
|
||||||
|
'لم نجد أي عملية شراء سابقة في هذا الحساب.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get premiumActivo => 'النسخة المميزة مفعّلة';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1864,4 +1864,14 @@ class AppLocalizationsBn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get restaurarCompras => 'কেনাকাটা পুনরুদ্ধার করুন';
|
String get restaurarCompras => 'কেনাকাটা পুনরুদ্ধার করুন';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get compraError => 'কেনাকাটা সম্পূর্ণ করা যায়নি। আবার চেষ্টা করুন।';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get restauracionSinCompras =>
|
||||||
|
'এই অ্যাকাউন্টে আমরা আগের কোনো কেনাকাটা খুঁজে পাইনি।';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get premiumActivo => 'প্রিমিয়াম সক্রিয়';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1877,4 +1877,15 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get restaurarCompras => 'Käufe wiederherstellen';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1856,4 +1856,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get restaurarCompras => 'Restore purchases';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1870,4 +1870,15 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get restaurarCompras => 'Restaurar compras';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1883,4 +1883,15 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get restaurarCompras => 'Restaurer les achats';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1857,4 +1857,14 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get restaurarCompras => 'खरीदारी पुनर्स्थापित करें';
|
String get restaurarCompras => 'खरीदारी पुनर्स्थापित करें';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get compraError => 'खरीद पूरी नहीं हो सकी। कृपया फिर से प्रयास करें।';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get restauracionSinCompras =>
|
||||||
|
'इस खाते में हमें कोई पिछली खरीद नहीं मिली।';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get premiumActivo => 'प्रीमियम सक्रिय';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1867,4 +1867,15 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get restaurarCompras => 'Pulihkan pembelian';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1880,4 +1880,15 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get restaurarCompras => 'Ripristina acquisti';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1803,4 +1803,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get restaurarCompras => '購入を復元';
|
String get restaurarCompras => '購入を復元';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get compraError => '購入を完了できませんでした。もう一度お試しください。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get restauracionSinCompras => 'このアカウントでは以前の購入が見つかりませんでした。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get premiumActivo => 'プレミアム有効';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1867,4 +1867,15 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get restaurarCompras => 'Restaurar compras';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1874,4 +1874,14 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get restaurarCompras => 'Восстановить покупки';
|
String get restaurarCompras => 'Восстановить покупки';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get compraError => 'Не удалось завершить покупку. Попробуйте ещё раз.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get restauracionSinCompras =>
|
||||||
|
'Мы не нашли предыдущих покупок на этом аккаунте.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get premiumActivo => 'Премиум активен';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1788,4 +1788,13 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get restaurarCompras => '恢复购买';
|
String get restaurarCompras => '恢复购买';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get compraError => '无法完成购买,请重试。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get restauracionSinCompras => '未在此账户中找到以前的购买记录。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get premiumActivo => '高级版已解锁';
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-1
@@ -8,12 +8,14 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
|
import 'estado/estado_entitlement.dart';
|
||||||
import 'servicios/arranque_audio.dart';
|
import 'servicios/arranque_audio.dart';
|
||||||
import 'servicios/musica_local_auto.dart';
|
import 'servicios/musica_local_auto.dart';
|
||||||
import 'servicios/navegacion_auto.dart';
|
import 'servicios/navegacion_auto.dart';
|
||||||
import 'servicios/servicio_audio.dart';
|
import 'servicios/servicio_audio.dart';
|
||||||
import 'servicios/servicio_audio_session.dart';
|
import 'servicios/servicio_audio_session.dart';
|
||||||
import 'servicios/servicio_compras.dart';
|
import 'servicios/servicio_compras.dart';
|
||||||
|
import 'servicios/servicio_consentimiento.dart';
|
||||||
import 'servicios/servicio_presets_personalizados.dart';
|
import 'servicios/servicio_presets_personalizados.dart';
|
||||||
import 'tema/pluriwave_tokens.dart';
|
import 'tema/pluriwave_tokens.dart';
|
||||||
|
|
||||||
@@ -110,7 +112,31 @@ Future<void> main() async {
|
|||||||
// stream subscription and an ad-SDK warm-up are both safe to finish late
|
// stream subscription and an ad-SDK warm-up are both safe to finish late
|
||||||
// (mirrors `aplicarPoliticaOrientacion`'s "cosmetic, never gates startup"
|
// (mirrors `aplicarPoliticaOrientacion`'s "cosmetic, never gates startup"
|
||||||
// rule immediately above).
|
// 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();
|
final compras = ServicioComprasPlayBilling();
|
||||||
|
|
||||||
// S3-R4: single SharedPreferences instance resolved once at startup and
|
// S3-R4: single SharedPreferences instance resolved once at startup and
|
||||||
|
|||||||
@@ -18,12 +18,10 @@ const _interstitialAdUnitIdReal = 'ca-app-pub-6038935671414339/4189478248';
|
|||||||
/// Real id in release builds only; test id everywhere else (debug/profile,
|
/// Real id in release builds only; test id everywhere else (debug/profile,
|
||||||
/// including internal-testing-track builds run via `flutter run --release`
|
/// including internal-testing-track builds run via `flutter run --release`
|
||||||
/// on a personal device — see the "never tap your own ads" note above).
|
/// on a personal device — see the "never tap your own ads" note above).
|
||||||
const bannerAdUnitId = kReleaseMode
|
const bannerAdUnitId =
|
||||||
? _bannerAdUnitIdReal
|
kReleaseMode ? _bannerAdUnitIdReal : bannerAdUnitIdPrueba;
|
||||||
: bannerAdUnitIdPrueba;
|
const interstitialAdUnitId =
|
||||||
const interstitialAdUnitId = kReleaseMode
|
kReleaseMode ? _interstitialAdUnitIdReal : interstitialAdUnitIdPrueba;
|
||||||
? _interstitialAdUnitIdReal
|
|
||||||
: interstitialAdUnitIdPrueba;
|
|
||||||
|
|
||||||
/// Ads port + AdMob adapter (Design "Interfaces / Contracts", ADR-6): owns
|
/// Ads port + AdMob adapter (Design "Interfaces / Contracts", ADR-6): owns
|
||||||
/// the entitlement gate for both surfaces, the interstitial's session
|
/// 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).
|
/// and zero AdMob platform channels (Design Testing Strategy).
|
||||||
class ServicioAnuncios {
|
class ServicioAnuncios {
|
||||||
ServicioAnuncios({
|
ServicioAnuncios({
|
||||||
bool Function()? esPremium,
|
required bool Function() esPremium,
|
||||||
DateTime Function()? ahora,
|
DateTime Function()? ahora,
|
||||||
Future<bool> Function()? mostrarInterstitialImpl,
|
Future<bool> Function()? mostrarInterstitialImpl,
|
||||||
}) : _esPremium = esPremium ?? (() => false),
|
Duration? timeoutIntentoInterstitial,
|
||||||
|
}) : _esPremium = esPremium,
|
||||||
_ahora = ahora ?? DateTime.now,
|
_ahora = ahora ?? DateTime.now,
|
||||||
_mostrarInterstitialImpl =
|
_mostrarInterstitialImpl =
|
||||||
mostrarInterstitialImpl ?? _mostrarInterstitialAdMob;
|
mostrarInterstitialImpl ?? _mostrarInterstitialAdMob,
|
||||||
|
_timeoutIntentoInterstitial =
|
||||||
|
timeoutIntentoInterstitial ?? timeoutIntentoInterstitialPorDefecto;
|
||||||
|
|
||||||
/// Session-scoped cap (ad-display spec "Interstitial Frequency Cap"): at
|
/// Session-scoped cap (ad-display spec "Interstitial Frequency Cap"): at
|
||||||
/// most 2 interstitials per process lifetime.
|
/// most 2 interstitials per process lifetime.
|
||||||
@@ -50,9 +51,32 @@ class ServicioAnuncios {
|
|||||||
/// requirement).
|
/// requirement).
|
||||||
static const separacionMinima = Duration(minutes: 3);
|
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 bool Function() _esPremium;
|
||||||
final DateTime Function() _ahora;
|
final DateTime Function() _ahora;
|
||||||
final Future<bool> Function() _mostrarInterstitialImpl;
|
final Future<bool> Function() _mostrarInterstitialImpl;
|
||||||
|
final Duration _timeoutIntentoInterstitial;
|
||||||
|
|
||||||
int _mostrados = 0;
|
int _mostrados = 0;
|
||||||
DateTime? _ultimoMostrado;
|
DateTime? _ultimoMostrado;
|
||||||
@@ -83,7 +107,14 @@ class ServicioAnuncios {
|
|||||||
/// interruptions, not load attempts).
|
/// interruptions, not load attempts).
|
||||||
Future<bool> intentarInterstitial() async {
|
Future<bool> intentarInterstitial() async {
|
||||||
if (!_dentroDelCap()) return false;
|
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) {
|
if (mostrado) {
|
||||||
_mostrados++;
|
_mostrados++;
|
||||||
_ultimoMostrado = _ahora();
|
_ultimoMostrado = _ahora();
|
||||||
@@ -94,11 +125,20 @@ class ServicioAnuncios {
|
|||||||
static Future<bool> _mostrarInterstitialAdMob() async {
|
static Future<bool> _mostrarInterstitialAdMob() async {
|
||||||
try {
|
try {
|
||||||
final cargaCompleter = Completer<InterstitialAd?>();
|
final cargaCompleter = Completer<InterstitialAd?>();
|
||||||
|
// 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(
|
await InterstitialAd.load(
|
||||||
adUnitId: interstitialAdUnitId,
|
adUnitId: interstitialAdUnitId,
|
||||||
request: const AdRequest(),
|
request: const AdRequest(),
|
||||||
adLoadCallback: InterstitialAdLoadCallback(
|
adLoadCallback: InterstitialAdLoadCallback(
|
||||||
onAdLoaded: (ad) {
|
onAdLoaded: (ad) {
|
||||||
|
if (expiradoCarga) {
|
||||||
|
ad.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!cargaCompleter.isCompleted) cargaCompleter.complete(ad);
|
if (!cargaCompleter.isCompleted) cargaCompleter.complete(ad);
|
||||||
},
|
},
|
||||||
onAdFailedToLoad: (error) {
|
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;
|
if (cargado == null) return false;
|
||||||
|
|
||||||
final cierreCompleter = Completer<void>();
|
// 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<bool>();
|
||||||
cargado.fullScreenContentCallback = FullScreenContentCallback(
|
cargado.fullScreenContentCallback = FullScreenContentCallback(
|
||||||
|
onAdShowedFullScreenContent: (ad) {
|
||||||
|
if (!presentacionCompleter.isCompleted) {
|
||||||
|
presentacionCompleter.complete(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
onAdDismissedFullScreenContent: (ad) {
|
onAdDismissedFullScreenContent: (ad) {
|
||||||
ad.dispose();
|
ad.dispose();
|
||||||
if (!cierreCompleter.isCompleted) cierreCompleter.complete();
|
|
||||||
},
|
},
|
||||||
onAdFailedToShowFullScreenContent: (ad, error) {
|
onAdFailedToShowFullScreenContent: (ad, error) {
|
||||||
|
if (expiradoPresentacion) {
|
||||||
|
ad.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
ad.dispose();
|
ad.dispose();
|
||||||
if (!cierreCompleter.isCompleted) cierreCompleter.complete();
|
if (!presentacionCompleter.isCompleted) {
|
||||||
|
presentacionCompleter.complete(false);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await cargado.show();
|
await cargado.show();
|
||||||
await cierreCompleter.future;
|
try {
|
||||||
return true;
|
return await presentacionCompleter.future.timeout(
|
||||||
|
timeoutPresentacionInterstitialPorDefecto,
|
||||||
|
);
|
||||||
|
} on TimeoutException {
|
||||||
|
expiradoPresentacion = true;
|
||||||
|
await cargado.dispose();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[PluriWave][anuncios] interstitial ERROR $e');
|
debugPrint('[PluriWave][anuncios] interstitial ERROR $e');
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -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<bool> 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<bool> 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<void>();
|
||||||
|
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<bool> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,14 @@ import '../servicios/servicio_anuncios.dart';
|
|||||||
/// body (`app.dart`'s `_PaginaPrincipalState.build`) — this widget itself
|
/// body (`app.dart`'s `_PaginaPrincipalState.build`) — this widget itself
|
||||||
/// never wraps its parent in a `Stack`/overlay.
|
/// never wraps its parent in a `Stack`/overlay.
|
||||||
class BannerAnuncioSuperior extends StatefulWidget {
|
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
|
@override
|
||||||
State<BannerAnuncioSuperior> createState() => _BannerAnuncioSuperiorState();
|
State<BannerAnuncioSuperior> createState() => _BannerAnuncioSuperiorState();
|
||||||
@@ -24,16 +31,34 @@ class _BannerAnuncioSuperiorState extends State<BannerAnuncioSuperior> {
|
|||||||
BannerAd? _bannerAd;
|
BannerAd? _bannerAd;
|
||||||
bool _cargado = false;
|
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
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
final servicio = context.read<ServicioAnuncios>();
|
final servicio = context.read<ServicioAnuncios>();
|
||||||
if (_bannerAd == null && servicio.debeMostrarBanner) {
|
if (!_cargaIntentada && servicio.debeMostrarBanner) {
|
||||||
|
_cargaIntentada = true;
|
||||||
_cargarBanner();
|
_cargarBanner();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _cargarBanner() {
|
void _cargarBanner() {
|
||||||
|
widget.alIntentarCargar?.call();
|
||||||
// Fire-and-forget: a failure (no plugin channel in `flutter test`, no
|
// Fire-and-forget: a failure (no plugin channel in `flutter test`, no
|
||||||
// fill, offline) leaves `_bannerAd` `null` forever, which keeps this
|
// fill, offline) leaves `_bannerAd` `null` forever, which keeps this
|
||||||
// widget collapsed — exactly the same degrade-to-shrink path a genuine
|
// widget collapsed — exactly the same degrade-to-shrink path a genuine
|
||||||
@@ -76,10 +101,22 @@ class _BannerAnuncioSuperiorState extends State<BannerAnuncioSuperior> {
|
|||||||
// transition is dropped, never shown to a now-premium user.
|
// transition is dropped, never shown to a now-premium user.
|
||||||
if (!_cargado || _bannerAd == null) return const SizedBox.shrink();
|
if (!_cargado || _bannerAd == null) return const SizedBox.shrink();
|
||||||
final ad = _bannerAd!;
|
final ad = _bannerAd!;
|
||||||
return SizedBox(
|
// FIX 1 (code review): the top-inset `SafeArea` now lives HERE, applied
|
||||||
width: ad.size.width.toDouble(),
|
// ONLY when an ad is actually about to render. `SafeArea` reserves
|
||||||
height: ad.size.height.toDouble(),
|
// `MediaQuery.padding.top` regardless of its child's own size — even a
|
||||||
child: AdWidget(ad: ad),
|
// 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),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,12 +62,58 @@ class HojaPremium extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
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)
|
if (entitlement.esPremium)
|
||||||
Padding(
|
Padding(
|
||||||
key: const ValueKey('hoja-premium-activo'),
|
key: const ValueKey('hoja-premium-activo'),
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
child: Text(
|
child: Text(
|
||||||
l10n.equalizerActive,
|
l10n.premiumActivo,
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.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
|
/// S1 (Tier 1 visual fidelity): the prototype (`t4`) never draws a global
|
||||||
/// `AppBar` — every root owns its own 56px title row instead (see
|
/// `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',
|
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<void> 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<EstadoEntitlement>(
|
||||||
|
create: (_) => EstadoEntitlement(prefs: null),
|
||||||
|
),
|
||||||
|
Provider<ServicioAnuncios>(
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,6 +181,122 @@ void main() {
|
|||||||
expect(estado.esPremium, isFalse);
|
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<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(estado.resultadoUsuario, isNull);
|
||||||
|
|
||||||
|
unawaited(estado.comprar());
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
compras.emitir(const EventoCompra(TipoEventoCompra.error));
|
||||||
|
await Future<void>.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<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
unawaited(estado.restaurar());
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
compras.emitir(const EventoCompra(TipoEventoCompra.noEncontrada));
|
||||||
|
await Future<void>.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<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
unawaited(estado.comprar());
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
compras.emitir(const EventoCompra(TipoEventoCompra.error));
|
||||||
|
await Future<void>.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<void>.delayed(Duration.zero);
|
||||||
|
compras.emitir(const EventoCompra(TipoEventoCompra.noEncontrada));
|
||||||
|
await Future<void>.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<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
unawaited(estado.comprar());
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
compras.emitir(
|
||||||
|
const EventoCompra(
|
||||||
|
TipoEventoCompra.error,
|
||||||
|
mensaje: 'Producto no encontrado en Play Console',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await Future<void>.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)', () {
|
group('esPremiumPersistido (headless, sin BuildContext)', () {
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:pluriwave/servicios/servicio_anuncios.dart';
|
import 'package:pluriwave/servicios/servicio_anuncios.dart';
|
||||||
|
|
||||||
@@ -10,10 +13,12 @@ void main() {
|
|||||||
required DateTime Function() ahora,
|
required DateTime Function() ahora,
|
||||||
required bool premium,
|
required bool premium,
|
||||||
Future<bool> Function()? mostrarInterstitialImpl,
|
Future<bool> Function()? mostrarInterstitialImpl,
|
||||||
|
Duration? timeoutIntentoInterstitial,
|
||||||
}) => ServicioAnuncios(
|
}) => ServicioAnuncios(
|
||||||
ahora: ahora,
|
ahora: ahora,
|
||||||
esPremium: () => premium,
|
esPremium: () => premium,
|
||||||
mostrarInterstitialImpl: mostrarInterstitialImpl ?? (() async => true),
|
mostrarInterstitialImpl: mostrarInterstitialImpl ?? (() async => true),
|
||||||
|
timeoutIntentoInterstitial: timeoutIntentoInterstitial,
|
||||||
);
|
);
|
||||||
|
|
||||||
group('intentarInterstitial — cap de frecuencia', () {
|
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<bool>();
|
||||||
|
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<void>.delayed(const Duration(milliseconds: 5));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
timeoutIntentoInterstitial: const Duration(milliseconds: 200),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await servicio.intentarInterstitial(), isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('debeMostrarBanner', () {
|
group('debeMostrarBanner', () {
|
||||||
test('free: true', () {
|
test('free: true', () {
|
||||||
final servicio = construir(
|
final servicio = construir(
|
||||||
|
|||||||
@@ -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<bool> 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,11 +1,34 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:pluriwave/estado/estado_entitlement.dart';
|
import 'package:pluriwave/estado/estado_entitlement.dart';
|
||||||
import 'package:pluriwave/servicios/servicio_anuncios.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:pluriwave/widgets/banner_anuncio_superior.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.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<EventoCompra>.broadcast();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<EventoCompra> get eventos => _eventos.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> comprar() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> restaurar() async {}
|
||||||
|
|
||||||
|
void emitir(EventoCompra evento) => _eventos.add(evento);
|
||||||
|
|
||||||
|
Future<void> dispose() => _eventos.close();
|
||||||
|
}
|
||||||
|
|
||||||
/// Ad-display spec "Persistent Top Banner, Never Overlapping Content": the
|
/// Ad-display spec "Persistent Top Banner, Never Overlapping Content": the
|
||||||
/// banner is entitlement-aware and reserves layout via a `Column`
|
/// banner is entitlement-aware and reserves layout via a `Column`
|
||||||
/// (`SizedBox.shrink()` collapses it to zero footprint) — never a `Stack`
|
/// (`SizedBox.shrink()` collapses it to zero footprint) — never a `Stack`
|
||||||
@@ -72,4 +95,66 @@ void main() {
|
|||||||
expect(tester.getSize(banner).height, 0);
|
expect(tester.getSize(banner).height, 0);
|
||||||
expect(find.text('contenido'), findsOneWidget);
|
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<EstadoEntitlement>(
|
||||||
|
create: (_) => EstadoEntitlement(prefs: null, compras: compras),
|
||||||
|
),
|
||||||
|
Provider<ServicioAnuncios>(
|
||||||
|
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<EstadoEntitlement>(), 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',
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<EventoCompra>.broadcast();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<EventoCompra> get eventos => _eventos.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> comprar() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> restaurar() async {}
|
||||||
|
|
||||||
|
void emitir(EventoCompra evento) => _eventos.add(evento);
|
||||||
|
|
||||||
|
Future<void> 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<EstadoEntitlement> bombear(
|
||||||
|
WidgetTester tester, {
|
||||||
|
required _PuertoComprasFalso compras,
|
||||||
|
}) async {
|
||||||
|
late EstadoEntitlement estado;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MultiProvider(
|
||||||
|
providers: [
|
||||||
|
ChangeNotifierProvider<EstadoEntitlement>(
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user