import 'dart:async'; import 'package:flutter/foundation.dart' show debugPrint; import 'package:in_app_purchase/in_app_purchase.dart'; /// Outcome kinds the purchase stream can report (Design ADR-2). Mirrors /// `in_app_purchase`'s [PurchaseStatus] but stays a PluriWave-owned type so /// [EstadoEntitlement] never imports the plugin package directly — the SAME /// port-boundary discipline `PuertoAlarmasAndroid` already applies. enum TipoEventoCompra { /// A fresh purchase completed successfully. comprada, /// [PuertoCompras.restaurar] found a prior purchase. restaurada, /// The user cancelled the purchase flow before it completed. cancelada, /// The purchase/restore flow failed (network, billing error, etc). error, /// [PuertoCompras.restaurar] completed with nothing to restore — NOT an /// error (Spec "Restore finds nothing"). noEncontrada, /// A purchase is in-flight (billing dialog shown, awaiting the user). pendiente, } /// A single purchase-stream event (Design ADR-2). [mensaje] is populated /// only for [TipoEventoCompra.error], for diagnostics/logging — never shown /// to the user verbatim. class EventoCompra { const EventoCompra(this.tipo, {this.mensaje}); final TipoEventoCompra tipo; final String? mensaje; } /// Purchase I/O abstraction (Design ADR-2): [EstadoEntitlement] depends on /// this port, never on `in_app_purchase` directly — matches /// `EstadoAlarmas(android: PuertoAlarmasAndroid)`'s injection shape, and /// keeps Strict TDD viable with zero plugin channels in unit tests. abstract class PuertoCompras { /// Broadcasts every purchase-flow outcome (Design ADR-2) — [comprar] and /// [restaurar] do not return the outcome directly because /// `in_app_purchase`'s own API is stream-based (a purchase can complete /// asynchronously well after the call that started it, e.g. after leaving /// and returning to the app). Stream get eventos; /// Starts the one-time non-consumable purchase flow. Future comprar(); /// Re-queries Play Billing for a prior purchase on this account. Future restaurar(); } /// The SOLE `in_app_purchase` call site (Design ADR-2) — every other file /// depends on [PuertoCompras] instead. class ServicioComprasPlayBilling implements PuertoCompras { ServicioComprasPlayBilling({InAppPurchase? inAppPurchase}) : _iap = inAppPurchase ?? InAppPurchase.instance { _sub = _iap.purchaseStream.listen( _alRecibirCompras, onError: (Object error) { debugPrint('[PluriWave][compras] purchaseStream ERROR $error'); _eventos.add( EventoCompra(TipoEventoCompra.error, mensaje: error.toString()), ); }, ); } /// The single non-consumable product id (Design "Interfaces / Contracts"). static const idProducto = 'pluriwave_premium'; final InAppPurchase _iap; final _eventos = StreamController.broadcast(); StreamSubscription>? _sub; @override Stream get eventos => _eventos.stream; @override Future comprar() async { try { final disponible = await _iap.isAvailable(); if (!disponible) { _eventos.add( const EventoCompra( TipoEventoCompra.error, mensaje: 'Play Billing no disponible', ), ); return; } final respuesta = await _iap.queryProductDetails({idProducto}); final detalle = respuesta.productDetails.firstOrNull; if (detalle == null) { _eventos.add( const EventoCompra( TipoEventoCompra.error, mensaje: 'Producto no encontrado en Play Console', ), ); return; } final parametros = PurchaseParam(productDetails: detalle); await _iap.buyNonConsumable(purchaseParam: parametros); } catch (e) { debugPrint('[PluriWave][compras] comprar ERROR $e'); _eventos.add(EventoCompra(TipoEventoCompra.error, mensaje: e.toString())); } } @override Future restaurar() async { try { await _iap.restorePurchases(); } catch (e) { debugPrint('[PluriWave][compras] restaurar ERROR $e'); _eventos.add(EventoCompra(TipoEventoCompra.error, mensaje: e.toString())); } } void _alRecibirCompras(List compras) { if (compras.isEmpty) { // `restorePurchases()` with nothing to restore pushes an EMPTY batch // (`in_app_purchase_android` does `_purchaseUpdatedController.add( // pastPurchases)` unconditionally) — there is no per-call correlation // in this stream, so this fires on ANY empty batch. In practice // `restorePurchases` on an account with nothing to restore is the only // source of an empty batch this stream would ever emit. // // Returning silently here (as this did before) left // [TipoEventoCompra.noEncontrada] NEVER emitted, so // `EstadoEntitlement._compraEnCurso` stayed `true` forever and // `hoja_premium.dart` kept BOTH buttons disabled — restore AND buy. // A paywall that cannot be paid. _eventos.add(const EventoCompra(TipoEventoCompra.noEncontrada)); return; } for (final compra in compras) { _eventos.add( eventoDesdeEstadoCompra(compra.status, mensaje: compra.error?.message), ); if (compra.pendingCompletePurchase) { unawaited(_iap.completePurchase(compra)); } } } Future dispose() async { await _sub?.cancel(); await _eventos.close(); } } /// Pure mapping from `in_app_purchase`'s [PurchaseStatus] to the /// PluriWave-owned [EventoCompra] (Design ADR-2's port boundary): pulled out /// of [ServicioComprasPlayBilling] so it is unit-testable with zero plugin /// channels, mirroring how `servicio_audio.dart` extracts its pure mapping /// helpers (e.g. `mapearEstadoProceso`) out of the un-instantiable handler. EventoCompra eventoDesdeEstadoCompra(PurchaseStatus status, {String? mensaje}) { return switch (status) { PurchaseStatus.pending => const EventoCompra(TipoEventoCompra.pendiente), PurchaseStatus.purchased => const EventoCompra(TipoEventoCompra.comprada), PurchaseStatus.restored => const EventoCompra(TipoEventoCompra.restaurada), PurchaseStatus.error => EventoCompra( TipoEventoCompra.error, mensaje: mensaje, ), PurchaseStatus.canceled => const EventoCompra(TipoEventoCompra.cancelada), }; } extension on List { T? get firstOrNull => isEmpty ? null : first; }