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'; /// "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; bool _initialLoading = true; bool _terminal = false; String? _error; 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; setState(() { _amount = (session['amount'] as int?) ?? 0; _expiresAt = expiresAt; _qrPayload = (session['qr_string'] as String?) ?? widget.paymentId; _initialLoading = false; }); _maybeHandleStatus(session); _startTicker(); _resumePolling(); } 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-sessions/${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 == PaymentSessionStatus.confirmed || status == PaymentSessionStatus.consumed) { _markTerminal(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; context.go('/onboarding/notif-gate'); }); } else if (status == PaymentSessionStatus.expired || status == PaymentSessionStatus.abandoned) { _markTerminal(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; context.go('/payment/expired/${widget.paymentId}'); }); } } 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: 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, ), ), ], ), ), 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), ), ], ], ), ), ), ], ); } }