From eeb4ea38fcd20b90f1d2fb1720d4ab017cc1a283 Mon Sep 17 00:00:00 2001 From: Ramadhan Sjamsani Date: Tue, 2 Jun 2026 21:57:26 +0800 Subject: [PATCH 1/4] 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) --- .../lib/core/analytics/analytics_service.dart | 254 ++++++++++++++++++ .../core/analytics/analytics_service.g.dart | 29 ++ client_app/lib/core/auth/auth_notifier.g.dart | 2 +- client_app/lib/core/chat/chat_notifier.g.dart | 2 +- .../core/chat/session_closure_notifier.g.dart | 2 +- .../lib/core/pairing/pairing_notifier.g.dart | 2 +- .../auth/screens/display_name_screen.dart | 11 +- .../lib/features/auth/screens/otp_screen.dart | 11 + .../auth/screens/register_screen.dart | 12 +- .../chat/screens/bestie_found_screen.dart | 11 + .../features/chat/screens/chat_screen.dart | 3 + .../chat/screens/no_bestie_screen.dart | 25 +- .../chat/widgets/pricing_bottom_sheet.dart | 5 + client_app/lib/features/home/home_screen.dart | 17 +- .../screens/bestie_history_list_screen.dart | 8 + .../home/widgets/bestie_choice_sheet.dart | 5 + .../onboarding/screens/usp_screen.dart | 30 ++- .../payment/screens/payment_entry_screen.dart | 13 + .../screens/payment_method_screen.dart | 49 +++- .../state/payment_draft_provider.g.dart | 2 +- client_app/lib/main.dart | 43 ++- client_app/lib/router.dart | 52 ++++ .../Flutter/GeneratedPluginRegistrant.swift | 2 + client_app/pubspec.lock | 24 ++ client_app/pubspec.yaml | 3 + 25 files changed, 594 insertions(+), 23 deletions(-) create mode 100644 client_app/lib/core/analytics/analytics_service.dart create mode 100644 client_app/lib/core/analytics/analytics_service.g.dart diff --git a/client_app/lib/core/analytics/analytics_service.dart b/client_app/lib/core/analytics/analytics_service.dart new file mode 100644 index 0000000..fbb43bc --- /dev/null +++ b/client_app/lib/core/analytics/analytics_service.dart @@ -0,0 +1,254 @@ +import 'package:firebase_analytics/firebase_analytics.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../features/auth/widgets/verif_choice_sheet.dart' show VerifChoice; + +part 'analytics_service.g.dart'; + +/// Which lifecycle funnel an event belongs to. GA4 reports filter on the +/// `funnel` custom dimension; the activation and repeat funnels deliberately +/// share event names and are split by this param (see +/// requirement/analytics-funnel-plan.md §2/§5). +enum AnalyticsFunnel { + activation('activation'), + repeat('repeat'); + + final String value; + const AnalyticsFunnel(this.value); +} + +/// Identity method the user chose at the start of an auth flow. Low-cardinality +/// and PII-free — the value is the *channel*, never the phone/email itself. +enum AnalyticsAuthMethod { + phone('phone'), + google('google'), + apple('apple'); + + final String value; + const AnalyticsAuthMethod(this.value); +} + +/// User-property bucket for `user_type`. Anonymous = server-issued anon +/// customer with no identity; verified = phone/Google/Apple linked. +enum AnalyticsUserType { + anonymous('anonymous'), + verified('verified'); + + final String value; + const AnalyticsUserType(this.value); +} + +/// Stable screen names for the GoRouter observer. Keeping these as an enum +/// (rather than mapping raw `state.uri.path`) guarantees path params like +/// `:sessionId` / `:paymentId` / `:mitraId` never leak into `screen_name`. +enum AnalyticsScreen { + splash('splash'), + authDisplayName('auth_display_name'), + authRegister('auth_register'), + authOtp('auth_otp'), + authSetName('auth_set_name'), + authForceRegister('auth_force_register'), + onboardingUspVerified('onboarding_usp_verified'), + onboardingUspAnon('onboarding_usp_anon'), + onboardingNotifGate('onboarding_notif_gate'), + home('home'), + profile('profile'), + paymentEntry('payment_entry'), + paymentDiscountPaywall('payment_discount_paywall'), + // Route /payment/method-pick is actually the chat-vs-call MODE picker + // ("pilih cara curhat"), NOT the payment-channel picker (that's + // paymentMethod → /payment/method). Named accordingly to avoid funnel + // ambiguity between the two. + curhatModePick('curhat_mode_pick'), + paymentDurationPick('payment_duration_pick'), + paymentMethod('payment_method'), + paymentWaiting('payment_waiting'), + paymentExpired('payment_expired'), + chatSearching('chat_searching'), + chatFound('chat_found'), + chatNoBestie('chat_no_bestie'), + chatWaitingTargeted('chat_waiting_targeted'), + chatSession('chat_session'), + chatThankYou('chat_thank_you'), + chatTabAktif('chat_tab_aktif'), + chatTabPembayaran('chat_tab_pembayaran'), + chatTabSelesai('chat_tab_selesai'), + chatTranscript('chat_transcript'), + bestieHistory('bestie_history'); + + final String value; + const AnalyticsScreen(this.value); +} + +/// Thin, typed façade over `FirebaseAnalytics.instance`. +/// +/// Every funnel event in requirement/analytics-funnel-plan.md §5 is a named +/// method here so call sites never pass free-form strings — the only way to +/// log is through a method whose params are fixed and PII-free. New events get +/// added to the spec table first, then here (governance, §9). +class AnalyticsService { + AnalyticsService(this._analytics); + + final FirebaseAnalytics _analytics; + + // ── Identity & user properties (§4) ─────────────────────────────────────── + + /// Set the opaque customer UUID. Re-set on identity upgrade (anon→verified) + /// so the same `user_id` continues across the merge. Never a phone/name. + Future setUserId(String? customerId) => + _analytics.setUserId(id: customerId); + + Future setUserType(AnalyticsUserType type) => + _analytics.setUserProperty(name: 'user_type', value: type.value); + + Future setIsReturning(bool isReturning) => _analytics.setUserProperty( + name: 'is_returning', + value: isReturning ? 'true' : 'false', + ); + + // ── Payment stitching helpers (§3) ──────────────────────────────────────── + + /// App-instance id required by the GA4 Measurement Protocol to stitch a + /// server-fired `payment_confirmed` back to this device's stream. + Future appInstanceId() => _analytics.appInstanceId; + + /// Current GA session id — replayed server-side so the MP event lands in the + /// same session as the client funnel. + Future sessionId() => _analytics.getSessionId(); + + // ── Funnel events (§5) ──────────────────────────────────────────────────── + + /// Activation: Home primary "aku mau curhat" CTA. + Future logCurhatStart({String? entryPoint}) => _log('curhat_start', { + 'funnel': AnalyticsFunnel.activation.value, + if (entryPoint != null) 'entry_point': entryPoint, + }); + + /// Repeat: returning "curhat sama bestie baru" / "curhat lagi" path. + Future logCurhatRepeatStart() => _log('curhat_repeat_start', { + 'funnel': AnalyticsFunnel.repeat.value, + }); + + /// Repeat: tapped a known bestie row in `/bestie/history`. `mitraRef` is an + /// opaque token (never the mitra's name/id surfaced to GA) — pass an + /// already-hashed/short ref or omit. + Future logBestieReselect({String? mitraRef}) => _log('bestie_reselect', { + 'funnel': AnalyticsFunnel.repeat.value, + if (mitraRef != null) 'mitra_ref': mitraRef, + }); + + Future logAuthStart(AnalyticsAuthMethod method) => _log('auth_start', { + 'method': method.value, + }); + + Future logAuthOtpSubmit() => _log('auth_otp_submit', const {}); + + Future logAuthComplete(AnalyticsUserType userType) => + _log('auth_complete', {'user_type': userType.value}); + + Future logOnboardingUspView({required bool verified}) => + _log('onboarding_usp_view', {'verified': verified}); + + Future logPaymentView({ + required AnalyticsFunnel funnel, + required bool isRepeat, + }) => + _log('payment_view', { + 'funnel': funnel.value, + 'is_repeat': isRepeat, + }); + + Future logPaymentMethodSelect({required String method}) => + _log('payment_method_select', {'method': method}); + + /// ⭐ Fired only AFTER the POST /payment-requests returns an id. + Future logPaymentStarted({ + required String paymentRequestId, + required int amount, + required String method, + required AnalyticsFunnel funnel, + required bool isRepeat, + required String productType, + required int durationMinutes, + String currency = 'IDR', + }) => + _log('payment_started', { + 'payment_request_id': paymentRequestId, + 'amount': amount, + 'currency': currency, + 'method': method, + 'funnel': funnel.value, + 'is_repeat': isRepeat, + 'product_type': productType, + 'duration_minutes': durationMinutes, + }); + + Future logPairingMatched({required AnalyticsFunnel funnel}) => + _log('pairing_matched', {'funnel': funnel.value}); + + Future logPairingNoBestie({required AnalyticsFunnel funnel}) => + _log('pairing_no_bestie', {'funnel': funnel.value}); + + // ── Bottom-sheet / modal funnel events (§5) ─────────────────────────────── + + /// Activation: post-name Verif Choice Sheet shown. Paired with + /// [logVerifChoiceSelect] — the gap between view and select is abandonment. + Future logVerifChoiceView() => + _log('verif_choice_view', const {}); + + /// Activation: user picked an identity branch in the Verif Choice Sheet. + /// Maps the [VerifChoice] enum to a low-cardinality, PII-free channel value. + Future logVerifChoiceSelect(VerifChoice choice) => + _log('verif_choice_select', { + 'choice': switch (choice) { + VerifChoice.verified => 'verified', + VerifChoice.anonymous => 'anonymous', + }, + }); + + /// Repeat: Bestie Choice Sheet shown from the returning-user Home CTA. + Future logBestieChoiceView() => + _log('bestie_choice_view', const {}); + + /// Repeat: user picked a bestie branch in the Bestie Choice Sheet. + Future logBestieChoiceSelect({required bool knownBestie}) => + _log('bestie_choice_select', { + 'choice': knownBestie ? 'known_bestie' : 'new_bestie', + }); + + /// Repeat: in-session extension upsell sheet shown (time-up / low-time). + Future logExtensionOfferView({String? sessionId}) => + _log('extension_offer_view', { + if (sessionId != null) 'session_id': sessionId, + }); + + /// Repeat: user confirmed an extension from the upsell sheet (not dismiss + /// or "akhiri sesi"). Fired the moment the perpanjang CTA is committed. + Future logChatExtensionRequested({String? sessionId}) => + _log('chat_extension_requested', { + if (sessionId != null) 'session_id': sessionId, + }); + + // ── Internal ────────────────────────────────────────────────────────────── + + Future _log(String name, Map params) async { + final clean = { + for (final e in params.entries) + if (e.value != null) e.key: e.value!, + }; + try { + await _analytics.logEvent(name: name, parameters: clean.isEmpty ? null : clean); + } catch (e) { + // Analytics must never break a user flow. + debugPrint('[analytics] failed to log $name: $e'); + } + } +} + +/// Keep-alive so a single AnalyticsService (and its FirebaseAnalytics handle) +/// is shared across screens/notifiers for the app's lifetime. +@Riverpod(keepAlive: true) +AnalyticsService analytics(Ref ref) => + AnalyticsService(FirebaseAnalytics.instance); diff --git a/client_app/lib/core/analytics/analytics_service.g.dart b/client_app/lib/core/analytics/analytics_service.g.dart new file mode 100644 index 0000000..bee36bc --- /dev/null +++ b/client_app/lib/core/analytics/analytics_service.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'analytics_service.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$analyticsHash() => r'862f59acd499543968f9ee591cec55cdd1b50035'; + +/// Keep-alive so a single AnalyticsService (and its FirebaseAnalytics handle) +/// is shared across screens/notifiers for the app's lifetime. +/// +/// Copied from [analytics]. +@ProviderFor(analytics) +final analyticsProvider = Provider.internal( + analytics, + name: r'analyticsProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$analyticsHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef AnalyticsRef = ProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/client_app/lib/core/auth/auth_notifier.g.dart b/client_app/lib/core/auth/auth_notifier.g.dart index b4318c8..4e674c5 100644 --- a/client_app/lib/core/auth/auth_notifier.g.dart +++ b/client_app/lib/core/auth/auth_notifier.g.dart @@ -6,7 +6,7 @@ part of 'auth_notifier.dart'; // RiverpodGenerator // ************************************************************************** -String _$authHash() => r'f0382b1380749cebe8182ad221023926768ebb92'; +String _$authHash() => r'76cd43babe5503d35a35c8fd23ba32afbc4c8c2d'; /// See also [Auth]. @ProviderFor(Auth) diff --git a/client_app/lib/core/chat/chat_notifier.g.dart b/client_app/lib/core/chat/chat_notifier.g.dart index f2cef9a..1258c1f 100644 --- a/client_app/lib/core/chat/chat_notifier.g.dart +++ b/client_app/lib/core/chat/chat_notifier.g.dart @@ -30,7 +30,7 @@ final chatRemainingSecondsProvider = AutoDisposeStreamProvider.internal( @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef ChatRemainingSecondsRef = AutoDisposeStreamProviderRef; -String _$chatHash() => r'a42bf404f5b38034c6c9511259b64b1092af3caa'; +String _$chatHash() => r'56f019ce6e527128ab42a71f56220c5412cfec0f'; /// See also [Chat]. @ProviderFor(Chat) diff --git a/client_app/lib/core/chat/session_closure_notifier.g.dart b/client_app/lib/core/chat/session_closure_notifier.g.dart index b25b261..9d50a52 100644 --- a/client_app/lib/core/chat/session_closure_notifier.g.dart +++ b/client_app/lib/core/chat/session_closure_notifier.g.dart @@ -6,7 +6,7 @@ part of 'session_closure_notifier.dart'; // RiverpodGenerator // ************************************************************************** -String _$sessionClosureHash() => r'a9aaf26da64d3489497d057e2b05741c08444e07'; +String _$sessionClosureHash() => r'1ab9df044138115e232b3df494e2895177d9d66d'; /// See also [SessionClosure]. @ProviderFor(SessionClosure) diff --git a/client_app/lib/core/pairing/pairing_notifier.g.dart b/client_app/lib/core/pairing/pairing_notifier.g.dart index ae23d29..2e31866 100644 --- a/client_app/lib/core/pairing/pairing_notifier.g.dart +++ b/client_app/lib/core/pairing/pairing_notifier.g.dart @@ -6,7 +6,7 @@ part of 'pairing_notifier.dart'; // RiverpodGenerator // ************************************************************************** -String _$pairingHash() => r'd39980fe5b82348d03485006d0534ab597b93ceb'; +String _$pairingHash() => r'e66bbf67e1013b3d25230ed01fe2595bff943b3a'; /// See also [Pairing]. @ProviderFor(Pairing) diff --git a/client_app/lib/features/auth/screens/display_name_screen.dart b/client_app/lib/features/auth/screens/display_name_screen.dart index c6f57b9..845f415 100644 --- a/client_app/lib/features/auth/screens/display_name_screen.dart +++ b/client_app/lib/features/auth/screens/display_name_screen.dart @@ -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 { 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 { 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); } diff --git a/client_app/lib/features/auth/screens/otp_screen.dart b/client_app/lib/features/auth/screens/otp_screen.dart index 2110eab..17ad538 100644 --- a/client_app/lib/features/auth/screens/otp_screen.dart +++ b/client_app/lib/features/auth/screens/otp_screen.dart @@ -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 { 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 { 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); } } diff --git a/client_app/lib/features/auth/screens/register_screen.dart b/client_app/lib/features/auth/screens/register_screen.dart index 0c42348..5a38c0d 100644 --- a/client_app/lib/features/auth/screens/register_screen.dart +++ b/client_app/lib/features/auth/screens/register_screen.dart @@ -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 { : '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) ...[ diff --git a/client_app/lib/features/chat/screens/bestie_found_screen.dart b/client_app/lib/features/chat/screens/bestie_found_screen.dart index d34e63d..f5a6a5b 100644 --- a/client_app/lib/features/chat/screens/bestie_found_screen.dart +++ b/client_app/lib/features/chat/screens/bestie_found_screen.dart @@ -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 { @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(pairingProvider, (prev, next) { if (!mounted) return; if (next is PairingActiveData) { diff --git a/client_app/lib/features/chat/screens/chat_screen.dart b/client_app/lib/features/chat/screens/chat_screen.dart index 475a603..db9d7d9 100644 --- a/client_app/lib/features/chat/screens/chat_screen.dart +++ b/client_app/lib/features/chat/screens/chat_screen.dart @@ -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, diff --git a/client_app/lib/features/chat/screens/no_bestie_screen.dart b/client_app/lib/features/chat/screens/no_bestie_screen.dart index bb7bf6c..2e49ea0 100644 --- a/client_app/lib/features/chat/screens/no_bestie_screen.dart +++ b/client_app/lib/features/chat/screens/no_bestie_screen.dart @@ -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 createState() => _NoBestieScreenState(); +} + +class _NoBestieScreenState extends ConsumerState { + @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, _) { diff --git a/client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart b/client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart index 3a38204..2412ed5 100644 --- a/client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart +++ b/client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart @@ -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 { } 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, diff --git a/client_app/lib/features/home/home_screen.dart b/client_app/lib/features/home/home_screen.dart index aa14e35..3caa1ec 100644 --- a/client_app/lib/features/home/home_screen.dart +++ b/client_app/lib/features/home/home_screen.dart @@ -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 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 _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 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 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, ); diff --git a/client_app/lib/features/home/screens/bestie_history_list_screen.dart b/client_app/lib/features/home/screens/bestie_history_list_screen.dart index a05cb30..c65f9f3 100644 --- a/client_app/lib/features/home/screens/bestie_history_list_screen.dart +++ b/client_app/lib/features/home/screens/bestie_history_list_screen.dart @@ -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 diff --git a/client_app/lib/features/home/widgets/bestie_choice_sheet.dart b/client_app/lib/features/home/widgets/bestie_choice_sheet.dart index dd0be99..6b2c45a 100644 --- a/client_app/lib/features/home/widgets/bestie_choice_sheet.dart +++ b/client_app/lib/features/home/widgets/bestie_choice_sheet.dart @@ -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(); diff --git a/client_app/lib/features/onboarding/screens/usp_screen.dart b/client_app/lib/features/onboarding/screens/usp_screen.dart index 72613aa..39bef35 100644 --- a/client_app/lib/features/onboarding/screens/usp_screen.dart +++ b/client_app/lib/features/onboarding/screens/usp_screen.dart @@ -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 createState() => _UspScreenState(); +} + +class _UspScreenState extends ConsumerState { + @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 _onContinue(BuildContext context, WidgetRef ref) async { + Future _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'); diff --git a/client_app/lib/features/payment/screens/payment_entry_screen.dart b/client_app/lib/features/payment/screens/payment_entry_screen.dart index 209af48..bcabaa8 100644 --- a/client_app/lib/features/payment/screens/payment_entry_screen.dart +++ b/client_app/lib/features/payment/screens/payment_entry_screen.dart @@ -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 { // 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, + ); }); } diff --git a/client_app/lib/features/payment/screens/payment_method_screen.dart b/client_app/lib/features/payment/screens/payment_method_screen.dart index a356025..7fbdbe5 100644 --- a/client_app/lib/features/payment/screens/payment_method_screen.dart +++ b/client_app/lib/features/payment/screens/payment_method_screen.dart @@ -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 { _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 = { + if (appInstanceId != null) 'app_instance_id': appInstanceId, + if (gaSessionId != null) 'ga_session_id': gaSessionId, + }; + final body = { 'mode': draft.mode.value, 'duration_minutes': draft.durationMinutes, @@ -61,11 +76,27 @@ class _PaymentMethodScreenState extends ConsumerState { '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; 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 { _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(), ); diff --git a/client_app/lib/features/payment/state/payment_draft_provider.g.dart b/client_app/lib/features/payment/state/payment_draft_provider.g.dart index 395afe3..3cf113b 100644 --- a/client_app/lib/features/payment/state/payment_draft_provider.g.dart +++ b/client_app/lib/features/payment/state/payment_draft_provider.g.dart @@ -7,7 +7,7 @@ part of 'payment_draft_provider.dart'; // ************************************************************************** String _$paymentDraftNotifierHash() => - r'1c81b22f25f525cd290f54618bee0b69de792998'; + r'e489a593f5e1cc2794d13566a9cf960bb89e45c6'; /// See also [PaymentDraftNotifier]. @ProviderFor(PaymentDraftNotifier) diff --git a/client_app/lib/main.dart b/client_app/lib/main.dart index 5fb9bdc..4b0ec5b 100644 --- a/client_app/lib/main.dart +++ b/client_app/lib/main.dart @@ -1,9 +1,11 @@ import 'dart:async'; +import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.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/auth/auth_providers_provider.dart'; @@ -28,6 +30,12 @@ void main() async { await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + // Enable GA4 collection. Fire-and-forget so it never adds to cold-start + // latency; the SDK queues events until collection is on. + unawaited( + FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(true), + ); + final messaging = FirebaseMessaging.instance; await messaging.requestPermission(); @@ -131,9 +139,41 @@ class _AppState extends ConsumerState with WidgetsBindingObserver { }); } + // Tracks the last user_id pushed to GA4 so we don't re-issue identical + // setUserId/user-property calls on every transient auth emission. + String? _analyticsUserId; + + /// Mirror auth state into GA4 identity (§4): opaque customer UUID as + /// `user_id` + `user_type` property. Re-set on identity upgrade + /// (anon→verified) so the same user continues. Never sets phone/name. + void _syncAnalyticsIdentity(AuthData? data) { + final analytics = ref.read(analyticsProvider); + final (String? customerId, AnalyticsUserType? userType) = switch (data) { + AuthAnonymousData d => (d.customerId, AnalyticsUserType.anonymous), + AuthForceRegisterData d => (d.customerId, AnalyticsUserType.anonymous), + AuthAuthenticatedData d => ( + d.profile['id'] as String?, + AnalyticsUserType.verified, + ), + AuthNeedsDisplayNameData d => ( + d.profile['id'] as String?, + AnalyticsUserType.verified, + ), + _ => (null, null), + }; + if (customerId == _analyticsUserId) return; + _analyticsUserId = customerId; + // ignore: discarded_futures + analytics.setUserId(customerId); + if (userType != null) { + // ignore: discarded_futures + analytics.setUserType(userType); + } + } + @override Widget build(BuildContext context) { - // FCM registration on auth. + // FCM registration + analytics identity on auth. ref.listen(authProvider, (prev, next) { final data = next.valueOrNull; if (data is AuthAuthenticatedData || data is AuthAnonymousData) { @@ -142,6 +182,7 @@ class _AppState extends ConsumerState with WidgetsBindingObserver { // Logged out (or initial) — ensure the chat WS is closed. ref.read(chatProvider.notifier).disconnect(); } + _syncAnalyticsIdentity(data); }); // Global chat WebSocket lifecycle: connect whenever the user has an diff --git a/client_app/lib/router.dart b/client_app/lib/router.dart index d09d95f..ad010b2 100644 --- a/client_app/lib/router.dart +++ b/client_app/lib/router.dart @@ -1,6 +1,8 @@ +import 'package:firebase_analytics/firebase_analytics.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/auth/auth_notifier.dart'; import 'core/auth/onboarding_intent_provider.dart'; import 'features/auth/screens/display_name_screen.dart'; @@ -54,12 +56,62 @@ class RouterNotifier extends ChangeNotifier { final routerProvider = Provider((ref) => buildRouter(ref)); +/// Maps a GoRoute path template (`Route.settings.name`) to a stable +/// `screen_name`. Keyed on the *template* (e.g. `/chat/session/:sessionId`) +/// so path params are never part of the logged name. Routes absent here are +/// dropped (return null → observer skips the screen_view). +const _screenNameByRoute = { + '/splash': AnalyticsScreen.splash, + '/auth/display-name': AnalyticsScreen.authDisplayName, + '/auth/register': AnalyticsScreen.authRegister, + '/auth/otp': AnalyticsScreen.authOtp, + '/auth/set-name': AnalyticsScreen.authSetName, + '/auth/force-register': AnalyticsScreen.authForceRegister, + '/onboarding/verif/usp': AnalyticsScreen.onboardingUspVerified, + '/onboarding/anon/usp': AnalyticsScreen.onboardingUspAnon, + '/onboarding/notif-gate': AnalyticsScreen.onboardingNotifGate, + '/home': AnalyticsScreen.home, + '/profile': AnalyticsScreen.profile, + '/payment/entry': AnalyticsScreen.paymentEntry, + '/payment/discount-paywall': AnalyticsScreen.paymentDiscountPaywall, + '/payment/method-pick': AnalyticsScreen.curhatModePick, + '/payment/duration-pick': AnalyticsScreen.paymentDurationPick, + '/payment/method': AnalyticsScreen.paymentMethod, + '/payment/waiting/:paymentId': AnalyticsScreen.paymentWaiting, + '/payment/expired/:paymentId': AnalyticsScreen.paymentExpired, + '/chat/searching': AnalyticsScreen.chatSearching, + '/chat/found': AnalyticsScreen.chatFound, + '/chat/no-bestie': AnalyticsScreen.chatNoBestie, + '/chat/waiting-targeted/:mitraId': AnalyticsScreen.chatWaitingTargeted, + '/chat/session/:sessionId': AnalyticsScreen.chatSession, + '/chat/thank-you': AnalyticsScreen.chatThankYou, + '/chat/aktif': AnalyticsScreen.chatTabAktif, + '/chat/pembayaran': AnalyticsScreen.chatTabPembayaran, + '/chat/selesai': AnalyticsScreen.chatTabSelesai, + '/chat/transcript/:sessionId': AnalyticsScreen.chatTranscript, + '/bestie/history': AnalyticsScreen.bestieHistory, +}; + +/// `nameExtractor` for [FirebaseAnalyticsObserver]. GoRouter sets +/// `Route.settings.name` to the route's path template, so this strips path +/// params (`:sessionId` etc.) by construction. +String? _screenNameFor(RouteSettings settings) { + final name = settings.name; + if (name == null) return null; + return _screenNameByRoute[name]?.value; +} + GoRouter buildRouter(Ref ref) { final notifier = RouterNotifier(ref); + final analyticsObserver = FirebaseAnalyticsObserver( + analytics: FirebaseAnalytics.instance, + nameExtractor: _screenNameFor, + ); return GoRouter( initialLocation: kThemePreviewEnabled ? '/_theme_preview' : '/splash', refreshListenable: notifier, + observers: [analyticsObserver], redirect: (context, state) { // Theme preview is dev-only and intentionally bypasses auth + onboarding // gates so it can be opened on any device build. diff --git a/client_app/macos/Flutter/GeneratedPluginRegistrant.swift b/client_app/macos/Flutter/GeneratedPluginRegistrant.swift index a5136ad..41805c5 100644 --- a/client_app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/client_app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,7 @@ import FlutterMacOS import Foundation +import firebase_analytics import firebase_core import firebase_messaging import flutter_local_notifications @@ -17,6 +18,7 @@ import url_launcher_macos import webview_flutter_wkwebview func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FirebaseAnalyticsPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) diff --git a/client_app/pubspec.lock b/client_app/pubspec.lock index 72a2331..fd9c01c 100644 --- a/client_app/pubspec.lock +++ b/client_app/pubspec.lock @@ -297,6 +297,30 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + firebase_analytics: + dependency: "direct main" + description: + name: firebase_analytics + sha256: "4f85b161772e1d54a66893ef131c0a44bd9e552efa78b33d5f4f60d2caa5c8a3" + url: "https://pub.dev" + source: hosted + version: "11.6.0" + firebase_analytics_platform_interface: + dependency: transitive + description: + name: firebase_analytics_platform_interface + sha256: a44b6d1155ed5cae7641e3de7163111cfd9f6f6c954ca916dc6a3bdfa86bf845 + url: "https://pub.dev" + source: hosted + version: "4.4.3" + firebase_analytics_web: + dependency: transitive + description: + name: firebase_analytics_web + sha256: c7d1ed1f86ae64215757518af5576ff88341c8ce5741988c05cc3b2e07b0b273 + url: "https://pub.dev" + source: hosted + version: "0.5.10+16" firebase_core: dependency: "direct main" description: diff --git a/client_app/pubspec.yaml b/client_app/pubspec.yaml index c193aed..7f56853 100644 --- a/client_app/pubspec.yaml +++ b/client_app/pubspec.yaml @@ -14,6 +14,9 @@ dependencies: # Firebase (Messaging only — Auth dropped in Phase 3.4, self-managed JWT now) firebase_core: ^3.12.1 firebase_messaging: ^15.2.5 + # GA4 funnel analytics (no PII — see requirement/analytics-funnel-plan.md). + # ^11.x is the firebase_core ^3.x-compatible major. + firebase_analytics: ^11.4.4 # Social login (kept — buttons gated server-side via /api/shared/auth-providers # until the corresponding OAuth env vars are set on the backend) From f59fa0e27f9cddbdd11fbfbccc7b1bfc68be8606 Mon Sep 17 00:00:00 2001 From: Ramadhan Sjamsani Date: Tue, 2 Jun 2026 21:57:42 +0800 Subject: [PATCH 2/4] fix(client/android): enable Firebase Analytics upload on AGP 8 google-services plugin 4.3.10 is incompatible with AGP 8.x: it generated google_app_id into a values.xml but never merged it into the APK, so native Firebase reported "Missing google_app_id. Firebase Analytics disabled" and uploaded nothing (FCM still worked via the Dart-side init, masking it). - bump com.google.gms.google-services 4.3.10 -> 4.4.2 - correct firebase_options.dart android appId from the stale com.halobestie.client.client_app registration to the com.mybestie app id (1:1068156046511:android:4f8fe9a3c7c14c57b8185a) so the Dart [DEFAULT] app matches google-services.json Verified: google_app_id now merges into R.txt and logcat FA shows "App measurement enabled ... Uploading data". Co-Authored-By: Claude Opus 4.8 (1M context) --- client_app/android/settings.gradle.kts | 2 +- client_app/lib/firebase_options.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client_app/android/settings.gradle.kts b/client_app/android/settings.gradle.kts index 511c251..9a76243 100644 --- a/client_app/android/settings.gradle.kts +++ b/client_app/android/settings.gradle.kts @@ -21,7 +21,7 @@ plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("com.android.application") version "8.11.1" apply false // START: FlutterFire Configuration - id("com.google.gms.google-services") version("4.3.10") apply false + id("com.google.gms.google-services") version("4.4.2") apply false // END: FlutterFire Configuration id("org.jetbrains.kotlin.android") version "2.2.20" apply false } diff --git a/client_app/lib/firebase_options.dart b/client_app/lib/firebase_options.dart index 57ea270..79c3c92 100644 --- a/client_app/lib/firebase_options.dart +++ b/client_app/lib/firebase_options.dart @@ -51,7 +51,7 @@ class DefaultFirebaseOptions { static const FirebaseOptions android = FirebaseOptions( apiKey: 'AIzaSyDFWlLSWytqwI7LSdUbVrO7J5De9L2LV2U', - appId: '1:1068156046511:android:ba6e699216de1c50b8185a', + appId: '1:1068156046511:android:4f8fe9a3c7c14c57b8185a', messagingSenderId: '1068156046511', projectId: 'halobestie-clone-dev', storageBucket: 'halobestie-clone-dev.firebasestorage.app', From 7e218decae741adae405e3bcfcc273d79777eba3 Mon Sep 17 00:00:00 2001 From: Ramadhan Sjamsani Date: Tue, 2 Jun 2026 21:57:42 +0800 Subject: [PATCH 3/4] docs(analytics): add funnel plan + live events reference - analytics-funnel-plan.md: design rationale, hybrid client/server stitching, identity model, GA4 setup - analytics-events-reference.md: live event dictionary + two Mermaid flow diagrams (funnel event flow + route/sheet navigation map) Co-Authored-By: Claude Opus 4.8 (1M context) --- requirement/analytics-events-reference.md | 191 ++++++++++++++++++ requirement/analytics-funnel-plan.md | 235 ++++++++++++++++++++++ 2 files changed, 426 insertions(+) create mode 100644 requirement/analytics-events-reference.md create mode 100644 requirement/analytics-funnel-plan.md diff --git a/requirement/analytics-events-reference.md b/requirement/analytics-events-reference.md new file mode 100644 index 0000000..469be39 --- /dev/null +++ b/requirement/analytics-events-reference.md @@ -0,0 +1,191 @@ +# Analytics Events Reference — client_app (GA4 / Firebase Analytics) + +> Companion to `requirement/analytics-funnel-plan.md` (the design). This is the +> **single source of truth for what is actually instrumented** in client_app as +> of 2026-06-02. Any new event must be added to this table *before* it is coded +> (governance, plan §9). +> +> Scope: **client_app only** · `user_id` = customer UUID · **no PII** · client +> events live now; server (Measurement Protocol) events are deferred. + +--- + +## 1. Identity & user properties + +| Key | Set where | Values | Notes | +|---|---|---|---| +| `user_id` | auth listener (main.dart) on resolve/upgrade | customer UUID | opaque; same row across anon→verified. Never phone/name. | +| `user_type` (user property) | same | `anonymous` \| `verified` | | +| `is_returning` (user property) | available via `setIsReturning` | `true` \| `false` | wire when "has ≥1 prior session" signal is read | + +--- + +## 2. Event dictionary (custom events) + +Type: **C** = fired client-side now · **S** = server-side (Measurement Protocol), **deferred** · **auto** = Firebase auto-collected. + +| Event | Type | Params | Trigger / location | +|---|---|---|---| +| `app_open` / `first_open` / `session_start` | auto | — | Firebase default | +| `screen_view` | auto (C) | `screen_name` | GoRouter observer — **page routes only** (see §4) | +| `curhat_start` | C | `funnel=activation`, `entry_point=home_primary` | Home "Aku Mau Curhat" CTA | +| `curhat_repeat_start` | C | `funnel=repeat` | Home "Aku Mau Curhat" (returning) / returning path | +| `bestie_choice_view` | C | — | `bestie_choice_sheet` shown (returning user with history) | +| `bestie_choice_select` | C | `choice=known_bestie\|new_bestie` | bestie-choice sheet card tap | +| `bestie_reselect` | C | `funnel=repeat`, `mitra_ref` (hashed) | `/bestie/history` row tap (targeted) | +| `verif_choice_view` | C | — | `verif_choice_sheet` shown (post anon-login) | +| `verif_choice_select` | C | `choice=verified\|anonymous` | verif-choice sheet decision (not on dismiss) | +| `auth_start` | C | `method=phone` | register screen "kirim kode" | +| `auth_otp_submit` | C | — | OTP screen submit | +| `auth_complete` | C | `user_type` | OTP verified resolve (verified) · display-name anon resolve (anonymous) | +| `onboarding_usp_view` | C | `verified` | USP screen initState | +| `payment_view` | C | `funnel`, `is_repeat` | `/payment/entry` initState | +| `payment_method_select` | C | `method` | payment-**channel** selection on `/payment/method` (once per change) — note: the chat/call mode picker is `curhat_mode_pick` on `/payment/method-pick` | +| `payment_started` ⭐ | C | `payment_request_id`, `amount`, `currency=IDR`, `method`, `funnel`, `is_repeat`, `product_type`, `duration_minutes` | `payment_method_screen._onPay`, **after** POST returns id | +| `pairing_matched` | C | `funnel` | `/chat/found` initState | +| `pairing_no_bestie` | C | `funnel` | `/chat/no-bestie` initState | +| `extension_offer_view` | C | `session_id` | `pricing_bottom_sheet` shown for extension (chat) | +| `chat_extension_requested` | C | `session_id` | user confirms extension (`PricingBottomSheet._onConfirm`) | +| `payment_confirmed` ⭐ | **S — deferred** | mirrors `payment_started` + `session_id`, `engagement_time_msec` | webhook → `payment_request.confirmed` | +| `payment_failed` | **S — deferred** | `payment_request_id`, `reason` | expiry/failure | +| `chat_session_start` ⭐ | **S — deferred** | `session_id`, `funnel`, `is_repeat`, `duration_minutes` | session.service start | +| `chat_session_end` | **S — deferred** | `session_id`, `end_reason`, `messages_count` | session end / timer | + +`funnel`/`is_repeat` are derived from `paymentDraftNotifierProvider.targetedMitraId != null` (targeted mitra ⇒ repeat funnel). + +### ⭐ Stitching keys carried on payment +Sent on `POST /api/client/payment-requests` body as `analytics:{ app_instance_id, ga_session_id }` (backend currently ignores). They let the deferred server `payment_confirmed` join the client funnel: `app_instance_id` (device/session), `user_id` (user), `payment_request_id` (exact attempt). Full rationale: plan §3. + +--- + +## 3. Screen views tracked (page routes) + +Auto `screen_view` fires for every GoRoute, mapped to a stable `screen_name` (path params stripped): + +`splash · auth_display_name · auth_register · auth_otp · auth_set_name · auth_force_register · onboarding_usp_verified · onboarding_usp_anon · onboarding_notif_gate · home · profile · payment_entry · payment_discount_paywall · curhat_mode_pick · payment_duration_pick · payment_method · payment_waiting · payment_expired · chat_searching · chat_found · chat_no_bestie · chat_waiting_targeted · chat_session · chat_thank_you · chat_tab_aktif · chat_tab_pembayaran · chat_tab_selesai · chat_transcript · bestie_history` + +--- + +## 4. Bottom sheets & modals + +Sheets/dialogs (`showModalBottomSheet` / `showDialog`) push routes with a **null `RouteSettings.name`**, so the `FirebaseAnalyticsObserver` skips them — they get **no auto `screen_view`**. Funnel-relevant sheets are instead instrumented with explicit `*_view` / `*_select` events (logged from the sheet's show/onTap). Each tracked sheet fires a `view` when shown and a `select` when the user acts; the **gap between them = abandonment**. + +| Sheet / dialog | Funnel relevance | Tracking | +|---|---|---| +| `verif_choice_sheet` (verify vs anonymous) | **high** | ✅ `verif_choice_view` + `verif_choice_select{choice}` | +| `bestie_choice_sheet` (new vs known bestie fork) | **high** | ✅ `bestie_choice_view` + `bestie_choice_select{choice}` | +| `pricing_bottom_sheet` (extension upsell in chat) | **medium** (monetization) | ✅ `extension_offer_view` + `chat_extension_requested` | +| `topic_selection_bottom_sheet` (pre-chat topic pick) | — | ⬜ **dead code** — `.show()` never called; track only once wired into a flow | +| `tanya_admin_sheet` (support) | low | ⬜ not tracked (negligible funnel value) | +| `bestie_unavailable_dialog` | low | ⬜ not tracked | +| `closing_message_sheet` (goodbye) | low | ⬜ not tracked | + +**Why not the rest:** the `verif_choice` and `bestie_choice` *outcomes* are also inferable from downstream events (`auth_start` vs anon `payment_view`; `bestie_history` view vs direct `payment_view`) — the explicit events add the **abandonment** signal you can't otherwise see, plus one-step branch clarity. The extension pair is pure net-new (no other event covers extension take-rate). The low-tier sheets are support/edge surfaces and intentionally left untracked to avoid noise. + +--- + +## 5. Visual flows + +Two views of the same instrumentation: +- **5.1 Funnel event flow** — the abstract conversion funnel (what GA4 reports on). +- **5.2 Screen navigation map** — the real route/screen/sheet flow with each event pinned to where it fires (what you'll see live in DebugView). + +### 5.1 Funnel event flow + +```mermaid +flowchart TD + classDef evt fill:#FFE3F0,stroke:#FF699F,color:#7A1F47; + classDef srv fill:#E3ECFF,stroke:#3B6FE0,color:#1B2E5A,stroke-dasharray:4 3; + classDef sheet fill:#FFF6D9,stroke:#D9A400,color:#5A4500; + + AO([app_open / session_start]):::evt --> HOME[screen_view: home] + + %% ---- Activation funnel ---- + HOME --> CS{{bestie_choice_sheet — not viewed}}:::sheet + CS -->|new bestie| CSTART([curhat_start
funnel=activation]):::evt + CSTART --> AUTH[screen_view: auth_register] + AUTH --> ASTART([auth_start method=phone]):::evt + ASTART --> OTP[screen_view: auth_otp] + OTP --> AOTP([auth_otp_submit]):::evt + AOTP --> ACOMP([auth_complete user_type]):::evt + ACOMP --> USP([onboarding_usp_view]):::evt + USP --> PV([payment_view funnel,is_repeat]):::evt + PV --> PMS([payment_method_select method]):::evt + PMS --> PSTART([payment_started ⭐
+ app_instance_id, ga_session_id sent on POST]):::evt + PSTART --> PCONF([payment_confirmed ⭐
SERVER — deferred]):::srv + PCONF --> PM{pairing} + PM -->|matched| PMATCH([pairing_matched]):::evt + PM -->|none| PNB([pairing_no_bestie]):::evt + PMATCH --> CSS([chat_session_start ⭐
SERVER — deferred]):::srv + CSS --> CSE([chat_session_end
SERVER — deferred]):::srv + + %% ---- Repeat funnel ---- + HOME --> RSTART([curhat_repeat_start
funnel=repeat]):::evt + RSTART --> BHIST[screen_view: bestie_history] + BHIST --> BRESEL([bestie_reselect funnel=repeat]):::evt + BRESEL --> PV2([payment_view is_repeat=true]):::evt + PV2 --> PMS +``` + +Legend — pink = client event (live) · blue dashed = server event (deferred) · yellow = bottom sheet (not auto-tracked). + +### 5.2 Screen navigation map (routes + sheets + events) + +Real GoRouter routes (blue `screen_view` nodes), bottom sheets (yellow, **no** `screen_view`), and the exact event each transition fires (pink = live, dashed = deferred server). Both home CTAs read **"Aku Mau Curhat"**; the path taken depends on auth state, not the label. + +```mermaid +flowchart TD + classDef screen fill:#E3ECFF,stroke:#3B6FE0,color:#1B2E5A; + classDef evt fill:#FFE3F0,stroke:#FF699F,color:#7A1F47; + classDef srv fill:#EFE3FF,stroke:#7B3BE0,color:#2E1B5A,stroke-dasharray:4 3; + classDef sheet fill:#FFF6D9,stroke:#D9A400,color:#5A4500; + + SPL[/splash/]:::screen --> HOME[/home/]:::screen + + %% ===== FRESH USER (activation) ===== + HOME -->|tap CTA · curhat_start| DN[/auth/display-name/]:::screen + DN -.loginAnonymous.-> VCS{{verif_choice_sheet
verif_choice_view}}:::sheet + VCS -->|verify · verif_choice_select| REG[/auth/register/]:::screen + VCS -->|lanjut tanpa verif · verif_choice_select| PE + REG -->|auth_start| OTP[/auth/otp/]:::screen + OTP -->|auth_otp_submit → auth_complete| UVU[/onboarding/verif/usp/]:::screen + UVU -->|onboarding_usp_view| PE + + %% ===== RETURNING USER (repeat) ===== + HOME -->|tap CTA · curhat_repeat_start| BCS{{bestie_choice_sheet
bestie_choice_view}}:::sheet + BCS -->|new bestie · bestie_choice_select| PE + BCS -->|known bestie · bestie_choice_select| BHL[/bestie/history/]:::screen + BHL -->|tap row · bestie_reselect| PE + + %% ===== SHARED PAYMENT SHELL ===== + %% NOTE: /payment/method-pick is the chat-vs-call MODE picker (curhat_mode_pick), + %% NOT the channel picker. The channel picker is /payment/method (payment_method), + %% where payment_method_select fires. + PE[/payment/entry/
payment_view/]:::screen --> MODE[/payment/method-pick/
curhat_mode_pick/]:::screen + MODE -->|chat / call| DUR[/payment/duration-pick/
payment_duration_pick/]:::screen + DUR --> PMETH[/payment/method/
payment_method + payment_method_select/]:::screen + PMETH -->|tap bayar · payment_started ⭐
+ app_instance_id & ga_session_id on POST| WP[/payment/waiting/:id/]:::screen + WP -.->|payment_confirmed ⭐ SERVER deferred| PCONF([backend webhook]):::srv + + %% ===== PAIRING + CHAT ===== + PCONF --> SRCH[/chat/searching/]:::screen + SRCH -->|matched · pairing_matched| FOUND[/chat/found/]:::screen + SRCH -->|none · pairing_no_bestie| NOB[/chat/no-bestie/]:::screen + FOUND --> SESS[/chat/session/:id/]:::screen + SESS -.->|chat_session_start / _end ⭐ SERVER deferred| SSRV([session.service]):::srv + SESS -->|tap perpanjang| EXT{{pricing_bottom_sheet
extension_offer_view}}:::sheet + EXT -->|confirm · chat_extension_requested| SESS +``` + +Legend — blue = page route (auto `screen_view`) · pink label = client event fired on that transition · yellow = bottom sheet (no `screen_view`) · purple dashed = deferred server event. + +> The three funnel-relevant sheets — **verify-vs-anonymous** (`verif_choice_sheet`), **new-vs-known-bestie** (`bestie_choice_sheet`), and the **extension upsell** (`pricing_bottom_sheet`) — each fire a `*_view` on show and a `*_select` / `chat_extension_requested` on action, so both the branch taken and sheet abandonment are measurable. See §4 for which sheets are intentionally left untracked. + +--- + +## 6. GA4 setup checklist (console) + +- Register custom dimensions: `funnel`, `is_repeat`, `method`, `user_type`, `payment_request_id`, `product_type`, `end_reason`. +- Mark key events / conversions: `payment_confirmed`, `chat_session_start` (once server phase lands; until then `payment_started` is the furthest reliable client conversion). +- Build two Funnel Explorations (activation / repeat) filtered by `funnel` / `is_repeat`. +- Validate end-to-end in **DebugView** with a debug build before release. diff --git a/requirement/analytics-funnel-plan.md b/requirement/analytics-funnel-plan.md new file mode 100644 index 0000000..e9706c3 --- /dev/null +++ b/requirement/analytics-funnel-plan.md @@ -0,0 +1,235 @@ +# Funnel Analytics Plan — Firebase Analytics (GA4) + +> Status: PLAN / draft for review. Scope decisions (2026-06-02): +> **client_app only** · **hybrid client+server events** · **user_id = customer UUID, no PII** · **full-lifecycle taxonomy** (activation + repeat/retention). + +--- + +## 1. Objectives + +1. Measure the **activation funnel** (acquisition → first paid chat) and the **repeat/retention funnel** (returning user → curhat lagi → paid chat) in one consistent event taxonomy. +2. Attribute drop-off to specific screens/steps so product can act on it. +3. Keep authoritative money/session events **server-side** so they are never lost when the app is backgrounded or killed mid-payment. +4. **No PII** ever leaves the device into GA4 — no phone number, display name, or chat content. Identity is an opaque customer UUID only. + +--- + +## 2. The two funnels (full lifecycle map) + +Screen/route references are from `client_app/lib/router.dart`. + +### Funnel A — Activation (first paid chat) +| # | Step (GA4 funnel step) | Event | Where | +|---|---|---|---| +| 1 | App open | `app_open` (auto) | Firebase auto | +| 2 | Home viewed | `screen_view{home}` | `/home` | +| 3 | Start curhat tapped | `curhat_start` | Home CTA "Aku Mau Curhat" | +| 4 | Auth started | `auth_start` | `/auth/display-name` / register | +| 5 | OTP submitted | `auth_otp_submit` | `/auth/otp` | +| 6 | Identified (verified or anon) | `auth_complete` | post-OTP / loginAnonymous | +| 7 | USP/onboarding seen | `onboarding_usp_view` | `/onboarding/*/usp` | +| 8 | Payment entry | `payment_view` | `/payment/entry` | +| 9 | Payment channel chosen | `payment_method_select` | `/payment/method` (channel picker; the chat/call mode picker is `curhat_mode_pick` on `/payment/method-pick`) | +| 10 | Payment created (invoice) | `payment_started` ⭐ | POST `/api/client/payment-requests` | +| 11 | **Payment confirmed** | `payment_confirmed` ⭐ **SERVER** | webhook → `payment_request.confirmed` | +| 12 | Paired with mitra | `pairing_matched` | `/chat/found` | +| 13 | Chat started | `chat_session_start` ⭐ **SERVER** | session.service start | +| 14 | Chat ended | `chat_session_end` **SERVER** | session end / timer | + +### Funnel B — Repeat / retention +| # | Step | Event | Where | +|---|---|---|---| +| 1 | Returning home | `screen_view{home}` | `/home` (has history) | +| 2 | "Curhat lagi" | `curhat_repeat_start` | BestieChoiceSheet | +| 3 | Picked known bestie | `bestie_reselect` | `/bestie/history` | +| 4 | Payment created | `payment_started` ⭐ (`is_repeat=true`) | targeted payment | +| 5 | Payment confirmed | `payment_confirmed` ⭐ **SERVER** | webhook | +| 6 | Targeted pairing | `pairing_targeted_*` | `/chat/waiting-targeted` | +| 7 | Chat started | `chat_session_start` ⭐ **SERVER** | session start | + +⭐ = the events that must join cleanly across client→server (see §3). +Funnel A and B share the same `payment_started` / `payment_confirmed` / `chat_session_start` events — they are distinguished by the **`funnel` / `is_repeat` event params**, not by separate event names. This keeps GA4 reports simple and lets one funnel exploration filter by param. + +--- + +## 3. ⭐ The hybrid join problem — "which payment event relates to which funnel?" + +This is the central design question. Answer: **three identifiers travel with every payment, so a server-fired `payment_confirmed` lands on the exact same user, session, and attempt as the client-fired `payment_started`.** + +### 3.1 The three join keys + +| Key | Joins at level | Who sets it | How it flows | +|---|---|---|---| +| `app_instance_id` | **device/app-instance** (required by GA4 MP for app streams) | Firebase SDK on device | client reads `FirebaseAnalytics.instance.appInstanceId`, sends it on payment-create, backend stores in `product_metadata`, replays it in the MP call | +| `user_id` | **user** (cross-device, cross-session) | our app | customer UUID set on both client `setUserId()` and server MP payload | +| `payment_request_id` | **attempt** (this specific purchase) | backend | returned by POST `/payment-requests`; client puts it on `payment_started`, backend puts the same value on `payment_confirmed` | + +> **Why all three:** GA4's Measurement Protocol for app streams *requires* `app_instance_id` to attribute a server event to a user's stream — `user_id` alone will record the event but standard funnel/realtime reports won't stitch it to the device's session. `user_id` gives cross-device continuity (anon→verified). `payment_request_id` is the precise attempt-level join used in Explorations/BigQuery to tie one `payment_started` to its `payment_confirmed` (compute exact payment success rate & latency). + +### 3.2 The flow, concretely + +``` +CLIENT payment_method_screen.dart + appInstanceId = await FirebaseAnalytics.instance.appInstanceId + POST /api/client/payment-requests + body: { ...draft, analytics: { app_instance_id, ga_session_id } } + ← { id: , invoice_url } + analytics.log('payment_started', { + payment_request_id, amount, currency:'IDR', method, + funnel:'activation'|'repeat', is_repeat, product_type }) + +BACKEND payment.service.createPaymentRequest() + store analytics.app_instance_id + ga_session_id into product_metadata + +BACKEND payment.service.confirmPayment() (fired from Xendit webhook) + emits 'payment_request.confirmed' + → analytics subscriber → GA4 Measurement Protocol POST: + app_instance_id = product_metadata.app_instance_id ← stitches device/session + user_id = customer_id ← stitches user + events: [{ name:'payment_confirmed', params:{ + payment_request_id, amount, currency, method, + funnel, is_repeat, session_id: ga_session_id, + engagement_time_msec: 1 }}] +``` + +`session_id` + `engagement_time_msec` in the MP params are what make GA4 attribute the server event to the **same session** as the client funnel (needed only for *session-scoped* funnel explorations; user-scoped funnels already work via `app_instance_id`+`user_id`). We capture `ga_session_id` client-side (`getSessionId()`) at payment-create and replay it. + +### 3.3 Net result +- **User-scoped funnel** (default): works via `app_instance_id` + `user_id`. +- **Session-scoped funnel**: works because we replay `ga_session_id`. +- **Exact attempt analysis** (success rate, time-to-pay): join `payment_started`↔`payment_confirmed` on `payment_request_id` in BigQuery/Explore. + +The same pattern covers `chat_session_start` / `chat_session_end` (server-authoritative) — keyed by `session_id` + `app_instance_id` + `user_id`. + +--- + +## 4. Identity & user properties + +- `setUserId(customerId)` — the customer UUID (same row across anon→verified via `anonymous_customer_id`). Set on app start once auth resolves, and re-set after identity upgrade so the verified session continues the same `user_id`. +- **User properties** (low-cardinality, no PII): + - `user_type` = `anonymous` | `verified` + - `is_returning` = whether the customer has ≥1 prior confirmed session + - `acquisition_channel` (if/when known) +- **Never** set: phone, display name, email, chat text, mitra identity. +- Set GA4 data retention + IP anonymization per the mental-health sensitivity; document in privacy section §9. + +--- + +## 5. Master event taxonomy + +Naming: `snake_case`, object_action where natural, ≤40 chars. Reserved Firebase events (`app_open`, `screen_view`, `first_open`, `session_start`) are auto-collected — do not redefine. + +| Event | Client/Server | Key params | Notes | +|---|---|---|---| +| `screen_view` | client (auto via observer) | `screen_name` | go_router observer, §6 | +| `curhat_start` | client | `funnel='activation'`, `entry_point` | Home primary CTA | +| `curhat_repeat_start` | client | `funnel='repeat'` | returning CTA | +| `bestie_reselect` | client | `mitra_ref` (opaque) | `/bestie/history` | +| `auth_start` | client | `method` (phone/google/apple) | | +| `auth_otp_submit` | client | — | | +| `auth_complete` | client | `user_type` | | +| `onboarding_usp_view` | client | `verified` | | +| `payment_view` | client | `funnel`, `is_repeat` | `/payment/entry` | +| `payment_method_select` | client | `method` | | +| `payment_started` ⭐ | client | `payment_request_id`, `amount`, `currency`, `method`, `funnel`, `is_repeat`, `product_type`, `duration_minutes` | fired right after POST returns id | +| `payment_confirmed` ⭐ | **server (MP)** | same as above + `session_id`, `engagement_time_msec` | from webhook | +| `payment_failed` | **server (MP)** | `payment_request_id`, `reason` | expiry/failure | +| `pairing_matched` | client | `funnel` | `/chat/found` | +| `pairing_no_bestie` | client | `funnel` | `/chat/no-bestie` | +| `chat_session_start` ⭐ | **server (MP)** | `session_id`, `funnel`, `is_repeat`, `duration_minutes` | authoritative | +| `chat_session_end` | **server (MP)** | `session_id`, `end_reason`, `messages_count` | authoritative | +| `chat_extension_requested` | client | `session_id` | optional | +| `app_open` / `session_start` / `first_open` | auto | — | Firebase default | + +> Keep custom params to those used in funnels/segments. Register the high-value ones as **custom dimensions** in GA4 (`funnel`, `is_repeat`, `method`, `user_type`, `payment_request_id`) so they're queryable. + +--- + +## 6. Client implementation (Flutter) + +1. **Add deps** to `client_app/pubspec.yaml`: `firebase_analytics`. Re-run `flutterfire configure` if needed (firebase_core already present; messaging configured). +2. **Init** in bootstrap after `Firebase.initializeApp()`: enable collection, set default user properties. +3. **Analytics service wrapper** — `core/analytics/analytics.dart`: + - Thin façade over `FirebaseAnalytics.instance` with typed methods (`logCurhatStart`, `logPaymentStarted`, …) so event names/params are centralized and not stringly-typed at call sites. + - Exposes `appInstanceId()` and `sessionId()` helpers for the payment-create call. + - Riverpod provider `analyticsProvider` for injection. +4. **Auto screen_view** — add `FirebaseAnalyticsObserver` to `GoRouter(observers: [...])`. Map routes → clean `screen_name`s (avoid leaking path params like `:sessionId`). +5. **user_id wiring** — in the auth notifier listener, call `setUserId` + update `user_type`/`is_returning` user properties whenever auth state resolves/upgrades. +6. **Instrument the funnel call sites** per §5 (CTAs, OTP submit, payment screens, pairing screens). Fire `payment_started` only after the POST returns a `payment_request_id`. + +> Pitfall guard (per client_app/CLAUDE.md): analytics calls inside widget teardown go in `deactivate()`, not `dispose()` — but prefer firing on the user action, not on screen disposal. + +--- + +## 7. Backend implementation (Measurement Protocol) + +1. **Config/env**: `GA4_API_SECRET`, `GA4_FIREBASE_APP_ID`, `GA4_MP_ENABLED` (default off, opt-in like Xendit/Fazpass flags). Endpoint: `https://www.google-analytics.com/mp/collect`. +2. **Capture identifiers**: extend POST `/api/client/payment-requests` to accept `analytics:{app_instance_id, ga_session_id}` and persist into `product_metadata` (already JSONB, already "for analytics"). +3. **Analytics emitter service** — `services/analytics-mp.service.js`: + - `sendEvent({ appInstanceId, userId, name, params })` → builds MP payload, POSTs, logs failures non-fatally (analytics must never break payment). + - Always include `engagement_time_msec` and `session_id` for app-stream session attribution. +4. **Subscribe to existing internal events** (no new webhook plumbing needed): + - `payment_request.confirmed` → `payment_confirmed` + - payment expiry/failure → `payment_failed` + - session start/end (session.service) → `chat_session_start` / `chat_session_end` +5. **Validation**: use GA4 MP **debug endpoint** (`/debug/mp/collect`) in dev to assert payloads before enabling. + +> Reliability: MP sends are fire-and-forget with a short timeout + retry-once; wrap in try/catch so a GA outage never affects the money path. Consider a lightweight outbox if we later need delivery guarantees. + +--- + +## 8. GA4 configuration (console) + +1. Create/confirm the **Firebase Analytics → GA4 property** for client_app (Android + iOS app streams). +2. Register **custom dimensions**: `funnel`, `is_repeat`, `method`, `user_type`, `payment_request_id`, `product_type`, `end_reason`. +3. Build **two Funnel Explorations**: + - *Activation*: steps = `curhat_start → auth_complete → payment_started → payment_confirmed → pairing_matched → chat_session_start`, filter `funnel=activation`. + - *Repeat*: steps = `curhat_repeat_start → payment_started → payment_confirmed → chat_session_start`, filter `is_repeat=true`. +4. Mark **conversions/key events**: `payment_confirmed`, `chat_session_start`. +5. (Optional, recommended) Enable **BigQuery export** for attempt-level joins (`payment_started`↔`payment_confirmed` on `payment_request_id`) that GA's UI can't express precisely. +6. Use **DebugView** with a debug build to validate the full funnel end-to-end before release. + +--- + +## 9. Privacy & governance + +- **No PII** in events or user properties — enforce via the typed wrapper (no free-form string params at call sites). +- `user_id` is an opaque UUID; document the mapping policy and retention. +- Respect OS-level analytics/ads consent; gate collection behind app config so it can be disabled. +- Add a one-page **event dictionary** (this §5 table) to `requirement/` and keep it the single source of truth; any new event gets added here first (governance). +- Set GA4 data retention to the minimum that supports the funnels; enable IP anonymization. + +--- + +## 10. Implementation phases / checklist + +**Phase 1 — Client foundation** +- [ ] Add `firebase_analytics`; init + collection toggle +- [ ] `AnalyticsService` typed wrapper + Riverpod provider +- [ ] `FirebaseAnalyticsObserver` on GoRouter + screen_name map +- [ ] `setUserId` + user properties in auth listener + +**Phase 2 — Client funnel events** +- [ ] Activation events (curhat_start … pairing_matched) +- [ ] Repeat events (curhat_repeat_start, bestie_reselect) +- [ ] `payment_started` after POST returns id; capture `app_instance_id` + `ga_session_id`, send on payment-create + +**Phase 3 — Backend server events (hybrid)** +- [ ] Persist `analytics` identifiers into `product_metadata` +- [ ] `analytics-mp.service.js` + env flags +- [ ] Subscribe to `payment_request.confirmed` → `payment_confirmed` +- [ ] Subscribe to session start/end → `chat_session_*` +- [ ] Validate via MP debug endpoint + +**Phase 4 — GA4 config + validation** +- [ ] Custom dimensions, conversions +- [ ] Two funnel explorations +- [ ] DebugView end-to-end pass on a real device +- [ ] (Optional) BigQuery export + +--- + +## 11. Open questions for product +1. Confirm GA4 property already exists for client_app (or do we create fresh)? Re separate prod/dev Firebase projects — see existing `firebase_env_strategy` note. +2. Do we want BigQuery export from day one (enables exact attempt-level payment analytics)? +3. Retention window + any consent-banner requirement for the mental-health context? From 12cf9f80e9a96a125d8972b9e0118b96f20ac0c2 Mon Sep 17 00:00:00 2001 From: Ramadhan Sjamsani Date: Tue, 2 Jun 2026 21:57:42 +0800 Subject: [PATCH 4/4] chore(backend): add dev helper to provision a static-OTP mitra login setup-test-mitra-otp.mjs adds a phone+mitra-scoped entry to the app_config.test_otp_bypass allowlist and ensures an ACTIVE mitra row (createMitra defaults inactive -> 403). Dev/QA convenience; the bypass is checked before Fazpass in requestOtp so it short-circuits even when FAZPASS_ENABLED=true. Idempotent. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/scripts/setup-test-mitra-otp.mjs | 85 ++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 backend/scripts/setup-test-mitra-otp.mjs diff --git a/backend/scripts/setup-test-mitra-otp.mjs b/backend/scripts/setup-test-mitra-otp.mjs new file mode 100644 index 0000000..379baa9 --- /dev/null +++ b/backend/scripts/setup-test-mitra-otp.mjs @@ -0,0 +1,85 @@ +// Dev helper: provision a STATIC-OTP mitra login for local/QA testing. +// +// Uses the existing test-OTP-bypass allowlist (app_config.test_otp_bypass), +// the same mechanism shipped for Apple-reviewer QA. It: +// 1. ensures an ACTIVE mitra exists with the test phone (mitras default to +// is_active=false, which the mitra verify route rejects with 403), +// 2. adds a phone-scoped, mitra-scoped static OTP entry (bcrypt-hashed), +// 3. flips the global bypass kill-switch on. +// +// After running, log into the mitra app with PHONE + OTP below — no Fazpass, +// no console code-reading. Re-running is idempotent. +// +// Usage (from backend/): node scripts/setup-test-mitra-otp.mjs +// Override defaults: TEST_MITRA_PHONE=+628... TEST_MITRA_OTP=123456 node scripts/setup-test-mitra-otp.mjs + +import 'dotenv/config' +import { getDb } from '../src/db/client.js' +import { + getTestOtpBypass, + addTestOtpBypassEntry, + setTestOtpBypassEnabled, +} from '../src/services/config.service.js' + +const PHONE = process.env.TEST_MITRA_PHONE || '+6281200000001' +const OTP = process.env.TEST_MITRA_OTP || '123456' +const LABEL = process.env.TEST_MITRA_LABEL || 'Dev static OTP (mitra)' +const DISPLAY_NAME = process.env.TEST_MITRA_NAME || 'Test Bestie' +// Far-future expiry — the allowlist requires a future expires_at per entry. +const EXPIRES_AT = '2099-01-01T00:00:00.000Z' + +const sql = getDb() + +async function main () { + // 1. Ensure an ACTIVE mitra with this phone (raw SQL — avoids importing + // mitra.service, which pulls in the valkey plugin and would leave an open + // handle keeping this script alive). + const [existing] = await sql`SELECT id, is_active FROM mitras WHERE phone = ${PHONE}` + if (!existing) { + const [m] = await sql` + INSERT INTO mitras (phone, display_name, is_active) + VALUES (${PHONE}, ${DISPLAY_NAME}, true) + RETURNING id + ` + console.log(` created active mitra ${m.id} (${PHONE})`) + } else if (!existing.is_active) { + await sql`UPDATE mitras SET is_active = true WHERE id = ${existing.id}` + console.log(` mitra ${existing.id} existed — activated`) + } else { + console.log(` mitra ${existing.id} already exists and active`) + } + + // 2. Add the static-OTP allowlist entry (skip if one already exists for this + // phone+mitra — addTestOtpBypassEntry throws on duplicate). + const current = await getTestOtpBypass() + const exists = current.entries.some(e => e.phone === PHONE && e.user_type === 'mitra') + if (exists) { + console.log(' bypass entry already present for this phone+mitra — leaving as is') + console.log(' (to rotate the OTP: delete the entry in CC → Settings, then re-run)') + } else { + await addTestOtpBypassEntry({ + phone: PHONE, + otp: OTP, + user_type: 'mitra', + label: LABEL, + expires_at: EXPIRES_AT, + }) + console.log(` added bypass entry: ${PHONE} → otp ${OTP} (mitra)`) + } + + // 3. Global kill-switch ON. + await setTestOtpBypassEnabled(true) + console.log(' bypass allowlist ENABLED') + + console.log('\n✅ Static mitra OTP ready:') + console.log(` phone: ${PHONE}`) + console.log(` otp: ${exists ? '(unchanged — set on a previous run)' : OTP}`) +} + +main() + .then(() => sql.end({ timeout: 5 })) + .catch(async (err) => { + console.error('FAILED:', err.message) + await sql.end({ timeout: 5 }) + process.exit(1) + })