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>
170 lines
6.3 KiB
Dart
170 lines
6.3 KiB
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/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.
|
|
///
|
|
/// Reskinned from the v4 mock (`v4.jsx::S9MatchV4`). Shows the matched
|
|
/// bestie's orb + a small online status dot, the matched-line copy, and a
|
|
/// primary CTA `mulai sesi {N} menit →`. The duration is read from the active
|
|
/// session payload (which the pairing notifier kicks via
|
|
/// `activeSessionProvider.refresh()` on the WS `paired` event).
|
|
///
|
|
/// `PairingActiveData` is the auto-advance signal — fired by the notifier
|
|
/// ~2s after WS `paired` lands. The same advance is also reachable manually
|
|
/// via the CTA in case the user is faster than the auto-advance timer.
|
|
class BestieFoundScreen extends ConsumerStatefulWidget {
|
|
final String sessionId;
|
|
final String mitraName;
|
|
|
|
const BestieFoundScreen({
|
|
super.key,
|
|
required this.sessionId,
|
|
required this.mitraName,
|
|
});
|
|
|
|
@override
|
|
ConsumerState<BestieFoundScreen> createState() => _BestieFoundScreenState();
|
|
}
|
|
|
|
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) {
|
|
context.go('/chat/session/${next.sessionId}', extra: next.mitraName);
|
|
}
|
|
});
|
|
}
|
|
|
|
void _enterChat() {
|
|
context.go('/chat/session/${widget.sessionId}', extra: widget.mitraName);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final activeSession = ref.watch(activeSessionProvider).valueOrNull;
|
|
final durationMinutes =
|
|
activeSession?.session?['duration_minutes'] as int?;
|
|
final ctaLabel = durationMinutes != null
|
|
? 'mulai sesi $durationMinutes menit →'
|
|
: 'mulai sesi →';
|
|
final subtitle = durationMinutes != null
|
|
? 'siap nemenin kamu $durationMinutes menit ke depan. cerita aja pelan-pelan ya 🤍'
|
|
: 'siap nemenin kamu. cerita aja pelan-pelan ya 🤍';
|
|
|
|
return Scaffold(
|
|
backgroundColor: HaloTokens.bg,
|
|
body: SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
HaloSpacing.s24,
|
|
HaloSpacing.s24,
|
|
HaloSpacing.s24,
|
|
HaloSpacing.s24,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Expanded(
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Stack(
|
|
children: [
|
|
HaloOrb(
|
|
size: 140,
|
|
seed: widget.mitraName.hashCode,
|
|
label: widget.mitraName,
|
|
),
|
|
Positioned(
|
|
right: 4,
|
|
bottom: 4,
|
|
child: Container(
|
|
width: 28,
|
|
height: 28,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: HaloTokens.success,
|
|
border: Border.all(
|
|
color: HaloTokens.bg,
|
|
width: 3,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: HaloSpacing.s20),
|
|
const Text(
|
|
'◦ MATCHED ◦',
|
|
style: TextStyle(
|
|
fontFamily: HaloTokens.fontBody,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: 1.6,
|
|
color: HaloTokens.brand,
|
|
),
|
|
),
|
|
const SizedBox(height: HaloSpacing.s8),
|
|
Text(
|
|
'halo, aku bestie ${widget.mitraName}',
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
fontFamily: HaloTokens.fontDisplay,
|
|
fontSize: 26,
|
|
height: 32 / 26,
|
|
fontWeight: FontWeight.w700,
|
|
color: HaloTokens.brandDark,
|
|
),
|
|
),
|
|
const SizedBox(height: HaloSpacing.s8),
|
|
ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 280),
|
|
child: Text(
|
|
subtitle,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
fontFamily: HaloTokens.fontBody,
|
|
fontSize: 14,
|
|
height: 22 / 14,
|
|
color: HaloTokens.inkSoft,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
HaloButton(
|
|
label: ctaLabel,
|
|
fullWidth: true,
|
|
size: HaloButtonSize.lg,
|
|
onPressed: _enterChat,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|