import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:qr_flutter/qr_flutter.dart'; import '../../../core/api/api_client_provider.dart'; import '../../../core/constants.dart'; import '../../../core/theme/halo_tokens.dart'; import '../state/payment_draft_provider.dart'; import 'xendit_checkout_screen.dart'; /// "Waiting payment" — placeholder QR + 20-minute countdown header. Polls the /// backend every 3 seconds for status changes. On `confirmed` the payment is /// considered paid; on `expired` we route to the expired screen. /// /// Polling is paused while the app is backgrounded and resumed on foreground /// (per the `WidgetsBindingObserver` pattern used elsewhere in the app). class WaitingPaymentScreen extends ConsumerStatefulWidget { final String paymentId; const WaitingPaymentScreen({super.key, required this.paymentId}); @override ConsumerState createState() => _WaitingPaymentScreenState(); } class _WaitingPaymentScreenState extends ConsumerState with WidgetsBindingObserver { static const Duration _pollInterval = Duration(seconds: 3); static const Duration _tickInterval = Duration(seconds: 1); Timer? _ticker; Timer? _poller; DateTime? _expiresAt; int _amount = 0; String? _qrPayload; // Phase 5 (Xendit on): when the backend's payment session payload includes // `xendit_invoice_url` (or legacy `invoice_url`), we host it inside the app // via [XenditCheckoutScreen] instead of rendering the QR mock. The QR path // still applies when XENDIT_ENABLED=false (dev/Maestro). String? _invoiceUrl; bool _initialLoading = true; bool _terminal = false; String? _error; // Auto-push the embedded WebView once on first load. The "Buka ulang // halaman pembayaran" button can re-push manually after that. bool _invoiceUrlLaunched = false; Duration get _remaining { final exp = _expiresAt; if (exp == null) return Duration.zero; final left = exp.difference(DateTime.now()); return left.isNegative ? Duration.zero : left; } @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); Future.microtask(_loadInitial); } @override void dispose() { _ticker?.cancel(); _poller?.cancel(); WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { _resumePolling(); } else if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive) { _poller?.cancel(); _poller = null; } } Future _loadInitial() async { final session = await _fetchSession(); if (!mounted || session == null) return; final expiresAtRaw = session['expires_at'] as String?; final expiresAt = expiresAtRaw != null ? DateTime.tryParse(expiresAtRaw) : null; final invoiceUrl = (session['xendit_invoice_url'] as String?) ?? (session['invoice_url'] as String?); setState(() { _amount = (session['amount'] as int?) ?? 0; _expiresAt = expiresAt; _qrPayload = (session['qr_string'] as String?) ?? widget.paymentId; _invoiceUrl = (invoiceUrl != null && invoiceUrl.isNotEmpty) ? invoiceUrl : null; _initialLoading = false; }); // Phase 5: when Xendit is on, host the invoice page inside the app via // an embedded WebView (XenditCheckoutScreen). Auto-push once on first // load; the "Buka ulang halaman pembayaran" button re-pushes manually if // the customer accidentally closed it. When Xendit is off (dev/Maestro), // _invoiceUrl is null and the QR fallback is rendered instead. _maybeOpenCheckoutScreen(); _maybeHandleStatus(session); _startTicker(); _resumePolling(); } /// Opens the embedded Xendit WebView. Auto-called once on first load when /// `_invoiceUrl` is non-null; also wired to the "Buka ulang halaman /// pembayaran" button (which forces a re-push regardless of the flag). Future _maybeOpenCheckoutScreen({bool force = false}) async { final url = _invoiceUrl; if (url == null) return; if (!force && _invoiceUrlLaunched) return; _invoiceUrlLaunched = true; if (!mounted) return; final result = await Navigator.of(context).push( MaterialPageRoute( builder: (_) => XenditCheckoutScreen( invoiceUrl: url, paymentId: widget.paymentId, ), ), ); // Pop result is mostly informational — the polling loop is still the // source of truth for terminal status. We just trigger an immediate poll // so the user doesn't have to wait for the next 3s tick. if (!mounted) return; if (result == 'success' || result == 'failure' || result == 'deeplink') { _pollOnce(); } } void _startTicker() { _ticker?.cancel(); _ticker = Timer.periodic(_tickInterval, (_) { if (!mounted) return; // Trigger a rebuild to refresh the countdown label. Status routing // happens off the polled response, not the local clock — backend is // the source of truth for `expired`. setState(() {}); }); } void _resumePolling() { if (_terminal) return; _poller?.cancel(); _poller = Timer.periodic(_pollInterval, (_) => _pollOnce()); } Future _pollOnce() async { final session = await _fetchSession(); if (!mounted || session == null) return; _maybeHandleStatus(session); } Future?> _fetchSession() async { try { final api = ref.read(apiClientProvider); final response = await api.get('/api/client/payment-requests/${widget.paymentId}'); return response['data'] as Map?; } catch (e) { if (!mounted) return null; setState(() => _error = 'Gagal memeriksa status pembayaran.'); return null; } } void _maybeHandleStatus(Map session) { final status = session['status'] as String?; if (status == PaymentRequestStatus.confirmed || status == PaymentRequestStatus.consumed) { _markTerminal(); _navigateTerminal('/onboarding/notif-gate'); } else if (status == PaymentRequestStatus.expired || status == PaymentRequestStatus.abandoned) { _markTerminal(); _navigateTerminal('/payment/expired/${widget.paymentId}'); } } /// Routes off the waiting screen once the payment session reached a /// terminal status. Belt-and-braces: /// - `Future.microtask` runs after the current event loop turn (after any /// pending setState), so we don't fight an in-flight build. /// - `addPostFrameCallback` is a fallback in case the microtask is /// pre-empted (observed once on release builds where the screen stayed /// visually stuck on "menunggu pembayaran" despite polling having /// stopped — see 2026-05-14 thread). void _navigateTerminal(String route) { Future.microtask(() { if (!mounted) return; context.go(route); }); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; // No-op if the microtask already navigated — `go` to the same location // is idempotent in GoRouter. context.go(route); }); } void _markTerminal() { _terminal = true; _ticker?.cancel(); _poller?.cancel(); } String _countdownLabel() { final r = _remaining; final mm = r.inMinutes; final ss = r.inSeconds % 60; return '${mm.toString().padLeft(2, '0')}:${ss.toString().padLeft(2, '0')}'; } @override Widget build(BuildContext context) { return PopScope( canPop: true, child: Scaffold( backgroundColor: HaloTokens.bg, appBar: AppBar( backgroundColor: HaloTokens.bg, elevation: 0, leading: IconButton( icon: const Icon(Icons.chevron_left, color: HaloTokens.brandDark), onPressed: () { if (context.canPop()) { context.pop(); } else { context.go('/home'); } }, ), centerTitle: true, title: Column( mainAxisSize: MainAxisSize.min, children: [ const Text( 'kedaluwarsa dalam', style: TextStyle(fontSize: 11, color: HaloTokens.inkMuted), ), Text( _initialLoading ? '--:--' : _countdownLabel(), style: const TextStyle( fontFamily: HaloTokens.fontMono, fontSize: 16, fontWeight: FontWeight.w700, color: HaloTokens.brandDark, letterSpacing: -0.5, ), ), ], ), ), body: _initialLoading ? const Center(child: CircularProgressIndicator()) : _buildContent(), ), ); } Widget _buildContent() { final draft = ref.watch(paymentDraftNotifierProvider); final amount = _amount > 0 ? _amount : (draft.priceIDR ?? 0); return Column( children: [ Expanded( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB( HaloSpacing.s24, HaloSpacing.s8, HaloSpacing.s24, HaloSpacing.s16, ), child: Column( children: [ Container( padding: const EdgeInsets.all(HaloSpacing.s24), decoration: BoxDecoration( color: HaloTokens.surface, borderRadius: HaloRadius.xl, border: Border.all(color: HaloTokens.border), ), child: _invoiceUrl != null ? _buildInvoiceCard(amount) : _buildQrCard(amount), ), const SizedBox(height: HaloSpacing.s12), Container( padding: const EdgeInsets.all(HaloSpacing.s12), decoration: BoxDecoration( color: HaloTokens.brandSofter, borderRadius: HaloRadius.md, border: Border.all(color: HaloTokens.brandSoft), ), child: Row( children: [ Container( width: 8, height: 8, decoration: const BoxDecoration( shape: BoxShape.circle, color: HaloTokens.brand, ), ), const SizedBox(width: HaloSpacing.s8), const Expanded( child: Text( 'menunggu pembayaran kamu...', style: TextStyle( fontSize: 12, color: HaloTokens.brandDark, ), ), ), ], ), ), if (_error != null) ...[ const SizedBox(height: HaloSpacing.s8), Text( _error!, style: const TextStyle(fontSize: 12, color: HaloTokens.danger), ), ], ], ), ), ), ], ); } /// Mock QRIS card — used when `xendit_invoice_url` is absent /// (`XENDIT_ENABLED=false`, i.e. dev/Maestro). Widget _buildQrCard(int amount) { return Column( children: [ const Text( 'scan QRIS untuk bayar', style: TextStyle(fontSize: 12, color: HaloTokens.inkSoft), ), const SizedBox(height: HaloSpacing.s12), Container( padding: const EdgeInsets.all(HaloSpacing.s12), decoration: BoxDecoration( color: HaloTokens.surface, borderRadius: HaloRadius.lg, border: Border.all(color: HaloTokens.border), ), child: QrImageView( data: _qrPayload ?? widget.paymentId, size: 200, version: QrVersions.auto, backgroundColor: HaloTokens.surface, eyeStyle: const QrEyeStyle( eyeShape: QrEyeShape.square, color: HaloTokens.ink, ), dataModuleStyle: const QrDataModuleStyle( dataModuleShape: QrDataModuleShape.square, color: HaloTokens.ink, ), ), ), const SizedBox(height: HaloSpacing.s12), const Text( 'jumlah', style: TextStyle(fontSize: 11.5, color: HaloTokens.inkSoft), ), Text( formatRupiah(amount), style: const TextStyle( fontFamily: HaloTokens.fontDisplay, fontSize: 24, fontWeight: FontWeight.w700, color: HaloTokens.brandDark, letterSpacing: -0.5, ), ), ], ); } /// Spinner + "Buka ulang halaman pembayaran" — used when the backend /// returned a Xendit invoice URL. The WebView screen is pushed /// automatically on first load; the button lets the customer reopen it /// if they accidentally closed it. Widget _buildInvoiceCard(int amount) { return Column( children: [ const SizedBox( width: 56, height: 56, child: CircularProgressIndicator( strokeWidth: 3, valueColor: AlwaysStoppedAnimation(HaloTokens.brand), ), ), const SizedBox(height: HaloSpacing.s16), const Text( 'Membuka halaman pembayaran…', textAlign: TextAlign.center, style: TextStyle( fontSize: 13, color: HaloTokens.inkSoft, fontWeight: FontWeight.w500, ), ), const SizedBox(height: HaloSpacing.s12), const Text( 'jumlah', style: TextStyle(fontSize: 11.5, color: HaloTokens.inkSoft), ), Text( formatRupiah(amount), style: const TextStyle( fontFamily: HaloTokens.fontDisplay, fontSize: 24, fontWeight: FontWeight.w700, color: HaloTokens.brandDark, letterSpacing: -0.5, ), ), const SizedBox(height: HaloSpacing.s16), SizedBox( width: double.infinity, child: OutlinedButton.icon( onPressed: () => _maybeOpenCheckoutScreen(force: true), icon: const Icon(Icons.open_in_browser, size: 18), label: const Text('Buka ulang halaman pembayaran'), style: OutlinedButton.styleFrom( foregroundColor: HaloTokens.brandDark, side: const BorderSide(color: HaloTokens.brandSoft), padding: const EdgeInsets.symmetric(vertical: HaloSpacing.s12), shape: const RoundedRectangleBorder( borderRadius: HaloRadius.md, ), textStyle: const TextStyle( fontSize: 13, fontWeight: FontWeight.w600, ), ), ), ), ], ); } }