Compare commits

..

4 Commits

Author SHA1 Message Date
12cf9f80e9 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) <noreply@anthropic.com>
2026-06-02 21:57:42 +08:00
7e218decae 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) <noreply@anthropic.com>
2026-06-02 21:57:42 +08:00
f59fa0e27f 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) <noreply@anthropic.com>
2026-06-02 21:57:42 +08:00
eeb4ea38fc feat(client/analytics): GA4 funnel instrumentation + unified home CTA
Add Firebase Analytics (GA4) funnel tracking to client_app:
- AnalyticsService typed wrapper (enum-gated, no PII) + analyticsProvider
- FirebaseAnalyticsObserver on GoRouter (screen_name via nameExtractor)
- user_id = customer UUID, user_type property, set on auth resolve/upgrade
- funnel events: curhat_start, curhat_repeat_start, auth_*, onboarding_usp_view,
  payment_view, payment_method_select, payment_started, pairing_matched/no_bestie
- bottom-sheet events: verif_choice_view/select, bestie_choice_view/select,
  extension_offer_view, chat_extension_requested
- payment_started carries app_instance_id + ga_session_id in the
  /payment-requests body for future server-side stitching (backend ignores)
- curhat_mode_pick screen name disambiguates the chat/call mode picker
  (/payment/method-pick) from the payment-channel picker (/payment/method)
- unify both home CTAs to "Aku Mau Curhat"

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 21:57:26 +08:00
30 changed files with 1107 additions and 25 deletions

View File

@@ -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)
})

View File

@@ -21,7 +21,7 @@ plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false id("com.android.application") version "8.11.1" apply false
// START: FlutterFire Configuration // 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 // END: FlutterFire Configuration
id("org.jetbrains.kotlin.android") version "2.2.20" apply false id("org.jetbrains.kotlin.android") version "2.2.20" apply false
} }

View File

@@ -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<void> setUserId(String? customerId) =>
_analytics.setUserId(id: customerId);
Future<void> setUserType(AnalyticsUserType type) =>
_analytics.setUserProperty(name: 'user_type', value: type.value);
Future<void> 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<String?> appInstanceId() => _analytics.appInstanceId;
/// Current GA session id — replayed server-side so the MP event lands in the
/// same session as the client funnel.
Future<int?> sessionId() => _analytics.getSessionId();
// ── Funnel events (§5) ────────────────────────────────────────────────────
/// Activation: Home primary "aku mau curhat" CTA.
Future<void> 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<void> 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<void> logBestieReselect({String? mitraRef}) => _log('bestie_reselect', {
'funnel': AnalyticsFunnel.repeat.value,
if (mitraRef != null) 'mitra_ref': mitraRef,
});
Future<void> logAuthStart(AnalyticsAuthMethod method) => _log('auth_start', {
'method': method.value,
});
Future<void> logAuthOtpSubmit() => _log('auth_otp_submit', const {});
Future<void> logAuthComplete(AnalyticsUserType userType) =>
_log('auth_complete', {'user_type': userType.value});
Future<void> logOnboardingUspView({required bool verified}) =>
_log('onboarding_usp_view', {'verified': verified});
Future<void> logPaymentView({
required AnalyticsFunnel funnel,
required bool isRepeat,
}) =>
_log('payment_view', {
'funnel': funnel.value,
'is_repeat': isRepeat,
});
Future<void> logPaymentMethodSelect({required String method}) =>
_log('payment_method_select', {'method': method});
/// ⭐ Fired only AFTER the POST /payment-requests returns an id.
Future<void> 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<void> logPairingMatched({required AnalyticsFunnel funnel}) =>
_log('pairing_matched', {'funnel': funnel.value});
Future<void> 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<void> 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<void> 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<void> logBestieChoiceView() =>
_log('bestie_choice_view', const {});
/// Repeat: user picked a bestie branch in the Bestie Choice Sheet.
Future<void> 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<void> 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<void> logChatExtensionRequested({String? sessionId}) =>
_log('chat_extension_requested', {
if (sessionId != null) 'session_id': sessionId,
});
// ── Internal ──────────────────────────────────────────────────────────────
Future<void> _log(String name, Map<String, Object?> params) async {
final clean = <String, Object>{
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);

View File

@@ -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<AnalyticsService>.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<AnalyticsService>;
// 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

View File

@@ -6,7 +6,7 @@ part of 'auth_notifier.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$authHash() => r'f0382b1380749cebe8182ad221023926768ebb92'; String _$authHash() => r'76cd43babe5503d35a35c8fd23ba32afbc4c8c2d';
/// See also [Auth]. /// See also [Auth].
@ProviderFor(Auth) @ProviderFor(Auth)

View File

@@ -30,7 +30,7 @@ final chatRemainingSecondsProvider = AutoDisposeStreamProvider<int>.internal(
@Deprecated('Will be removed in 3.0. Use Ref instead') @Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element // ignore: unused_element
typedef ChatRemainingSecondsRef = AutoDisposeStreamProviderRef<int>; typedef ChatRemainingSecondsRef = AutoDisposeStreamProviderRef<int>;
String _$chatHash() => r'a42bf404f5b38034c6c9511259b64b1092af3caa'; String _$chatHash() => r'56f019ce6e527128ab42a71f56220c5412cfec0f';
/// See also [Chat]. /// See also [Chat].
@ProviderFor(Chat) @ProviderFor(Chat)

View File

@@ -6,7 +6,7 @@ part of 'session_closure_notifier.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$sessionClosureHash() => r'a9aaf26da64d3489497d057e2b05741c08444e07'; String _$sessionClosureHash() => r'1ab9df044138115e232b3df494e2895177d9d66d';
/// See also [SessionClosure]. /// See also [SessionClosure].
@ProviderFor(SessionClosure) @ProviderFor(SessionClosure)

View File

@@ -6,7 +6,7 @@ part of 'pairing_notifier.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$pairingHash() => r'd39980fe5b82348d03485006d0534ab597b93ceb'; String _$pairingHash() => r'e66bbf67e1013b3d25230ed01fe2595bff943b3a';
/// See also [Pairing]. /// See also [Pairing].
@ProviderFor(Pairing) @ProviderFor(Pairing)

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/analytics/analytics_service.dart';
import '../../../core/api/api_client_provider.dart'; import '../../../core/api/api_client_provider.dart';
import '../../../core/auth/auth_notifier.dart'; import '../../../core/auth/auth_notifier.dart';
import '../../../core/theme/halo_tokens.dart'; import '../../../core/theme/halo_tokens.dart';
@@ -35,6 +36,9 @@ class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> {
final data = next.valueOrNull; final data = next.valueOrNull;
if (data is AuthAnonymousData && !_routedAfterLogin) { if (data is AuthAnonymousData && !_routedAfterLogin) {
_routedAfterLogin = true; _routedAfterLogin = true;
// Anonymous identity established — activation step 6.
// ignore: discarded_futures
ref.read(analyticsProvider).logAuthComplete(AnalyticsUserType.anonymous);
_proceedAfterLogin(); _proceedAfterLogin();
} }
}); });
@@ -82,12 +86,17 @@ class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> {
return; return;
} }
// ignore: discarded_futures
ref.read(analyticsProvider).logVerifChoiceView();
final choice = await VerifChoiceSheet.show(context); final choice = await VerifChoiceSheet.show(context);
if (!mounted || choice == null) { 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; _routedAfterLogin = false;
return; return;
} }
// ignore: discarded_futures
ref.read(analyticsProvider).logVerifChoiceSelect(choice);
if (!mounted) return; if (!mounted) return;
await routeForVerifChoice(context, ref, choice); await routeForVerifChoice(context, ref, choice);
} }

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/analytics/analytics_service.dart';
import '../../../core/api/api_client_provider.dart'; import '../../../core/api/api_client_provider.dart';
import '../../../core/auth/auth_notifier.dart'; import '../../../core/auth/auth_notifier.dart';
import '../../../core/constants.dart'; import '../../../core/constants.dart';
@@ -75,6 +76,14 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
if (_errorMessage != null && mounted) { if (_errorMessage != null && mounted) {
setState(() => _errorMessage = null); 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(); final code = _readCode();
if (code.length == _kOtpLength && !_autoSubmitted && _otpRequestId != null) { if (code.length == _kOtpLength && !_autoSubmitted && _otpRequestId != null) {
_autoSubmitted = true; _autoSubmitted = true;
// ignore: discarded_futures
ref.read(analyticsProvider).logAuthOtpSubmit();
ref.read(authProvider.notifier).verifyOtp(_otpRequestId!, code); 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/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/analytics/analytics_service.dart';
import '../../../core/auth/auth_notifier.dart'; import '../../../core/auth/auth_notifier.dart';
import '../../../core/constants.dart'; import '../../../core/constants.dart';
import '../../../core/theme/halo_tokens.dart'; import '../../../core/theme/halo_tokens.dart';
@@ -222,8 +223,15 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
: 'kirim kode', : 'kirim kode',
fullWidth: true, fullWidth: true,
onPressed: canSubmit onPressed: canSubmit
? () => ? () {
ref.read(authProvider.notifier).requestOtp(_e164Phone()) // ignore: discarded_futures
ref
.read(analyticsProvider)
.logAuthStart(AnalyticsAuthMethod.phone);
ref
.read(authProvider.notifier)
.requestOtp(_e164Phone());
}
: null, : null,
), ),
if (!fromProfile) ...[ if (!fromProfile) ...[

View File

@@ -1,9 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/analytics/analytics_service.dart';
import '../../../core/chat/active_session_notifier.dart'; import '../../../core/chat/active_session_notifier.dart';
import '../../../core/pairing/pairing_notifier.dart'; import '../../../core/pairing/pairing_notifier.dart';
import '../../../core/theme/halo_tokens.dart'; import '../../../core/theme/halo_tokens.dart';
import '../../payment/state/payment_draft_provider.dart';
import '../../../core/theme/widgets/widgets.dart'; import '../../../core/theme/widgets/widgets.dart';
/// Phase 4 Stage 5 — S9 Match-found screen. /// Phase 4 Stage 5 — S9 Match-found screen.
@@ -35,6 +37,15 @@ class _BestieFoundScreenState extends ConsumerState<BestieFoundScreen> {
@override @override
void initState() { void initState() {
super.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) { ref.listenManual<PairingData>(pairingProvider, (prev, next) {
if (!mounted) return; if (!mounted) return;
if (next is PairingActiveData) { if (next is PairingActiveData) {

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/analytics/analytics_service.dart';
import '../../../core/chat/active_session_notifier.dart'; import '../../../core/chat/active_session_notifier.dart';
import '../../../core/chat/chat_notifier.dart'; import '../../../core/chat/chat_notifier.dart';
import '../../../core/chat/session_closure_notifier.dart'; import '../../../core/chat/session_closure_notifier.dart';
@@ -512,6 +513,8 @@ class _TimerBanner extends ConsumerWidget {
return _BannerKind.none; return _BannerKind.none;
})); }));
void onExtend() { void onExtend() {
// ignore: discarded_futures
ref.read(analyticsProvider).logExtensionOfferView(sessionId: sessionId);
PricingBottomSheet.showForExtension( PricingBottomSheet.showForExtension(
context, context,
sessionId: sessionId, sessionId: sessionId,

View File

@@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/analytics/analytics_service.dart';
import '../../../core/pairing/pairing_notifier.dart'; import '../../../core/pairing/pairing_notifier.dart';
import '../../payment/state/payment_draft_provider.dart';
/// Terminal failed-pairing screen. /// 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 /// Single CTA "Kembali ke beranda" resets the pairing notifier and routes
/// home. PopScope falls back to home for deep-link entry per project memory /// home. PopScope falls back to home for deep-link entry per project memory
/// rule "Deep-link pop fallback". /// rule "Deep-link pop fallback".
class NoBestieScreen extends ConsumerWidget { class NoBestieScreen extends ConsumerStatefulWidget {
const NoBestieScreen({super.key}); const NoBestieScreen({super.key});
@override @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( return PopScope(
canPop: true, canPop: true,
onPopInvokedWithResult: (didPop, _) { onPopInvokedWithResult: (didPop, _) {

View File

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

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../core/analytics/analytics_service.dart';
import '../../core/auth/auth_notifier.dart'; import '../../core/auth/auth_notifier.dart';
import '../../core/auth/onboarding_intent_provider.dart'; import '../../core/auth/onboarding_intent_provider.dart';
import '../../core/availability/mitra_availability_notifier.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 /// CTA path for SHomeReturning. Returning users get the bestie-choice sheet
/// when they have prior history, otherwise jump to the new-payment shell. /// when they have prior history, otherwise jump to the new-payment shell.
Future<void> _onCurhatBestieBaruPressed(BuildContext context) async { 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; bool hasHistory;
try { try {
hasHistory = await ref.read(bestieHistoryHasItemsProvider.future); hasHistory = await ref.read(bestieHistoryHasItemsProvider.future);
@@ -89,6 +95,8 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
} }
if (!context.mounted) return; if (!context.mounted) return;
if (hasHistory) { if (hasHistory) {
// ignore: discarded_futures
ref.read(analyticsProvider).logBestieChoiceView();
await BestieChoiceSheet.show(context); await BestieChoiceSheet.show(context);
return; return;
} }
@@ -101,6 +109,9 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
/// (the call-sign check); display_name_screen kicks off `loginAnonymous` /// (the call-sign check); display_name_screen kicks off `loginAnonymous`
/// and pushes into the verif-choice sheet. /// and pushes into the verif-choice sheet.
void _onAkuMauCurhatPressed(BuildContext context) { 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'); context.push('/auth/display-name');
} }
@@ -172,7 +183,7 @@ class _SHome1stView extends ConsumerWidget {
const _GreetingSubtitle(), const _GreetingSubtitle(),
const SizedBox(height: 32), const SizedBox(height: 32),
_PrimaryCTA( _PrimaryCTA(
label: 'aku mau curhat', label: 'Aku Mau Curhat',
enabled: mitraAvailable, enabled: mitraAvailable,
onPressed: onCTA, onPressed: onCTA,
), ),
@@ -395,7 +406,7 @@ class _SHomeReturningView extends ConsumerWidget {
activeSessionAsync.when( activeSessionAsync.when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => _PrimaryCTA( error: (_, __) => _PrimaryCTA(
label: 'curhat sama bestie baru', label: 'Aku Mau Curhat',
enabled: mitraAvailable, enabled: mitraAvailable,
onPressed: onCTA, onPressed: onCTA,
), ),
@@ -415,7 +426,7 @@ class _SHomeReturningView extends ConsumerWidget {
); );
} }
return _PrimaryCTA( return _PrimaryCTA(
label: 'curhat sama bestie baru', label: 'Aku Mau Curhat',
enabled: mitraAvailable, enabled: mitraAvailable,
onPressed: onCTA, onPressed: onCTA,
); );

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/analytics/analytics_service.dart';
import '../../../core/constants.dart'; import '../../../core/constants.dart';
import '../../../core/theme/halo_tokens.dart'; import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart'; import '../../../core/theme/widgets/widgets.dart';
@@ -132,6 +133,13 @@ class BestieHistoryListScreen extends ConsumerWidget {
return; return;
} }
if (item.mitraId == null) 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 // Stamp the targeted mitra onto the payment draft; the
// multi-screen payment flow (entry → method → waiting → // multi-screen payment flow (entry → method → waiting →
// notif-gate → searching) reads it back to fire the // notif-gate → searching) reads it back to fire the

View File

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

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/analytics/analytics_service.dart';
import '../../../core/theme/halo_tokens.dart'; import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart'; import '../../../core/theme/widgets/widgets.dart';
import '../usp_seen_provider.dart'; import '../usp_seen_provider.dart';
@@ -11,11 +12,30 @@ import '../usp_seen_provider.dart';
/// ///
/// `verified` ➞ USP → OTP (`/auth/register`). /// `verified` ➞ USP → OTP (`/auth/register`).
/// `anonymous` ➞ USP → `/payment/method-pick`. /// `anonymous` ➞ USP → `/payment/method-pick`.
class UspScreen extends ConsumerWidget { class UspScreen extends ConsumerStatefulWidget {
final bool verified; final bool verified;
const UspScreen({super.key, required this.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 = [ static const _cards = [
_UspCard( _UspCard(
icon: Icons.bolt_outlined, icon: Icons.bolt_outlined,
@@ -40,7 +60,7 @@ class UspScreen extends ConsumerWidget {
]; ];
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Padding( title: const Padding(
@@ -94,7 +114,7 @@ class UspScreen extends ConsumerWidget {
HaloButton( HaloButton(
label: 'aku ngerti, lanjut', label: 'aku ngerti, lanjut',
fullWidth: true, 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 // Persist the local + server flag before leaving — next time the user
// hits VerifChoice, this screen is skipped. // hits VerifChoice, this screen is skipped.
await ref.read(uspSeenProvider.notifier).markSeen(); await ref.read(uspSeenProvider.notifier).markSeen();
if (!context.mounted) return; if (!context.mounted) return;
if (verified) { if (widget.verified) {
context.push('/auth/register'); context.push('/auth/register');
} else { } else {
context.push('/payment/method-pick'); context.push('/payment/method-pick');

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/analytics/analytics_service.dart';
import '../../../core/auth/onboarding_intent_provider.dart'; import '../../../core/auth/onboarding_intent_provider.dart';
import '../../../core/chat/chat_opening_provider.dart'; import '../../../core/chat/chat_opening_provider.dart';
import '../../../core/theme/halo_tokens.dart'; import '../../../core/theme/halo_tokens.dart';
@@ -38,6 +39,18 @@ class _PaymentEntryScreenState extends ConsumerState<PaymentEntryScreen> {
// inherit a stale onboarding intent. // inherit a stale onboarding intent.
ref.read(onboardingIntentProvider.notifier).state = ref.read(onboardingIntentProvider.notifier).state =
OnboardingIntent.recover; 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/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/analytics/analytics_service.dart';
import '../../../core/api/api_client_provider.dart'; import '../../../core/api/api_client_provider.dart';
import '../../../core/constants.dart'; import '../../../core/constants.dart';
import '../../../core/theme/halo_tokens.dart'; import '../../../core/theme/halo_tokens.dart';
@@ -53,7 +54,21 @@ class _PaymentMethodScreenState extends ConsumerState<PaymentMethodScreen> {
_error = null; _error = null;
}); });
final api = ref.read(apiClientProvider); final api = ref.read(apiClientProvider);
final analytics = ref.read(analyticsProvider);
try { 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>{ final body = <String, dynamic>{
'mode': draft.mode.value, 'mode': draft.mode.value,
'duration_minutes': draft.durationMinutes, 'duration_minutes': draft.durationMinutes,
@@ -61,11 +76,27 @@ class _PaymentMethodScreenState extends ConsumerState<PaymentMethodScreen> {
'is_first_session_discount': draft.isFirstSessionDiscount, 'is_first_session_discount': draft.isFirstSessionDiscount,
'method': _selectedCode, 'method': _selectedCode,
if (draft.targetedMitraId != null) 'targeted_mitra_id': draft.targetedMitraId, 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 response = await api.post('/api/client/payment-requests/', data: body);
final data = response['data'] as Map<String, dynamic>; final data = response['data'] as Map<String, dynamic>;
final paymentId = data['id'] as String; final paymentId = data['id'] as String;
ref.read(paymentDraftNotifierProvider.notifier).setPaymentId(paymentId); 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; if (!mounted) return;
context.push('/payment/waiting/$paymentId'); context.push('/payment/waiting/$paymentId');
} on DioException catch (e) { } on DioException catch (e) {
@@ -216,10 +247,20 @@ class _PaymentMethodScreenState extends ConsumerState<PaymentMethodScreen> {
_expandedGroupIds.remove(g.id); _expandedGroupIds.remove(g.id);
} }
}), }),
onSelect: (code) => setState(() { onSelect: (code) {
_selectedCode = code; // Funnel step 9 — method chosen. Fire once per pick
_error = null; // (not on every rebuild).
}), if (code != _selectedCode) {
// ignore: discarded_futures
ref
.read(analyticsProvider)
.logPaymentMethodSelect(method: code);
}
setState(() {
_selectedCode = code;
_error = null;
});
},
); );
}).toList(), }).toList(),
); );

View File

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

View File

@@ -51,7 +51,7 @@ class DefaultFirebaseOptions {
static const FirebaseOptions android = FirebaseOptions( static const FirebaseOptions android = FirebaseOptions(
apiKey: 'AIzaSyDFWlLSWytqwI7LSdUbVrO7J5De9L2LV2U', apiKey: 'AIzaSyDFWlLSWytqwI7LSdUbVrO7J5De9L2LV2U',
appId: '1:1068156046511:android:ba6e699216de1c50b8185a', appId: '1:1068156046511:android:4f8fe9a3c7c14c57b8185a',
messagingSenderId: '1068156046511', messagingSenderId: '1068156046511',
projectId: 'halobestie-clone-dev', projectId: 'halobestie-clone-dev',
storageBucket: 'halobestie-clone-dev.firebasestorage.app', storageBucket: 'halobestie-clone-dev.firebasestorage.app',

View File

@@ -1,9 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/analytics/analytics_service.dart';
import 'core/api/api_client_provider.dart'; import 'core/api/api_client_provider.dart';
import 'core/auth/auth_notifier.dart'; import 'core/auth/auth_notifier.dart';
import 'core/auth/auth_providers_provider.dart'; import 'core/auth/auth_providers_provider.dart';
@@ -28,6 +30,12 @@ void main() async {
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); 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; final messaging = FirebaseMessaging.instance;
await messaging.requestPermission(); await messaging.requestPermission();
@@ -131,9 +139,41 @@ class _AppState extends ConsumerState<App> 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// FCM registration on auth. // FCM registration + analytics identity on auth.
ref.listen(authProvider, (prev, next) { ref.listen(authProvider, (prev, next) {
final data = next.valueOrNull; final data = next.valueOrNull;
if (data is AuthAuthenticatedData || data is AuthAnonymousData) { if (data is AuthAuthenticatedData || data is AuthAnonymousData) {
@@ -142,6 +182,7 @@ class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
// Logged out (or initial) — ensure the chat WS is closed. // Logged out (or initial) — ensure the chat WS is closed.
ref.read(chatProvider.notifier).disconnect(); ref.read(chatProvider.notifier).disconnect();
} }
_syncAnalyticsIdentity(data);
}); });
// Global chat WebSocket lifecycle: connect whenever the user has an // Global chat WebSocket lifecycle: connect whenever the user has an

View File

@@ -1,6 +1,8 @@
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'core/analytics/analytics_service.dart';
import 'core/auth/auth_notifier.dart'; import 'core/auth/auth_notifier.dart';
import 'core/auth/onboarding_intent_provider.dart'; import 'core/auth/onboarding_intent_provider.dart';
import 'features/auth/screens/display_name_screen.dart'; import 'features/auth/screens/display_name_screen.dart';
@@ -54,12 +56,62 @@ class RouterNotifier extends ChangeNotifier {
final routerProvider = Provider<GoRouter>((ref) => buildRouter(ref)); final routerProvider = Provider<GoRouter>((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 = <String, AnalyticsScreen>{
'/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) { GoRouter buildRouter(Ref ref) {
final notifier = RouterNotifier(ref); final notifier = RouterNotifier(ref);
final analyticsObserver = FirebaseAnalyticsObserver(
analytics: FirebaseAnalytics.instance,
nameExtractor: _screenNameFor,
);
return GoRouter( return GoRouter(
initialLocation: kThemePreviewEnabled ? '/_theme_preview' : '/splash', initialLocation: kThemePreviewEnabled ? '/_theme_preview' : '/splash',
refreshListenable: notifier, refreshListenable: notifier,
observers: [analyticsObserver],
redirect: (context, state) { redirect: (context, state) {
// Theme preview is dev-only and intentionally bypasses auth + onboarding // Theme preview is dev-only and intentionally bypasses auth + onboarding
// gates so it can be opened on any device build. // gates so it can be opened on any device build.

View File

@@ -5,6 +5,7 @@
import FlutterMacOS import FlutterMacOS
import Foundation import Foundation
import firebase_analytics
import firebase_core import firebase_core
import firebase_messaging import firebase_messaging
import flutter_local_notifications import flutter_local_notifications
@@ -17,6 +18,7 @@ import url_launcher_macos
import webview_flutter_wkwebview import webview_flutter_wkwebview
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FirebaseAnalyticsPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))

View File

@@ -297,6 +297,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.1" 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: firebase_core:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@@ -14,6 +14,9 @@ dependencies:
# Firebase (Messaging only — Auth dropped in Phase 3.4, self-managed JWT now) # Firebase (Messaging only — Auth dropped in Phase 3.4, self-managed JWT now)
firebase_core: ^3.12.1 firebase_core: ^3.12.1
firebase_messaging: ^15.2.5 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 # Social login (kept — buttons gated server-side via /api/shared/auth-providers
# until the corresponding OAuth env vars are set on the backend) # until the corresponding OAuth env vars are set on the backend)

View File

@@ -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<br/>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 ⭐<br/>+ app_instance_id, ga_session_id sent on POST]):::evt
PSTART --> PCONF([payment_confirmed ⭐<br/>SERVER — deferred]):::srv
PCONF --> PM{pairing}
PM -->|matched| PMATCH([pairing_matched]):::evt
PM -->|none| PNB([pairing_no_bestie]):::evt
PMATCH --> CSS([chat_session_start ⭐<br/>SERVER — deferred]):::srv
CSS --> CSE([chat_session_end<br/>SERVER — deferred]):::srv
%% ---- Repeat funnel ----
HOME --> RSTART([curhat_repeat_start<br/>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<br/>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<br/>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/<br/>payment_view/]:::screen --> MODE[/payment/method-pick/<br/>curhat_mode_pick/]:::screen
MODE -->|chat / call| DUR[/payment/duration-pick/<br/>payment_duration_pick/]:::screen
DUR --> PMETH[/payment/method/<br/>payment_method + payment_method_select/]:::screen
PMETH -->|tap bayar · payment_started ⭐<br/>+ 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<br/>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.

View File

@@ -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: <payment_request_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?