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_catalog_provider.dart'; import '../state/payment_draft_provider.dart'; import '../widgets/payment_icon.dart'; /// "Cara bayar" — catalog-driven payment method picker. Methods are grouped /// into collapsible sections sourced from `paymentCatalogProvider`; the user /// picks one, then taps `bayar` which: /// 1. POSTs `/api/client/payment-requests` with the draft + chosen /// `payment_code`. /// 2. Pushes `/payment/waiting/:paymentId`. /// /// First group is expanded by default; the rest are collapsed. Empty groups /// are hidden by the backend before they reach this screen. class PaymentMethodScreen extends ConsumerStatefulWidget { const PaymentMethodScreen({super.key}); @override ConsumerState createState() => _PaymentMethodScreenState(); } class _PaymentMethodScreenState extends ConsumerState { String? _selectedCode; bool _submitting = false; String? _error; /// Track which groups are expanded. Keyed by group id; default-expanded for /// the first group is applied lazily inside `build` when we first see the /// catalog (we don't know the group ids until then). final Set _expandedGroupIds = {}; bool _initialExpansionDone = false; 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; } if (_selectedCode == null) { setState(() => _error = 'Pilih metode pembayaran dulu.'); 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': _selectedCode, if (draft.targetedMitraId != null) 'targeted_mitra_id': draft.targetedMitraId, }; final response = await api.post('/api/client/payment-requests/', 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 (code == 'INVALID_PAYMENT_METHOD') { return 'Metode pembayaran tidak tersedia.'; } if (status == 403) return 'Sesi tidak diizinkan.'; if (status == 404) return 'Sesi pembayaran tidak ditemukan.'; return 'Gagal membuat sesi pembayaran.'; } void _applyInitialExpansion(PaymentCatalog catalog) { if (_initialExpansionDone || catalog.groups.isEmpty) return; _expandedGroupIds.add(catalog.groups.first.id); _initialExpansionDone = true; } @override Widget build(BuildContext context) { final draft = ref.watch(paymentDraftNotifierProvider); final catalogAsync = ref.watch(paymentCatalogProvider); final amount = draft.priceIDR ?? 0; final durationLabel = draft.durationMinutes != null ? 'sesi ${draft.durationMinutes} menit' : 'sesi'; final amountLabel = formatRupiah(amount); 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: [ // Amount summary card (unchanged from the pre-catalog version). 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, ), ), ], ), ), ), // Catalog body — collapsible groups. Expanded( child: catalogAsync.when( data: (catalog) { _applyInitialExpansion(catalog); return ListView( padding: const EdgeInsets.fromLTRB( HaloSpacing.s20, HaloSpacing.s8, HaloSpacing.s20, HaloSpacing.s16, ), children: catalog.groups.map((g) { return _GroupSection( group: g, expanded: _expandedGroupIds.contains(g.id), selectedCode: _selectedCode, onToggle: () => setState(() { if (!_expandedGroupIds.add(g.id)) { _expandedGroupIds.remove(g.id); } }), onSelect: (code) => setState(() { _selectedCode = code; _error = null; }), ); }).toList(), ); }, loading: () => const Center(child: CircularProgressIndicator()), error: (_, __) => const Center(child: Text('Gagal memuat metode pembayaran.')), ), ), 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 || _selectedCode == null) ? null : _onPay, ), ), ], ), ); } } class _GroupSection extends StatelessWidget { final PaymentMethodGroup group; final bool expanded; final String? selectedCode; final VoidCallback onToggle; final ValueChanged onSelect; const _GroupSection({ required this.group, required this.expanded, required this.selectedCode, required this.onToggle, required this.onSelect, }); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(bottom: HaloSpacing.s12), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ InkWell( onTap: onToggle, borderRadius: HaloRadius.md, child: Padding( padding: const EdgeInsets.symmetric( vertical: HaloSpacing.s8, horizontal: HaloSpacing.s4, ), child: Row( children: [ Expanded( child: Text( group.name.toLowerCase(), style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w700, color: HaloTokens.brandDark, letterSpacing: 0.4, ), ), ), AnimatedRotation( duration: HaloMotion.fast, turns: expanded ? 0.5 : 0, child: const Icon( Icons.keyboard_arrow_down, color: HaloTokens.brandDark, size: 20, ), ), ], ), ), ), AnimatedCrossFade( duration: HaloMotion.normal, firstChild: const SizedBox(height: 0), secondChild: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: group.methods .map((m) => _MethodTile( method: m, selected: selectedCode == m.paymentCode, onTap: () => onSelect(m.paymentCode), )) .toList(), ), crossFadeState: expanded ? CrossFadeState.showSecond : CrossFadeState.showFirst, ), ], ), ); } } class _MethodTile extends StatelessWidget { final PaymentMethodEntry method; final bool selected; final VoidCallback onTap; const _MethodTile({ required this.method, required this.selected, required this.onTap, }); @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: const EdgeInsets.all(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: 40, height: 40, decoration: BoxDecoration( color: HaloTokens.surface, borderRadius: HaloRadius.md, border: Border.all(color: HaloTokens.border), ), alignment: Alignment.center, child: PaymentIcon( slug: method.icon, size: 22, color: HaloTokens.brandDark, ), ), const SizedBox(width: HaloSpacing.s12), Expanded( child: Text( method.displayName, style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: HaloTokens.ink, ), ), ), Container( width: 20, height: 20, 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: 8, height: 8, decoration: const BoxDecoration( shape: BoxShape.circle, color: Colors.white, ), ), ) : null, ), ], ), ), ), ), ); } }