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>
This commit is contained in:
2026-06-02 21:57:26 +08:00
parent 76d74aa7b5
commit eeb4ea38fc
25 changed files with 594 additions and 23 deletions

View File

@@ -1,6 +1,7 @@
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/auth/auth_notifier.dart';
import '../../../core/theme/halo_tokens.dart';
@@ -35,6 +36,9 @@ class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> {
final data = next.valueOrNull;
if (data is AuthAnonymousData && !_routedAfterLogin) {
_routedAfterLogin = true;
// Anonymous identity established — activation step 6.
// ignore: discarded_futures
ref.read(analyticsProvider).logAuthComplete(AnalyticsUserType.anonymous);
_proceedAfterLogin();
}
});
@@ -82,12 +86,17 @@ class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> {
return;
}
// ignore: discarded_futures
ref.read(analyticsProvider).logVerifChoiceView();
final choice = await VerifChoiceSheet.show(context);
if (!mounted || choice == null) {
// User dismissed the sheet — let them tap Lanjut again to retry.
// User dismissed the sheet — let them tap Lanjut again to retry. No
// select event: the view→select gap is the abandonment signal.
_routedAfterLogin = false;
return;
}
// ignore: discarded_futures
ref.read(analyticsProvider).logVerifChoiceSelect(choice);
if (!mounted) return;
await routeForVerifChoice(context, ref, choice);
}

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/analytics/analytics_service.dart';
import '../../../core/api/api_client_provider.dart';
import '../../../core/auth/auth_notifier.dart';
import '../../../core/constants.dart';
@@ -75,6 +76,14 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
if (_errorMessage != null && mounted) {
setState(() => _errorMessage = null);
}
// OTP verify resolved to a real identity — activation/repeat step 6.
final data = next.valueOrNull;
if (data is AuthAuthenticatedData || data is AuthNeedsDisplayNameData) {
// ignore: discarded_futures
ref
.read(analyticsProvider)
.logAuthComplete(AnalyticsUserType.verified);
}
}
});
@@ -152,6 +161,8 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
final code = _readCode();
if (code.length == _kOtpLength && !_autoSubmitted && _otpRequestId != null) {
_autoSubmitted = true;
// ignore: discarded_futures
ref.read(analyticsProvider).logAuthOtpSubmit();
ref.read(authProvider.notifier).verifyOtp(_otpRequestId!, code);
}
}

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/analytics/analytics_service.dart';
import '../../../core/auth/auth_notifier.dart';
import '../../../core/constants.dart';
import '../../../core/theme/halo_tokens.dart';
@@ -222,8 +223,15 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
: 'kirim kode',
fullWidth: true,
onPressed: canSubmit
? () =>
ref.read(authProvider.notifier).requestOtp(_e164Phone())
? () {
// ignore: discarded_futures
ref
.read(analyticsProvider)
.logAuthStart(AnalyticsAuthMethod.phone);
ref
.read(authProvider.notifier)
.requestOtp(_e164Phone());
}
: null,
),
if (!fromProfile) ...[

View File

@@ -1,9 +1,11 @@
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/chat/active_session_notifier.dart';
import '../../../core/pairing/pairing_notifier.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../payment/state/payment_draft_provider.dart';
import '../../../core/theme/widgets/widgets.dart';
/// Phase 4 Stage 5 — S9 Match-found screen.
@@ -35,6 +37,15 @@ class _BestieFoundScreenState extends ConsumerState<BestieFoundScreen> {
@override
void initState() {
super.initState();
// Funnel step 12 — paired with a mitra. A targeted-mitra draft means the
// repeat funnel; otherwise activation. Fire once on view.
final isRepeat =
ref.read(paymentDraftNotifierProvider).targetedMitraId != null;
// ignore: discarded_futures
ref.read(analyticsProvider).logPairingMatched(
funnel:
isRepeat ? AnalyticsFunnel.repeat : AnalyticsFunnel.activation,
);
ref.listenManual<PairingData>(pairingProvider, (prev, next) {
if (!mounted) return;
if (next is PairingActiveData) {

View File

@@ -2,6 +2,7 @@ import 'dart:async';
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/chat/active_session_notifier.dart';
import '../../../core/chat/chat_notifier.dart';
import '../../../core/chat/session_closure_notifier.dart';
@@ -512,6 +513,8 @@ class _TimerBanner extends ConsumerWidget {
return _BannerKind.none;
}));
void onExtend() {
// ignore: discarded_futures
ref.read(analyticsProvider).logExtensionOfferView(sessionId: sessionId);
PricingBottomSheet.showForExtension(
context,
sessionId: sessionId,

View File

@@ -1,7 +1,9 @@
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/pairing/pairing_notifier.dart';
import '../../payment/state/payment_draft_provider.dart';
/// Terminal failed-pairing screen.
///
@@ -13,11 +15,30 @@ import '../../../core/pairing/pairing_notifier.dart';
/// Single CTA "Kembali ke beranda" resets the pairing notifier and routes
/// home. PopScope falls back to home for deep-link entry per project memory
/// rule "Deep-link pop fallback".
class NoBestieScreen extends ConsumerWidget {
class NoBestieScreen extends ConsumerStatefulWidget {
const NoBestieScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<NoBestieScreen> createState() => _NoBestieScreenState();
}
class _NoBestieScreenState extends ConsumerState<NoBestieScreen> {
@override
void initState() {
super.initState();
// Funnel drop-off marker — pairing failed. A targeted-mitra draft means
// the repeat funnel; otherwise activation. Fire once on view.
final isRepeat =
ref.read(paymentDraftNotifierProvider).targetedMitraId != null;
// ignore: discarded_futures
ref.read(analyticsProvider).logPairingNoBestie(
funnel:
isRepeat ? AnalyticsFunnel.repeat : AnalyticsFunnel.activation,
);
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: true,
onPopInvokedWithResult: (didPop, _) {

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/analytics/analytics_service.dart';
import '../../../core/chat/chat_opening_provider.dart';
import '../../../core/chat/session_closure_notifier.dart';
import '../../../core/constants.dart';
@@ -85,6 +86,10 @@ class _PricingBottomSheetState extends ConsumerState<PricingBottomSheet> {
}
void _onConfirm(PriceTier tier) {
// ignore: discarded_futures
ref.read(analyticsProvider).logChatExtensionRequested(
sessionId: widget.extensionSessionId,
);
Navigator.of(context).pop();
ref.read(sessionClosureProvider.notifier).requestExtension(
widget.extensionSessionId,

View File

@@ -1,6 +1,7 @@
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/auth/auth_notifier.dart';
import '../../core/auth/onboarding_intent_provider.dart';
import '../../core/availability/mitra_availability_notifier.dart';
@@ -81,6 +82,11 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
/// CTA path for SHomeReturning. Returning users get the bestie-choice sheet
/// when they have prior history, otherwise jump to the new-payment shell.
Future<void> _onCurhatBestieBaruPressed(BuildContext context) async {
// Returning user starting a fresh curhat (repeat funnel). The
// bestie_reselect sub-event fires later from the history list if they pick
// a known bestie; this marks the top of the repeat funnel.
// ignore: discarded_futures
ref.read(analyticsProvider).logCurhatRepeatStart();
bool hasHistory;
try {
hasHistory = await ref.read(bestieHistoryHasItemsProvider.future);
@@ -89,6 +95,8 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
}
if (!context.mounted) return;
if (hasHistory) {
// ignore: discarded_futures
ref.read(analyticsProvider).logBestieChoiceView();
await BestieChoiceSheet.show(context);
return;
}
@@ -101,6 +109,9 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
/// (the call-sign check); display_name_screen kicks off `loginAnonymous`
/// and pushes into the verif-choice sheet.
void _onAkuMauCurhatPressed(BuildContext context) {
// Top of the activation funnel — fresh user tapping the primary CTA.
// ignore: discarded_futures
ref.read(analyticsProvider).logCurhatStart(entryPoint: 'home_primary');
context.push('/auth/display-name');
}
@@ -172,7 +183,7 @@ class _SHome1stView extends ConsumerWidget {
const _GreetingSubtitle(),
const SizedBox(height: 32),
_PrimaryCTA(
label: 'aku mau curhat',
label: 'Aku Mau Curhat',
enabled: mitraAvailable,
onPressed: onCTA,
),
@@ -395,7 +406,7 @@ class _SHomeReturningView extends ConsumerWidget {
activeSessionAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => _PrimaryCTA(
label: 'curhat sama bestie baru',
label: 'Aku Mau Curhat',
enabled: mitraAvailable,
onPressed: onCTA,
),
@@ -415,7 +426,7 @@ class _SHomeReturningView extends ConsumerWidget {
);
}
return _PrimaryCTA(
label: 'curhat sama bestie baru',
label: 'Aku Mau Curhat',
enabled: mitraAvailable,
onPressed: onCTA,
);

View File

@@ -1,6 +1,7 @@
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/constants.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
@@ -132,6 +133,13 @@ class BestieHistoryListScreen extends ConsumerWidget {
return;
}
if (item.mitraId == null) return;
// Repeat funnel: user re-selected a known bestie. mitra_ref
// is opaque (hashed) — never the raw mitra id, per the
// no-PII / opaque-mitra-identity rule.
// ignore: discarded_futures
ref.read(analyticsProvider).logBestieReselect(
mitraRef: item.mitraId!.hashCode.toString(),
);
// Stamp the targeted mitra onto the payment draft; the
// multi-screen payment flow (entry → method → waiting →
// notif-gate → searching) reads it back to fire the

View File

@@ -1,6 +1,7 @@
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/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
import '../../payment/state/payment_draft_provider.dart';
@@ -52,6 +53,8 @@ class BestieChoiceSheet extends ConsumerWidget {
subtitle: 'lanjut cerita ke bestie yang pernah dengerin kamu.',
icon: Icons.favorite_outline,
onTap: () {
// ignore: discarded_futures
ref.read(analyticsProvider).logBestieChoiceSelect(knownBestie: true);
Navigator.of(context).pop();
context.push('/bestie/history');
},
@@ -62,6 +65,8 @@ class BestieChoiceSheet extends ConsumerWidget {
subtitle: 'cari bestie baru yang siap dengerin sekarang.',
icon: Icons.auto_awesome_outlined,
onTap: () {
// ignore: discarded_futures
ref.read(analyticsProvider).logBestieChoiceSelect(knownBestie: false);
// explicit reset — this branch is blast-only, clear any stale targeted mitra
ref.read(paymentDraftNotifierProvider.notifier).reset();
Navigator.of(context).pop();

View File

@@ -1,6 +1,7 @@
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/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
import '../usp_seen_provider.dart';
@@ -11,11 +12,30 @@ import '../usp_seen_provider.dart';
///
/// `verified` ➞ USP → OTP (`/auth/register`).
/// `anonymous` ➞ USP → `/payment/method-pick`.
class UspScreen extends ConsumerWidget {
class UspScreen extends ConsumerStatefulWidget {
final bool verified;
const UspScreen({super.key, required this.verified});
@override
ConsumerState<UspScreen> createState() => _UspScreenState();
}
class _UspScreenState extends ConsumerState<UspScreen> {
@override
void initState() {
super.initState();
// Activation funnel step 7 — fire on view (not teardown). One-shot:
// initState runs once per screen instance.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
// ignore: discarded_futures
ref
.read(analyticsProvider)
.logOnboardingUspView(verified: widget.verified);
});
}
static const _cards = [
_UspCard(
icon: Icons.bolt_outlined,
@@ -40,7 +60,7 @@ class UspScreen extends ConsumerWidget {
];
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Padding(
@@ -94,7 +114,7 @@ class UspScreen extends ConsumerWidget {
HaloButton(
label: 'aku ngerti, lanjut',
fullWidth: true,
onPressed: () => _onContinue(context, ref),
onPressed: () => _onContinue(context),
),
],
),
@@ -103,12 +123,12 @@ class UspScreen extends ConsumerWidget {
);
}
Future<void> _onContinue(BuildContext context, WidgetRef ref) async {
Future<void> _onContinue(BuildContext context) async {
// Persist the local + server flag before leaving — next time the user
// hits VerifChoice, this screen is skipped.
await ref.read(uspSeenProvider.notifier).markSeen();
if (!context.mounted) return;
if (verified) {
if (widget.verified) {
context.push('/auth/register');
} else {
context.push('/payment/method-pick');

View File

@@ -1,6 +1,7 @@
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/auth/onboarding_intent_provider.dart';
import '../../../core/chat/chat_opening_provider.dart';
import '../../../core/theme/halo_tokens.dart';
@@ -38,6 +39,18 @@ class _PaymentEntryScreenState extends ConsumerState<PaymentEntryScreen> {
// inherit a stale onboarding intent.
ref.read(onboardingIntentProvider.notifier).state =
OnboardingIntent.recover;
// Funnel step 8 — payment entry. A targeted mitra (set just before this
// screen by the bestie-history list) marks the repeat funnel; otherwise
// it's activation. resetExceptTarget() above preserves that flag.
final isRepeat =
ref.read(paymentDraftNotifierProvider).targetedMitraId != null;
// ignore: discarded_futures
ref.read(analyticsProvider).logPaymentView(
funnel:
isRepeat ? AnalyticsFunnel.repeat : AnalyticsFunnel.activation,
isRepeat: isRepeat,
);
});
}

View File

@@ -2,6 +2,7 @@ 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';
@@ -53,7 +54,21 @@ class _PaymentMethodScreenState extends ConsumerState<PaymentMethodScreen> {
_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,
@@ -61,11 +76,27 @@ class _PaymentMethodScreenState extends ConsumerState<PaymentMethodScreen> {
'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) {
@@ -216,10 +247,20 @@ class _PaymentMethodScreenState extends ConsumerState<PaymentMethodScreen> {
_expandedGroupIds.remove(g.id);
}
}),
onSelect: (code) => setState(() {
_selectedCode = code;
_error = null;
}),
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(),
);

View File

@@ -7,7 +7,7 @@ part of 'payment_draft_provider.dart';
// **************************************************************************
String _$paymentDraftNotifierHash() =>
r'1c81b22f25f525cd290f54618bee0b69de792998';
r'e489a593f5e1cc2794d13566a9cf960bb89e45c6';
/// See also [PaymentDraftNotifier].
@ProviderFor(PaymentDraftNotifier)