feat(client/analytics): GA4 funnel instrumentation + unified home CTA
Add Firebase Analytics (GA4) funnel tracking to client_app: - AnalyticsService typed wrapper (enum-gated, no PII) + analyticsProvider - FirebaseAnalyticsObserver on GoRouter (screen_name via nameExtractor) - user_id = customer UUID, user_type property, set on auth resolve/upgrade - funnel events: curhat_start, curhat_repeat_start, auth_*, onboarding_usp_view, payment_view, payment_method_select, payment_started, pairing_matched/no_bestie - bottom-sheet events: verif_choice_view/select, bestie_choice_view/select, extension_offer_view, chat_extension_requested - payment_started carries app_instance_id + ga_session_id in the /payment-requests body for future server-side stitching (backend ignores) - curhat_mode_pick screen name disambiguates the chat/call mode picker (/payment/method-pick) from the payment-channel picker (/payment/method) - unify both home CTAs to "Aku Mau Curhat" Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
254
client_app/lib/core/analytics/analytics_service.dart
Normal file
254
client_app/lib/core/analytics/analytics_service.dart
Normal 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);
|
||||
29
client_app/lib/core/analytics/analytics_service.g.dart
Normal file
29
client_app/lib/core/analytics/analytics_service.g.dart
Normal 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
|
||||
@@ -6,7 +6,7 @@ part of 'auth_notifier.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$authHash() => r'f0382b1380749cebe8182ad221023926768ebb92';
|
||||
String _$authHash() => r'76cd43babe5503d35a35c8fd23ba32afbc4c8c2d';
|
||||
|
||||
/// See also [Auth].
|
||||
@ProviderFor(Auth)
|
||||
|
||||
@@ -30,7 +30,7 @@ final chatRemainingSecondsProvider = AutoDisposeStreamProvider<int>.internal(
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef ChatRemainingSecondsRef = AutoDisposeStreamProviderRef<int>;
|
||||
String _$chatHash() => r'a42bf404f5b38034c6c9511259b64b1092af3caa';
|
||||
String _$chatHash() => r'56f019ce6e527128ab42a71f56220c5412cfec0f';
|
||||
|
||||
/// See also [Chat].
|
||||
@ProviderFor(Chat)
|
||||
|
||||
@@ -6,7 +6,7 @@ part of 'session_closure_notifier.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$sessionClosureHash() => r'a9aaf26da64d3489497d057e2b05741c08444e07';
|
||||
String _$sessionClosureHash() => r'1ab9df044138115e232b3df494e2895177d9d66d';
|
||||
|
||||
/// See also [SessionClosure].
|
||||
@ProviderFor(SessionClosure)
|
||||
|
||||
@@ -6,7 +6,7 @@ part of 'pairing_notifier.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$pairingHash() => r'd39980fe5b82348d03485006d0534ab597b93ceb';
|
||||
String _$pairingHash() => r'e66bbf67e1013b3d25230ed01fe2595bff943b3a';
|
||||
|
||||
/// See also [Pairing].
|
||||
@ProviderFor(Pairing)
|
||||
|
||||
Reference in New Issue
Block a user