Files
halobestie-clone/client_app/lib/features/payment/screens/payment_method_screen.dart
Ramadhan Sjamsani 2c95fd040d Phase 5.x payment revamp + Xendit Stage-8 prep
- Backend wraps idn-finlogos npm at /assets/payment-icons/<slug>.svg with
  1y immutable cache. Mobile drops bundled SVGs (only placeholder remains)
  and fetches via flutter_cache_manager. payment_methods.icon is now a
  CSV of slugs; catalog emits icon_urls[]. CARDS tile renders Visa + MC +
  JCB side by side.
- Per-method min/max amount bounds (BIGINT, nullable). Picker greys out
  out-of-range tiles with subtitle; backend gates with INVALID_PAYMENT_AMOUNT
  (422). Defense in depth against stale-catalog clients.
- Xendit channel codes corrected from authoritative docs
  (BCA_VA -> BCA_VIRTUAL_ACCOUNT, CREDIT_CARD -> CARDS, ovo -> ovo-new,
  shopeepay -> shopee-pay, ...). 18 methods x 5 groups seeded with
  Xendit-published per-channel min/max.
- Re-runnable seed (ON CONFLICT DO NOTHING on payment_code + new unique
  index on group name). Operator CC edits never clobbered across re-runs.
  One-shot reset + inspect scripts under backend/.dev/.
- Customer redirect HTML pages at /payment/return/{success,failure},
  brand-styled with "Buka HaloBestie" CTA firing halobestie:// deeplink.
  URL scheme registered on Android (intent-filter w/ BROWSABLE on
  MainActivity) and iOS (CFBundleURLTypes). Waiting-payment poller still
  owns confirmation; deeplink just brings the activity to foreground.
- Control center payment-catalog page: min/max inputs + columns. Other
  CC pages restyled with new theme tokens (separate work, bundled here).

169/169 backend tests pass. See requirement/phase5-payment-revamp-2026-05-27.md
for the full revamp doc. Stage 8 (E2E) still pending: webhook URL routing
decision + two client_app follow-ups (legacy /chat/request removal,
extension Custom Tab).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:33:51 +08:00

504 lines
17 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<PaymentMethodScreen> createState() => _PaymentMethodScreenState();
}
class _PaymentMethodScreenState extends ConsumerState<PaymentMethodScreen> {
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<String> _expandedGroupIds = {};
bool _initialExpansionDone = false;
Future<void> _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 = <String, dynamic>{
'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<String, dynamic>;
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 (code == 'INVALID_PAYMENT_AMOUNT') {
// Server confirms the picker should have caught this — most likely a
// stale catalog. The picker's tile subtitle already explains; we just
// need to nudge the user to pick a different method.
return 'Metode pembayaran tidak cocok untuk nominal ini. Pilih metode lain.';
}
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,
amount: amount,
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 int amount;
final VoidCallback onToggle;
final ValueChanged<String> onSelect;
const _GroupSection({
required this.group,
required this.expanded,
required this.selectedCode,
required this.amount,
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) {
final disabledReason = m.disabledReason(amount);
return _MethodTile(
method: m,
selected: selectedCode == m.paymentCode,
disabledReason: disabledReason,
onTap: disabledReason == null
? () => onSelect(m.paymentCode)
: null,
);
}).toList(),
),
crossFadeState:
expanded ? CrossFadeState.showSecond : CrossFadeState.showFirst,
),
],
),
);
}
}
/// Visual container for one or more brand-mark icons on a payment-method tile.
///
/// Single-icon: 40×40 box with the icon at 22px. Multi-icon (e.g. credit-card
/// row showing Visa + Mastercard + JCB): box widens to fit, icons render at
/// 18px with 3px gaps. Empty list: placeholder via [PaymentIcon] in the box.
class _MethodIconBox extends StatelessWidget {
final List<String> iconUrls;
const _MethodIconBox({required this.iconUrls});
@override
Widget build(BuildContext context) {
final multi = iconUrls.length > 1;
final iconSize = multi ? 18.0 : 22.0;
final children = iconUrls.isEmpty
? <Widget>[
PaymentIcon(iconUrl: null, size: iconSize, color: HaloTokens.brandDark),
]
: [
for (var i = 0; i < iconUrls.length; i++) ...[
if (i > 0) const SizedBox(width: 3),
PaymentIcon(iconUrl: iconUrls[i], size: iconSize, color: HaloTokens.brandDark),
],
];
return Container(
height: 40,
constraints: BoxConstraints(minWidth: multi ? 0 : 40),
padding: EdgeInsets.symmetric(horizontal: multi ? 6 : 0),
decoration: BoxDecoration(
color: HaloTokens.surface,
borderRadius: HaloRadius.md,
border: Border.all(color: HaloTokens.border),
),
alignment: Alignment.center,
child: Row(mainAxisSize: MainAxisSize.min, children: children),
);
}
}
class _MethodTile extends StatelessWidget {
final PaymentMethodEntry method;
final bool selected;
final String? disabledReason;
final VoidCallback? onTap;
const _MethodTile({
required this.method,
required this.selected,
required this.disabledReason,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final disabled = disabledReason != null;
return Padding(
padding: const EdgeInsets.only(bottom: HaloSpacing.s8),
child: Opacity(
opacity: disabled ? 0.5 : 1.0,
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: [
_MethodIconBox(iconUrls: method.iconUrls),
const SizedBox(width: HaloSpacing.s12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
method.displayName,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: HaloTokens.ink,
),
),
if (disabled)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
disabledReason!,
style: const TextStyle(
fontSize: 11.5,
color: HaloTokens.inkMuted,
),
),
),
],
),
),
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,
),
],
),
),
),
),
),
);
}
}