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)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/analytics/analytics_service.dart';
|
||||
import '../../../core/api/api_client_provider.dart';
|
||||
import '../../../core/auth/auth_notifier.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
@@ -35,6 +36,9 @@ class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> {
|
||||
final data = next.valueOrNull;
|
||||
if (data is AuthAnonymousData && !_routedAfterLogin) {
|
||||
_routedAfterLogin = true;
|
||||
// Anonymous identity established — activation step 6.
|
||||
// ignore: discarded_futures
|
||||
ref.read(analyticsProvider).logAuthComplete(AnalyticsUserType.anonymous);
|
||||
_proceedAfterLogin();
|
||||
}
|
||||
});
|
||||
@@ -82,12 +86,17 @@ class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
// ignore: discarded_futures
|
||||
ref.read(analyticsProvider).logVerifChoiceView();
|
||||
final choice = await VerifChoiceSheet.show(context);
|
||||
if (!mounted || choice == null) {
|
||||
// User dismissed the sheet — let them tap Lanjut again to retry.
|
||||
// User dismissed the sheet — let them tap Lanjut again to retry. No
|
||||
// select event: the view→select gap is the abandonment signal.
|
||||
_routedAfterLogin = false;
|
||||
return;
|
||||
}
|
||||
// ignore: discarded_futures
|
||||
ref.read(analyticsProvider).logVerifChoiceSelect(choice);
|
||||
if (!mounted) return;
|
||||
await routeForVerifChoice(context, ref, choice);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/analytics/analytics_service.dart';
|
||||
import '../../../core/api/api_client_provider.dart';
|
||||
import '../../../core/auth/auth_notifier.dart';
|
||||
import '../../../core/constants.dart';
|
||||
@@ -75,6 +76,14 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
|
||||
if (_errorMessage != null && mounted) {
|
||||
setState(() => _errorMessage = null);
|
||||
}
|
||||
// OTP verify resolved to a real identity — activation/repeat step 6.
|
||||
final data = next.valueOrNull;
|
||||
if (data is AuthAuthenticatedData || data is AuthNeedsDisplayNameData) {
|
||||
// ignore: discarded_futures
|
||||
ref
|
||||
.read(analyticsProvider)
|
||||
.logAuthComplete(AnalyticsUserType.verified);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -152,6 +161,8 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
|
||||
final code = _readCode();
|
||||
if (code.length == _kOtpLength && !_autoSubmitted && _otpRequestId != null) {
|
||||
_autoSubmitted = true;
|
||||
// ignore: discarded_futures
|
||||
ref.read(analyticsProvider).logAuthOtpSubmit();
|
||||
ref.read(authProvider.notifier).verifyOtp(_otpRequestId!, code);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/analytics/analytics_service.dart';
|
||||
import '../../../core/auth/auth_notifier.dart';
|
||||
import '../../../core/constants.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
@@ -222,8 +223,15 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
||||
: 'kirim kode',
|
||||
fullWidth: true,
|
||||
onPressed: canSubmit
|
||||
? () =>
|
||||
ref.read(authProvider.notifier).requestOtp(_e164Phone())
|
||||
? () {
|
||||
// ignore: discarded_futures
|
||||
ref
|
||||
.read(analyticsProvider)
|
||||
.logAuthStart(AnalyticsAuthMethod.phone);
|
||||
ref
|
||||
.read(authProvider.notifier)
|
||||
.requestOtp(_e164Phone());
|
||||
}
|
||||
: null,
|
||||
),
|
||||
if (!fromProfile) ...[
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/analytics/analytics_service.dart';
|
||||
import '../../../core/chat/active_session_notifier.dart';
|
||||
import '../../../core/pairing/pairing_notifier.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
import '../../payment/state/payment_draft_provider.dart';
|
||||
import '../../../core/theme/widgets/widgets.dart';
|
||||
|
||||
/// Phase 4 Stage 5 — S9 Match-found screen.
|
||||
@@ -35,6 +37,15 @@ class _BestieFoundScreenState extends ConsumerState<BestieFoundScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Funnel step 12 — paired with a mitra. A targeted-mitra draft means the
|
||||
// repeat funnel; otherwise activation. Fire once on view.
|
||||
final isRepeat =
|
||||
ref.read(paymentDraftNotifierProvider).targetedMitraId != null;
|
||||
// ignore: discarded_futures
|
||||
ref.read(analyticsProvider).logPairingMatched(
|
||||
funnel:
|
||||
isRepeat ? AnalyticsFunnel.repeat : AnalyticsFunnel.activation,
|
||||
);
|
||||
ref.listenManual<PairingData>(pairingProvider, (prev, next) {
|
||||
if (!mounted) return;
|
||||
if (next is PairingActiveData) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/analytics/analytics_service.dart';
|
||||
import '../../../core/chat/active_session_notifier.dart';
|
||||
import '../../../core/chat/chat_notifier.dart';
|
||||
import '../../../core/chat/session_closure_notifier.dart';
|
||||
@@ -512,6 +513,8 @@ class _TimerBanner extends ConsumerWidget {
|
||||
return _BannerKind.none;
|
||||
}));
|
||||
void onExtend() {
|
||||
// ignore: discarded_futures
|
||||
ref.read(analyticsProvider).logExtensionOfferView(sessionId: sessionId);
|
||||
PricingBottomSheet.showForExtension(
|
||||
context,
|
||||
sessionId: sessionId,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/analytics/analytics_service.dart';
|
||||
import '../../../core/pairing/pairing_notifier.dart';
|
||||
import '../../payment/state/payment_draft_provider.dart';
|
||||
|
||||
/// Terminal failed-pairing screen.
|
||||
///
|
||||
@@ -13,11 +15,30 @@ import '../../../core/pairing/pairing_notifier.dart';
|
||||
/// Single CTA "Kembali ke beranda" resets the pairing notifier and routes
|
||||
/// home. PopScope falls back to home for deep-link entry per project memory
|
||||
/// rule "Deep-link pop fallback".
|
||||
class NoBestieScreen extends ConsumerWidget {
|
||||
class NoBestieScreen extends ConsumerStatefulWidget {
|
||||
const NoBestieScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<NoBestieScreen> createState() => _NoBestieScreenState();
|
||||
}
|
||||
|
||||
class _NoBestieScreenState extends ConsumerState<NoBestieScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Funnel drop-off marker — pairing failed. A targeted-mitra draft means
|
||||
// the repeat funnel; otherwise activation. Fire once on view.
|
||||
final isRepeat =
|
||||
ref.read(paymentDraftNotifierProvider).targetedMitraId != null;
|
||||
// ignore: discarded_futures
|
||||
ref.read(analyticsProvider).logPairingNoBestie(
|
||||
funnel:
|
||||
isRepeat ? AnalyticsFunnel.repeat : AnalyticsFunnel.activation,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
canPop: true,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/analytics/analytics_service.dart';
|
||||
import '../../../core/chat/chat_opening_provider.dart';
|
||||
import '../../../core/chat/session_closure_notifier.dart';
|
||||
import '../../../core/constants.dart';
|
||||
@@ -85,6 +86,10 @@ class _PricingBottomSheetState extends ConsumerState<PricingBottomSheet> {
|
||||
}
|
||||
|
||||
void _onConfirm(PriceTier tier) {
|
||||
// ignore: discarded_futures
|
||||
ref.read(analyticsProvider).logChatExtensionRequested(
|
||||
sessionId: widget.extensionSessionId,
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
ref.read(sessionClosureProvider.notifier).requestExtension(
|
||||
widget.extensionSessionId,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../core/analytics/analytics_service.dart';
|
||||
import '../../core/auth/auth_notifier.dart';
|
||||
import '../../core/auth/onboarding_intent_provider.dart';
|
||||
import '../../core/availability/mitra_availability_notifier.dart';
|
||||
@@ -81,6 +82,11 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
|
||||
/// CTA path for SHomeReturning. Returning users get the bestie-choice sheet
|
||||
/// when they have prior history, otherwise jump to the new-payment shell.
|
||||
Future<void> _onCurhatBestieBaruPressed(BuildContext context) async {
|
||||
// Returning user starting a fresh curhat (repeat funnel). The
|
||||
// bestie_reselect sub-event fires later from the history list if they pick
|
||||
// a known bestie; this marks the top of the repeat funnel.
|
||||
// ignore: discarded_futures
|
||||
ref.read(analyticsProvider).logCurhatRepeatStart();
|
||||
bool hasHistory;
|
||||
try {
|
||||
hasHistory = await ref.read(bestieHistoryHasItemsProvider.future);
|
||||
@@ -89,6 +95,8 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
if (hasHistory) {
|
||||
// ignore: discarded_futures
|
||||
ref.read(analyticsProvider).logBestieChoiceView();
|
||||
await BestieChoiceSheet.show(context);
|
||||
return;
|
||||
}
|
||||
@@ -101,6 +109,9 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
|
||||
/// (the call-sign check); display_name_screen kicks off `loginAnonymous`
|
||||
/// and pushes into the verif-choice sheet.
|
||||
void _onAkuMauCurhatPressed(BuildContext context) {
|
||||
// Top of the activation funnel — fresh user tapping the primary CTA.
|
||||
// ignore: discarded_futures
|
||||
ref.read(analyticsProvider).logCurhatStart(entryPoint: 'home_primary');
|
||||
context.push('/auth/display-name');
|
||||
}
|
||||
|
||||
@@ -172,7 +183,7 @@ class _SHome1stView extends ConsumerWidget {
|
||||
const _GreetingSubtitle(),
|
||||
const SizedBox(height: 32),
|
||||
_PrimaryCTA(
|
||||
label: 'aku mau curhat',
|
||||
label: 'Aku Mau Curhat',
|
||||
enabled: mitraAvailable,
|
||||
onPressed: onCTA,
|
||||
),
|
||||
@@ -395,7 +406,7 @@ class _SHomeReturningView extends ConsumerWidget {
|
||||
activeSessionAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, __) => _PrimaryCTA(
|
||||
label: 'curhat sama bestie baru',
|
||||
label: 'Aku Mau Curhat',
|
||||
enabled: mitraAvailable,
|
||||
onPressed: onCTA,
|
||||
),
|
||||
@@ -415,7 +426,7 @@ class _SHomeReturningView extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
return _PrimaryCTA(
|
||||
label: 'curhat sama bestie baru',
|
||||
label: 'Aku Mau Curhat',
|
||||
enabled: mitraAvailable,
|
||||
onPressed: onCTA,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/analytics/analytics_service.dart';
|
||||
import '../../../core/constants.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
import '../../../core/theme/widgets/widgets.dart';
|
||||
@@ -132,6 +133,13 @@ class BestieHistoryListScreen extends ConsumerWidget {
|
||||
return;
|
||||
}
|
||||
if (item.mitraId == null) return;
|
||||
// Repeat funnel: user re-selected a known bestie. mitra_ref
|
||||
// is opaque (hashed) — never the raw mitra id, per the
|
||||
// no-PII / opaque-mitra-identity rule.
|
||||
// ignore: discarded_futures
|
||||
ref.read(analyticsProvider).logBestieReselect(
|
||||
mitraRef: item.mitraId!.hashCode.toString(),
|
||||
);
|
||||
// Stamp the targeted mitra onto the payment draft; the
|
||||
// multi-screen payment flow (entry → method → waiting →
|
||||
// notif-gate → searching) reads it back to fire the
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/analytics/analytics_service.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
import '../../../core/theme/widgets/widgets.dart';
|
||||
import '../../payment/state/payment_draft_provider.dart';
|
||||
@@ -52,6 +53,8 @@ class BestieChoiceSheet extends ConsumerWidget {
|
||||
subtitle: 'lanjut cerita ke bestie yang pernah dengerin kamu.',
|
||||
icon: Icons.favorite_outline,
|
||||
onTap: () {
|
||||
// ignore: discarded_futures
|
||||
ref.read(analyticsProvider).logBestieChoiceSelect(knownBestie: true);
|
||||
Navigator.of(context).pop();
|
||||
context.push('/bestie/history');
|
||||
},
|
||||
@@ -62,6 +65,8 @@ class BestieChoiceSheet extends ConsumerWidget {
|
||||
subtitle: 'cari bestie baru yang siap dengerin sekarang.',
|
||||
icon: Icons.auto_awesome_outlined,
|
||||
onTap: () {
|
||||
// ignore: discarded_futures
|
||||
ref.read(analyticsProvider).logBestieChoiceSelect(knownBestie: false);
|
||||
// explicit reset — this branch is blast-only, clear any stale targeted mitra
|
||||
ref.read(paymentDraftNotifierProvider.notifier).reset();
|
||||
Navigator.of(context).pop();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/analytics/analytics_service.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
import '../../../core/theme/widgets/widgets.dart';
|
||||
import '../usp_seen_provider.dart';
|
||||
@@ -11,11 +12,30 @@ import '../usp_seen_provider.dart';
|
||||
///
|
||||
/// `verified` ➞ USP → OTP (`/auth/register`).
|
||||
/// `anonymous` ➞ USP → `/payment/method-pick`.
|
||||
class UspScreen extends ConsumerWidget {
|
||||
class UspScreen extends ConsumerStatefulWidget {
|
||||
final bool verified;
|
||||
|
||||
const UspScreen({super.key, required this.verified});
|
||||
|
||||
@override
|
||||
ConsumerState<UspScreen> createState() => _UspScreenState();
|
||||
}
|
||||
|
||||
class _UspScreenState extends ConsumerState<UspScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Activation funnel step 7 — fire on view (not teardown). One-shot:
|
||||
// initState runs once per screen instance.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
// ignore: discarded_futures
|
||||
ref
|
||||
.read(analyticsProvider)
|
||||
.logOnboardingUspView(verified: widget.verified);
|
||||
});
|
||||
}
|
||||
|
||||
static const _cards = [
|
||||
_UspCard(
|
||||
icon: Icons.bolt_outlined,
|
||||
@@ -40,7 +60,7 @@ class UspScreen extends ConsumerWidget {
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Padding(
|
||||
@@ -94,7 +114,7 @@ class UspScreen extends ConsumerWidget {
|
||||
HaloButton(
|
||||
label: 'aku ngerti, lanjut',
|
||||
fullWidth: true,
|
||||
onPressed: () => _onContinue(context, ref),
|
||||
onPressed: () => _onContinue(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -103,12 +123,12 @@ class UspScreen extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onContinue(BuildContext context, WidgetRef ref) async {
|
||||
Future<void> _onContinue(BuildContext context) async {
|
||||
// Persist the local + server flag before leaving — next time the user
|
||||
// hits VerifChoice, this screen is skipped.
|
||||
await ref.read(uspSeenProvider.notifier).markSeen();
|
||||
if (!context.mounted) return;
|
||||
if (verified) {
|
||||
if (widget.verified) {
|
||||
context.push('/auth/register');
|
||||
} else {
|
||||
context.push('/payment/method-pick');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/analytics/analytics_service.dart';
|
||||
import '../../../core/auth/onboarding_intent_provider.dart';
|
||||
import '../../../core/chat/chat_opening_provider.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
@@ -38,6 +39,18 @@ class _PaymentEntryScreenState extends ConsumerState<PaymentEntryScreen> {
|
||||
// inherit a stale onboarding intent.
|
||||
ref.read(onboardingIntentProvider.notifier).state =
|
||||
OnboardingIntent.recover;
|
||||
|
||||
// Funnel step 8 — payment entry. A targeted mitra (set just before this
|
||||
// screen by the bestie-history list) marks the repeat funnel; otherwise
|
||||
// it's activation. resetExceptTarget() above preserves that flag.
|
||||
final isRepeat =
|
||||
ref.read(paymentDraftNotifierProvider).targetedMitraId != null;
|
||||
// ignore: discarded_futures
|
||||
ref.read(analyticsProvider).logPaymentView(
|
||||
funnel:
|
||||
isRepeat ? AnalyticsFunnel.repeat : AnalyticsFunnel.activation,
|
||||
isRepeat: isRepeat,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/analytics/analytics_service.dart';
|
||||
import '../../../core/api/api_client_provider.dart';
|
||||
import '../../../core/constants.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
@@ -53,7 +54,21 @@ class _PaymentMethodScreenState extends ConsumerState<PaymentMethodScreen> {
|
||||
_error = null;
|
||||
});
|
||||
final api = ref.read(apiClientProvider);
|
||||
final analytics = ref.read(analyticsProvider);
|
||||
try {
|
||||
// ⭐ Capture GA4 stitching identifiers BEFORE the POST so the backend can
|
||||
// store them in product_metadata and replay them in the server-fired
|
||||
// payment_confirmed (Measurement Protocol). The backend currently
|
||||
// ignores unknown body fields — intentional; we send now, stitch later.
|
||||
final appInstanceId = await analytics.appInstanceId();
|
||||
final gaSessionId = await analytics.sessionId();
|
||||
if (!mounted) return;
|
||||
|
||||
final analyticsIds = <String, dynamic>{
|
||||
if (appInstanceId != null) 'app_instance_id': appInstanceId,
|
||||
if (gaSessionId != null) 'ga_session_id': gaSessionId,
|
||||
};
|
||||
|
||||
final body = <String, dynamic>{
|
||||
'mode': draft.mode.value,
|
||||
'duration_minutes': draft.durationMinutes,
|
||||
@@ -61,11 +76,27 @@ class _PaymentMethodScreenState extends ConsumerState<PaymentMethodScreen> {
|
||||
'is_first_session_discount': draft.isFirstSessionDiscount,
|
||||
'method': _selectedCode,
|
||||
if (draft.targetedMitraId != null) 'targeted_mitra_id': draft.targetedMitraId,
|
||||
if (analyticsIds.isNotEmpty) 'analytics': analyticsIds,
|
||||
};
|
||||
final response = await api.post('/api/client/payment-requests/', data: body);
|
||||
final data = response['data'] as Map<String, dynamic>;
|
||||
final paymentId = data['id'] as String;
|
||||
ref.read(paymentDraftNotifierProvider.notifier).setPaymentId(paymentId);
|
||||
|
||||
// ⭐ payment_started fires AFTER the id is known. A targeted mitra means
|
||||
// the returning/repeat funnel; otherwise activation.
|
||||
final isRepeat = draft.targetedMitraId != null;
|
||||
// ignore: discarded_futures
|
||||
analytics.logPaymentStarted(
|
||||
paymentRequestId: paymentId,
|
||||
amount: draft.priceIDR!,
|
||||
method: _selectedCode!,
|
||||
funnel: isRepeat ? AnalyticsFunnel.repeat : AnalyticsFunnel.activation,
|
||||
isRepeat: isRepeat,
|
||||
productType: draft.mode.value,
|
||||
durationMinutes: draft.durationMinutes!,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
context.push('/payment/waiting/$paymentId');
|
||||
} on DioException catch (e) {
|
||||
@@ -216,10 +247,20 @@ class _PaymentMethodScreenState extends ConsumerState<PaymentMethodScreen> {
|
||||
_expandedGroupIds.remove(g.id);
|
||||
}
|
||||
}),
|
||||
onSelect: (code) => setState(() {
|
||||
_selectedCode = code;
|
||||
_error = null;
|
||||
}),
|
||||
onSelect: (code) {
|
||||
// Funnel step 9 — method chosen. Fire once per pick
|
||||
// (not on every rebuild).
|
||||
if (code != _selectedCode) {
|
||||
// ignore: discarded_futures
|
||||
ref
|
||||
.read(analyticsProvider)
|
||||
.logPaymentMethodSelect(method: code);
|
||||
}
|
||||
setState(() {
|
||||
_selectedCode = code;
|
||||
_error = null;
|
||||
});
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ part of 'payment_draft_provider.dart';
|
||||
// **************************************************************************
|
||||
|
||||
String _$paymentDraftNotifierHash() =>
|
||||
r'1c81b22f25f525cd290f54618bee0b69de792998';
|
||||
r'e489a593f5e1cc2794d13566a9cf960bb89e45c6';
|
||||
|
||||
/// See also [PaymentDraftNotifier].
|
||||
@ProviderFor(PaymentDraftNotifier)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'core/analytics/analytics_service.dart';
|
||||
import 'core/api/api_client_provider.dart';
|
||||
import 'core/auth/auth_notifier.dart';
|
||||
import 'core/auth/auth_providers_provider.dart';
|
||||
@@ -28,6 +30,12 @@ void main() async {
|
||||
|
||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||
|
||||
// Enable GA4 collection. Fire-and-forget so it never adds to cold-start
|
||||
// latency; the SDK queues events until collection is on.
|
||||
unawaited(
|
||||
FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(true),
|
||||
);
|
||||
|
||||
final messaging = FirebaseMessaging.instance;
|
||||
await messaging.requestPermission();
|
||||
|
||||
@@ -131,9 +139,41 @@ class _AppState extends ConsumerState<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
|
||||
Widget build(BuildContext context) {
|
||||
// FCM registration on auth.
|
||||
// FCM registration + analytics identity on auth.
|
||||
ref.listen(authProvider, (prev, next) {
|
||||
final data = next.valueOrNull;
|
||||
if (data is AuthAuthenticatedData || data is AuthAnonymousData) {
|
||||
@@ -142,6 +182,7 @@ class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
|
||||
// Logged out (or initial) — ensure the chat WS is closed.
|
||||
ref.read(chatProvider.notifier).disconnect();
|
||||
}
|
||||
_syncAnalyticsIdentity(data);
|
||||
});
|
||||
|
||||
// Global chat WebSocket lifecycle: connect whenever the user has an
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'core/analytics/analytics_service.dart';
|
||||
import 'core/auth/auth_notifier.dart';
|
||||
import 'core/auth/onboarding_intent_provider.dart';
|
||||
import 'features/auth/screens/display_name_screen.dart';
|
||||
@@ -54,12 +56,62 @@ class RouterNotifier extends ChangeNotifier {
|
||||
|
||||
final routerProvider = Provider<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) {
|
||||
final notifier = RouterNotifier(ref);
|
||||
final analyticsObserver = FirebaseAnalyticsObserver(
|
||||
analytics: FirebaseAnalytics.instance,
|
||||
nameExtractor: _screenNameFor,
|
||||
);
|
||||
|
||||
return GoRouter(
|
||||
initialLocation: kThemePreviewEnabled ? '/_theme_preview' : '/splash',
|
||||
refreshListenable: notifier,
|
||||
observers: [analyticsObserver],
|
||||
redirect: (context, state) {
|
||||
// Theme preview is dev-only and intentionally bypasses auth + onboarding
|
||||
// gates so it can be opened on any device build.
|
||||
|
||||
Reference in New Issue
Block a user