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_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<Offset>(
|
||||
begin: const Offset(0.035, 0),
|
||||
end: Offset.zero,
|
||||
).animate(animation),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: KeyedSubtree(
|
||||
key: ValueKey<int>(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<Offset>(
|
||||
begin: const Offset(0.035, 0),
|
||||
end: Offset.zero,
|
||||
).animate(animation),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: KeyedSubtree(
|
||||
key: ValueKey<int>(indice),
|
||||
child: _paginas[indice],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
top: false,
|
||||
|
||||
@@ -30,6 +30,26 @@ Future<bool> 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<void> _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;
|
||||
|
||||
+4
-1
@@ -901,5 +901,8 @@
|
||||
"funcionPremium": "ميزة مميزة",
|
||||
"limiteAlarmasAlcanzado": "لقد وصلت إلى الحد المجاني وهو 5 منبهات.",
|
||||
"desbloquearPremium": "فتح النسخة المميزة",
|
||||
"restaurarCompras": "استعادة المشتريات"
|
||||
"restaurarCompras": "استعادة المشتريات",
|
||||
"compraError": "تعذّر إتمام عملية الشراء. حاول مرة أخرى.",
|
||||
"restauracionSinCompras": "لم نجد أي عملية شراء سابقة في هذا الحساب.",
|
||||
"premiumActivo": "النسخة المميزة مفعّلة"
|
||||
}
|
||||
|
||||
+4
-1
@@ -901,5 +901,8 @@
|
||||
"funcionPremium": "প্রিমিয়াম বৈশিষ্ট্য",
|
||||
"limiteAlarmasAlcanzado": "আপনি বিনামূল্যে ৫টি অ্যালার্মের সীমায় পৌঁছেছেন।",
|
||||
"desbloquearPremium": "প্রিমিয়াম আনলক করুন",
|
||||
"restaurarCompras": "কেনাকাটা পুনরুদ্ধার করুন"
|
||||
"restaurarCompras": "কেনাকাটা পুনরুদ্ধার করুন",
|
||||
"compraError": "কেনাকাটা সম্পূর্ণ করা যায়নি। আবার চেষ্টা করুন।",
|
||||
"restauracionSinCompras": "এই অ্যাকাউন্টে আমরা আগের কোনো কেনাকাটা খুঁজে পাইনি।",
|
||||
"premiumActivo": "প্রিমিয়াম সক্রিয়"
|
||||
}
|
||||
|
||||
+4
-1
@@ -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"
|
||||
}
|
||||
|
||||
+4
-1
@@ -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"
|
||||
}
|
||||
|
||||
+4
-1
@@ -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"
|
||||
}
|
||||
|
||||
+4
-1
@@ -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"
|
||||
}
|
||||
|
||||
+4
-1
@@ -901,5 +901,8 @@
|
||||
"funcionPremium": "प्रीमियम सुविधा",
|
||||
"limiteAlarmasAlcanzado": "आप मुफ़्त 5 अलार्म की सीमा तक पहुँच गए हैं।",
|
||||
"desbloquearPremium": "प्रीमियम अनलॉक करें",
|
||||
"restaurarCompras": "खरीदारी पुनर्स्थापित करें"
|
||||
"restaurarCompras": "खरीदारी पुनर्स्थापित करें",
|
||||
"compraError": "खरीद पूरी नहीं हो सकी। कृपया फिर से प्रयास करें।",
|
||||
"restauracionSinCompras": "इस खाते में हमें कोई पिछली खरीद नहीं मिली।",
|
||||
"premiumActivo": "प्रीमियम सक्रिय"
|
||||
}
|
||||
|
||||
+4
-1
@@ -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"
|
||||
}
|
||||
|
||||
+4
-1
@@ -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"
|
||||
}
|
||||
|
||||
+4
-1
@@ -901,5 +901,8 @@
|
||||
"funcionPremium": "プレミアム機能",
|
||||
"limiteAlarmasAlcanzado": "無料プランのアラーム上限(5件)に達しました。",
|
||||
"desbloquearPremium": "プレミアムを解除",
|
||||
"restaurarCompras": "購入を復元"
|
||||
"restaurarCompras": "購入を復元",
|
||||
"compraError": "購入を完了できませんでした。もう一度お試しください。",
|
||||
"restauracionSinCompras": "このアカウントでは以前の購入が見つかりませんでした。",
|
||||
"premiumActivo": "プレミアム有効"
|
||||
}
|
||||
|
||||
+4
-1
@@ -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"
|
||||
}
|
||||
|
||||
+4
-1
@@ -901,5 +901,8 @@
|
||||
"funcionPremium": "Премиум-функция",
|
||||
"limiteAlarmasAlcanzado": "Вы достигли бесплатного лимита в 5 будильников.",
|
||||
"desbloquearPremium": "Разблокировать Премиум",
|
||||
"restaurarCompras": "Восстановить покупки"
|
||||
"restaurarCompras": "Восстановить покупки",
|
||||
"compraError": "Не удалось завершить покупку. Попробуйте ещё раз.",
|
||||
"restauracionSinCompras": "Мы не нашли предыдущих покупок на этом аккаунте.",
|
||||
"premiumActivo": "Премиум активен"
|
||||
}
|
||||
|
||||
+4
-1
@@ -901,5 +901,8 @@
|
||||
"funcionPremium": "高级功能",
|
||||
"limiteAlarmasAlcanzado": "您已达到免费版 5 个闹钟的上限。",
|
||||
"desbloquearPremium": "解锁高级版",
|
||||
"restaurarCompras": "恢复购买"
|
||||
"restaurarCompras": "恢复购买",
|
||||
"compraError": "无法完成购买,请重试。",
|
||||
"restauracionSinCompras": "未在此账户中找到以前的购买记录。",
|
||||
"premiumActivo": "高级版已解锁"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1853,4 +1853,14 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get restaurarCompras => 'استعادة المشتريات';
|
||||
|
||||
@override
|
||||
String get compraError => 'تعذّر إتمام عملية الشراء. حاول مرة أخرى.';
|
||||
|
||||
@override
|
||||
String get restauracionSinCompras =>
|
||||
'لم نجد أي عملية شراء سابقة في هذا الحساب.';
|
||||
|
||||
@override
|
||||
String get premiumActivo => 'النسخة المميزة مفعّلة';
|
||||
}
|
||||
|
||||
@@ -1864,4 +1864,14 @@ class AppLocalizationsBn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get restaurarCompras => 'কেনাকাটা পুনরুদ্ধার করুন';
|
||||
|
||||
@override
|
||||
String get compraError => 'কেনাকাটা সম্পূর্ণ করা যায়নি। আবার চেষ্টা করুন।';
|
||||
|
||||
@override
|
||||
String get restauracionSinCompras =>
|
||||
'এই অ্যাকাউন্টে আমরা আগের কোনো কেনাকাটা খুঁজে পাইনি।';
|
||||
|
||||
@override
|
||||
String get premiumActivo => 'প্রিমিয়াম সক্রিয়';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -1857,4 +1857,14 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get restaurarCompras => 'खरीदारी पुनर्स्थापित करें';
|
||||
|
||||
@override
|
||||
String get compraError => 'खरीद पूरी नहीं हो सकी। कृपया फिर से प्रयास करें।';
|
||||
|
||||
@override
|
||||
String get restauracionSinCompras =>
|
||||
'इस खाते में हमें कोई पिछली खरीद नहीं मिली।';
|
||||
|
||||
@override
|
||||
String get premiumActivo => 'प्रीमियम सक्रिय';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -1803,4 +1803,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get restaurarCompras => '購入を復元';
|
||||
|
||||
@override
|
||||
String get compraError => '購入を完了できませんでした。もう一度お試しください。';
|
||||
|
||||
@override
|
||||
String get restauracionSinCompras => 'このアカウントでは以前の購入が見つかりませんでした。';
|
||||
|
||||
@override
|
||||
String get premiumActivo => 'プレミアム有効';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -1874,4 +1874,14 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get restaurarCompras => 'Восстановить покупки';
|
||||
|
||||
@override
|
||||
String get compraError => 'Не удалось завершить покупку. Попробуйте ещё раз.';
|
||||
|
||||
@override
|
||||
String get restauracionSinCompras =>
|
||||
'Мы не нашли предыдущих покупок на этом аккаунте.';
|
||||
|
||||
@override
|
||||
String get premiumActivo => 'Премиум активен';
|
||||
}
|
||||
|
||||
@@ -1788,4 +1788,13 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
|
||||
@override
|
||||
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: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<void> 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
|
||||
|
||||
@@ -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<bool> 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<bool> Function() _mostrarInterstitialImpl;
|
||||
final Duration _timeoutIntentoInterstitial;
|
||||
|
||||
int _mostrados = 0;
|
||||
DateTime? _ultimoMostrado;
|
||||
@@ -83,7 +107,14 @@ class ServicioAnuncios {
|
||||
/// interruptions, not load attempts).
|
||||
Future<bool> 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<bool> _mostrarInterstitialAdMob() async {
|
||||
try {
|
||||
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(
|
||||
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<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(
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
/// 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<BannerAnuncioSuperior> createState() => _BannerAnuncioSuperiorState();
|
||||
@@ -24,16 +31,34 @@ class _BannerAnuncioSuperiorState extends State<BannerAnuncioSuperior> {
|
||||
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<ServicioAnuncios>();
|
||||
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<BannerAnuncioSuperior> {
|
||||
// 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user