import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../core/api/api_client_provider.dart'; import '../../../core/constants.dart'; import '../../../core/theme/halo_tokens.dart'; import '../../../core/theme/widgets/halo_button.dart'; import '../state/payment_draft_provider.dart'; /// "Cara bayar" — QRIS-first list of payment methods. On tap of `bayar`: /// 1. POST `/api/client/payment-sessions` with the draft + chosen method. /// 2. Push `/payment/waiting/:paymentId`. class PaymentMethodScreen extends ConsumerStatefulWidget { const PaymentMethodScreen({super.key}); @override ConsumerState createState() => _PaymentMethodScreenState(); } enum _PayMethod { qris('qris', 'QRIS', 'semua e-wallet & m-banking', '🔲', recommended: true), ovo('ovo', 'OVO', 'saldo OVO', '🟣'), gopay('gopay', 'GoPay', 'saldo GoPay', '🟢'), dana('dana', 'DANA', 'saldo DANA', '🔵'), shopee('shopee', 'ShopeePay', 'saldo ShopeePay', '🟠'); final String id; final String label; final String sub; final String icon; final bool recommended; const _PayMethod( this.id, this.label, this.sub, this.icon, { this.recommended = false, }); } class _PaymentMethodScreenState extends ConsumerState { _PayMethod _selected = _PayMethod.qris; bool _submitting = false; String? _error; Future _onPay() async { if (_submitting) return; final draft = ref.read(paymentDraftNotifierProvider); if (draft.durationMinutes == null || draft.priceIDR == null) { setState(() => _error = 'Pilih durasi dulu sebelum bayar.'); return; } setState(() { _submitting = true; _error = null; }); final api = ref.read(apiClientProvider); try { final body = { 'mode': draft.mode.value, 'duration_minutes': draft.durationMinutes, 'price_idr': draft.priceIDR, 'is_first_session_discount': draft.isFirstSessionDiscount, 'method': _selected.id, }; // Trailing slash matches the existing payment_notifier path — Fastify // is not configured with `ignoreTrailingSlash`. final response = await api.post('/api/client/payment-sessions/', data: body); final data = response['data'] as Map; final paymentId = data['id'] as String; ref.read(paymentDraftNotifierProvider.notifier).setPaymentId(paymentId); if (!mounted) return; context.push('/payment/waiting/$paymentId'); } on DioException catch (e) { if (!mounted) return; setState(() { _submitting = false; _error = _humanError(e); }); } catch (_) { if (!mounted) return; setState(() { _submitting = false; _error = 'Gagal membuat sesi pembayaran.'; }); } } String _humanError(DioException e) { final code = e.response?.data?['error']?['code'] as String?; final status = e.response?.statusCode; if (status == 422 || code == 'VALIDATION_ERROR' || code == 'INVALID_TIER') { return 'Pilihan durasi tidak valid.'; } if (status == 403) return 'Sesi tidak diizinkan.'; if (status == 404) return 'Sesi pembayaran tidak ditemukan.'; return 'Gagal membuat sesi pembayaran.'; } @override Widget build(BuildContext context) { final draft = ref.watch(paymentDraftNotifierProvider); final amount = draft.priceIDR ?? 0; final durationLabel = draft.durationMinutes != null ? 'sesi ${draft.durationMinutes} menit' : 'sesi'; final amountLabel = formatRupiah(amount); final recommended = _PayMethod.values.where((m) => m.recommended).toList(); final others = _PayMethod.values.where((m) => !m.recommended).toList(); return 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('/payment/duration-pick'); } }, ), title: const Text( 'cara bayar', style: TextStyle( fontFamily: HaloTokens.fontDisplay, fontSize: 18, fontWeight: FontWeight.w700, color: HaloTokens.brandDark, ), ), centerTitle: false, ), body: Column( children: [ Padding( padding: const EdgeInsets.fromLTRB( HaloSpacing.s24, HaloSpacing.s4, HaloSpacing.s24, HaloSpacing.s12, ), child: Container( padding: const EdgeInsets.all(HaloSpacing.s12), decoration: BoxDecoration( color: HaloTokens.surface, borderRadius: HaloRadius.lg, border: Border.all(color: HaloTokens.border), ), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'total bayar', style: TextStyle(fontSize: 11.5, color: HaloTokens.inkSoft), ), Text( durationLabel, style: const TextStyle(fontSize: 11, color: HaloTokens.inkMuted), ), ], ), ), Text( amountLabel, style: const TextStyle( fontFamily: HaloTokens.fontDisplay, fontSize: 22, fontWeight: FontWeight.w700, color: HaloTokens.brandDark, letterSpacing: -0.5, ), ), ], ), ), ), Expanded( child: ListView( padding: const EdgeInsets.fromLTRB( HaloSpacing.s20, HaloSpacing.s8, HaloSpacing.s20, HaloSpacing.s16, ), children: [ const _SectionLabel('paling cepat'), ...recommended.map((m) => _MethodTile( method: m, selected: _selected == m, onTap: () => setState(() => _selected = m), large: true, )), const SizedBox(height: HaloSpacing.s8), const _SectionLabel('e-wallet lain'), ...others.map((m) => _MethodTile( method: m, selected: _selected == m, onTap: () => setState(() => _selected = m), )), ], ), ), if (_error != null) Container( margin: const EdgeInsets.fromLTRB( HaloSpacing.s24, 0, HaloSpacing.s24, HaloSpacing.s8, ), padding: const EdgeInsets.all(HaloSpacing.s12), decoration: BoxDecoration( color: const Color(0xFFFFEBEB), borderRadius: HaloRadius.md, border: Border.all(color: HaloTokens.danger), ), child: Row( children: [ const Icon(Icons.error_outline, color: HaloTokens.danger, size: 18), const SizedBox(width: HaloSpacing.s8), Expanded( child: Text( _error!, style: const TextStyle(color: HaloTokens.danger, fontSize: 13), ), ), ], ), ), Container( padding: const EdgeInsets.fromLTRB( HaloSpacing.s24, HaloSpacing.s12, HaloSpacing.s24, HaloSpacing.s32, ), decoration: const BoxDecoration( color: HaloTokens.bg, border: Border(top: BorderSide(color: HaloTokens.border)), ), child: HaloButton( label: _submitting ? 'memproses...' : 'bayar $amountLabel', size: HaloButtonSize.lg, fullWidth: true, onPressed: _submitting ? null : _onPay, ), ), ], ), ); } } class _SectionLabel extends StatelessWidget { final String label; const _SectionLabel(this.label); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(4, HaloSpacing.s8, 4, HaloSpacing.s8), child: Text( label, style: const TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: HaloTokens.inkSoft, letterSpacing: 0.6, ), ), ); } } class _MethodTile extends StatelessWidget { final _PayMethod method; final bool selected; final VoidCallback onTap; final bool large; const _MethodTile({ required this.method, required this.selected, required this.onTap, this.large = false, }); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(bottom: HaloSpacing.s8), child: Material( color: selected ? HaloTokens.brandSofter : HaloTokens.surface, borderRadius: HaloRadius.lg, child: InkWell( borderRadius: HaloRadius.lg, onTap: onTap, child: AnimatedContainer( duration: HaloMotion.fast, padding: EdgeInsets.all(large ? HaloSpacing.s16 : HaloSpacing.s12), decoration: BoxDecoration( border: Border.all( color: selected ? HaloTokens.brand : HaloTokens.border, width: selected ? 2 : 1, ), borderRadius: HaloRadius.lg, ), child: Row( children: [ Container( width: large ? 40 : 36, height: large ? 40 : 36, decoration: BoxDecoration( color: HaloTokens.surface, borderRadius: HaloRadius.md, border: Border.all(color: HaloTokens.border), ), alignment: Alignment.center, child: Text(method.icon, style: TextStyle(fontSize: large ? 20 : 18)), ), const SizedBox(width: HaloSpacing.s12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( method.label, style: TextStyle( fontSize: large ? 14.5 : 13.5, fontWeight: FontWeight.w600, color: HaloTokens.ink, ), ), if (method.recommended) ...[ const SizedBox(width: HaloSpacing.s8), Container( padding: const EdgeInsets.symmetric( horizontal: HaloSpacing.s8, vertical: 2, ), decoration: const BoxDecoration( color: HaloTokens.mint, borderRadius: HaloRadius.pill, ), child: const Text( 'DIREKOMENDASIKAN', style: TextStyle( fontSize: 9, fontWeight: FontWeight.w700, color: Color(0xFF1F4D34), letterSpacing: 0.4, ), ), ), ], ], ), const SizedBox(height: 2), Text( method.sub, style: TextStyle( fontSize: large ? 11.5 : 11, color: HaloTokens.inkSoft, ), ), ], ), ), Container( width: large ? 20 : 18, height: large ? 20 : 18, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( color: selected ? HaloTokens.brand : HaloTokens.border, width: 2, ), color: selected ? HaloTokens.brand : HaloTokens.surface, ), child: selected ? Center( child: Container( width: large ? 8 : 6, height: large ? 8 : 6, decoration: const BoxDecoration( shape: BoxShape.circle, color: Colors.white, ), ), ) : null, ), ], ), ), ), ), ); } }