Files
halobestie-clone/client_app/lib/features/payment/screens/payment_method_screen.dart
Ramadhan Sjamsani eeb4ea38fc feat(client/analytics): GA4 funnel instrumentation + unified home CTA
Add Firebase Analytics (GA4) funnel tracking to client_app:
- AnalyticsService typed wrapper (enum-gated, no PII) + analyticsProvider
- FirebaseAnalyticsObserver on GoRouter (screen_name via nameExtractor)
- user_id = customer UUID, user_type property, set on auth resolve/upgrade
- funnel events: curhat_start, curhat_repeat_start, auth_*, onboarding_usp_view,
  payment_view, payment_method_select, payment_started, pairing_matched/no_bestie
- bottom-sheet events: verif_choice_view/select, bestie_choice_view/select,
  extension_offer_view, chat_extension_requested
- payment_started carries app_instance_id + ga_session_id in the
  /payment-requests body for future server-side stitching (backend ignores)
- curhat_mode_pick screen name disambiguates the chat/call mode picker
  (/payment/method-pick) from the payment-channel picker (/payment/method)
- unify both home CTAs to "Aku Mau Curhat"

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 21:57:26 +08:00

545 lines
19 KiB
Dart
Raw Permalink 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/analytics/analytics_service.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);
final analytics = ref.read(analyticsProvider);
try {
// ⭐ Capture GA4 stitching identifiers BEFORE the POST so the backend can
// store them in product_metadata and replay them in the server-fired
// payment_confirmed (Measurement Protocol). The backend currently
// ignores unknown body fields — intentional; we send now, stitch later.
final appInstanceId = await analytics.appInstanceId();
final gaSessionId = await analytics.sessionId();
if (!mounted) return;
final analyticsIds = <String, dynamic>{
if (appInstanceId != null) 'app_instance_id': appInstanceId,
if (gaSessionId != null) 'ga_session_id': gaSessionId,
};
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,
if (analyticsIds.isNotEmpty) 'analytics': analyticsIds,
};
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);
// ⭐ payment_started fires AFTER the id is known. A targeted mitra means
// the returning/repeat funnel; otherwise activation.
final isRepeat = draft.targetedMitraId != null;
// ignore: discarded_futures
analytics.logPaymentStarted(
paymentRequestId: paymentId,
amount: draft.priceIDR!,
method: _selectedCode!,
funnel: isRepeat ? AnalyticsFunnel.repeat : AnalyticsFunnel.activation,
isRepeat: isRepeat,
productType: draft.mode.value,
durationMinutes: draft.durationMinutes!,
);
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) {
// Funnel step 9 — method chosen. Fire once per pick
// (not on every rebuild).
if (code != _selectedCode) {
// ignore: discarded_futures
ref
.read(analyticsProvider)
.logPaymentMethodSelect(method: 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,
),
],
),
),
),
),
),
);
}
}