Files
halobestie-clone/client_app/lib/features/auth/screens/register_screen.dart
Ramadhan Sjamsani 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

421 lines
15 KiB
Dart

import 'dart:async';
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';
import '../../../core/theme/widgets/widgets.dart';
/// S3a — WhatsApp input screen.
///
/// Visual contract is `Figma/screens/onboarding.jsx::S3Phone`:
/// - Personalised display-title `"nomor wa-mu, {name}?"`
/// - +62 prefix as static chip; user types only the trailing digits
/// - Privacy reassurance card
/// - Primary CTA `"kirim kode"` (disabled until ≥9 digits)
/// - Ghost link `"lanjut tanpa verifikasi (harga normal)"` → anonymous path
///
/// Two callers route here:
/// 1. New-user verified onboarding (USP → here) — auth state has the
/// anonymous display_name set by S2.
/// 2. SHome1st "masuk →" banner for returning-user recovery — auth state
/// is initial; the name greeting falls back to "kamu".
class RegisterScreen extends ConsumerStatefulWidget {
const RegisterScreen({super.key});
@override
ConsumerState<RegisterScreen> createState() => _RegisterScreenState();
}
class _RegisterScreenState extends ConsumerState<RegisterScreen> {
final _phoneController = TextEditingController();
ProviderSubscription<AsyncValue<AuthData>>? _authSub;
// Server-imposed lockout from /otp/request 429s. Backend embeds
// retry_after_seconds in the AuthErrorInfo so we can disable the CTA
// until the next slot opens.
int _lockoutSeconds = 0;
Timer? _lockoutTimer;
String? _errorMessage;
@override
void initState() {
super.initState();
_phoneController.addListener(() => setState(() {}));
_authSub =
ref.listenManual<AsyncValue<AuthData>>(authProvider, (prev, next) {
if (!mounted) return;
final data = next.valueOrNull;
if (data is AuthOtpSentData) {
// go (replace) so re-submitting the form doesn't stack OtpScreens
// with leftover listeners.
context.go('/auth/otp', extra: _e164Phone());
return;
}
if (next is AsyncError) {
final err = next.error;
setState(() => _errorMessage = err.toString());
if (err is AuthErrorInfo &&
err.retryAfterSeconds != null &&
(err.code == 'OTP_COOLDOWN' ||
err.code == 'OTP_RATE_LIMIT_PHONE' ||
err.code == 'OTP_RATE_LIMIT_IP')) {
_startLockout(err.retryAfterSeconds!);
}
} else if (next is AsyncData) {
if (_errorMessage != null) setState(() => _errorMessage = null);
}
});
}
@override
void dispose() {
_authSub?.close();
_lockoutTimer?.cancel();
_phoneController.dispose();
super.dispose();
}
void _startLockout(int seconds) {
_lockoutTimer?.cancel();
setState(() => _lockoutSeconds = seconds);
_lockoutTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!mounted) {
timer.cancel();
return;
}
setState(() {
if (_lockoutSeconds > 0) _lockoutSeconds--;
if (_lockoutSeconds <= 0) timer.cancel();
});
});
}
/// Subscriber digits with leading zeros stripped. Users commonly type
/// `0812…` (local format); the backend wants `+62812…`, so the 0 must go.
String _subscriberDigits() {
final digits = _phoneController.text.replaceAll(RegExp(r'\D'), '');
return digits.replaceFirst(RegExp(r'^0+'), '');
}
/// Local digits (no country code) → E.164 string the backend expects.
String _e164Phone() => '+62${_subscriberDigits()}';
String _greetingName(AuthData? data) => switch (data) {
AuthAnonymousData d => d.displayName,
AuthAuthenticatedData d => (d.profile['display_name'] as String?) ?? '',
AuthNeedsDisplayNameData d =>
(d.profile['display_name'] as String?) ?? '',
_ => '',
};
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
final isLoading = authState is AsyncLoading;
final isLockedOut = _lockoutSeconds > 0;
final hasMinDigits = _subscriberDigits().length >= 9;
final canSubmit = hasMinDigits && !isLoading && !isLockedOut;
final name = _greetingName(authState.valueOrNull);
final shownName = name.isEmpty ? 'kamu' : name;
// When the user arrives via the Profile "Simpan Nomor HP" CTA they've
// already committed to identifying — drop the anonymous escape hatch so
// there's only one way forward.
final fromProfile =
GoRouterState.of(context).uri.queryParameters['from'] == 'profile';
return Scaffold(
backgroundColor: HaloTokens.bg,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(28, 8, 28, 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(top: 4, bottom: 4),
child: Row(
children: [
_CircleBackButton(
onTap: () {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
} else {
context.go('/home');
}
},
),
],
),
),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
child: ConstrainedBox(
constraints:
BoxConstraints(minHeight: constraints.maxHeight),
child: IntrinsicHeight(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Center(child: _LogoBadge()),
const SizedBox(height: 18),
Text(
'nomor wa-mu, $shownName?',
style: const TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 28,
fontWeight: FontWeight.w700,
color: HaloTokens.brandDark,
height: 1.15,
letterSpacing: -0.56,
),
),
const SizedBox(height: 10),
const Text(
'supaya bisa lanjut kapan aja, dan dapat harga khusus pengguna baru.',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14.5,
color: HaloTokens.inkSoft,
height: 1.5,
),
),
const SizedBox(height: 24),
_PhoneRow(
controller: _phoneController,
borderColor: hasMinDigits
? HaloTokens.brand
: HaloTokens.border,
),
const SizedBox(height: 16),
const _PrivacyCard(),
if (_errorMessage != null) ...[
const SizedBox(height: 12),
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
color: HaloTokens.danger,
fontSize: 13,
),
),
],
],
),
),
),
),
),
),
HaloButton(
label: isLoading
? 'memproses...'
: isLockedOut
? 'coba lagi dalam ${formatCountdown(_lockoutSeconds)}'
: 'kirim kode',
fullWidth: true,
onPressed: canSubmit
? () {
// ignore: discarded_futures
ref
.read(analyticsProvider)
.logAuthStart(AnalyticsAuthMethod.phone);
ref
.read(authProvider.notifier)
.requestOtp(_e164Phone());
}
: null,
),
if (!fromProfile) ...[
const SizedBox(height: 4),
TextButton(
onPressed: isLoading
? null
// Skip ESPb/USPb — the verified branch already ran ESPa+USPa,
// so the redirect alias drops the user straight at PickMethod.
: () => context.go('/onboarding/anon/method'),
style: TextButton.styleFrom(
foregroundColor: HaloTokens.inkSoft,
minimumSize: const Size(0, 40),
),
child: const Text(
'lanjut tanpa verifikasi (harga normal)',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 12.5,
decoration: TextDecoration.underline,
decorationColor: HaloTokens.inkSoft,
),
),
),
],
],
),
),
),
);
}
}
class _PhoneRow extends StatelessWidget {
final TextEditingController controller;
final Color borderColor;
const _PhoneRow({required this.controller, required this.borderColor});
@override
Widget build(BuildContext context) {
return Container(
height: 60,
padding: const EdgeInsets.symmetric(horizontal: 18),
decoration: BoxDecoration(
color: HaloTokens.surface,
borderRadius: HaloRadius.lg,
border: Border.all(color: borderColor, width: 1.5),
),
child: Row(
children: [
const Text(
'🇮🇩 +62',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 15,
fontWeight: FontWeight.w600,
color: HaloTokens.ink,
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: controller,
keyboardType: TextInputType.phone,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
maxLength: 13, // enough headroom for 12-digit ID mobiles
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 16,
fontWeight: FontWeight.w500,
color: HaloTokens.ink,
),
decoration: const InputDecoration(
hintText: '812 3456 7890',
hintStyle: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 16,
fontWeight: FontWeight.w500,
color: HaloTokens.inkMuted,
),
// Override the app-wide inputDecorationTheme so the input
// sits flush inside the outer pill — no fill, no border.
filled: false,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
errorBorder: InputBorder.none,
focusedErrorBorder: InputBorder.none,
disabledBorder: InputBorder.none,
isCollapsed: true,
counterText: '',
contentPadding: EdgeInsets.symmetric(vertical: 18),
),
),
),
],
),
);
}
}
class _CircleBackButton extends StatelessWidget {
final VoidCallback onTap;
const _CircleBackButton({required this.onTap});
@override
Widget build(BuildContext context) {
return Material(
color: HaloTokens.brand,
shape: const CircleBorder(),
elevation: 2,
shadowColor: const Color(0x1F000000),
child: InkWell(
customBorder: const CircleBorder(),
onTap: onTap,
child: const SizedBox(
width: 40,
height: 40,
child: Icon(Icons.arrow_back, color: Colors.white, size: 20),
),
),
);
}
}
class _LogoBadge extends StatelessWidget {
const _LogoBadge();
@override
Widget build(BuildContext context) {
return Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: HaloTokens.brandLogoBg,
borderRadius: BorderRadius.circular(20),
boxShadow: const [
BoxShadow(
color: Color(0x47FF69A0),
blurRadius: 18,
offset: Offset(0, 6),
),
],
),
clipBehavior: Clip.antiAlias,
child: Transform.scale(
scale: 1.4,
child: Image.asset('assets/icons/logo.png', fit: BoxFit.cover),
),
);
}
}
class _PrivacyCard extends StatelessWidget {
const _PrivacyCard();
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: HaloTokens.brandSofter,
borderRadius: HaloRadius.md,
border: Border.all(color: HaloTokens.brandSoft),
),
child: const Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('🛡️', style: TextStyle(fontSize: 14)),
SizedBox(width: 10),
Expanded(
child: Text(
'anonim — bestie cuma tau nama panggilan kamu. nomor gak akan dishare.',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 12,
color: HaloTokens.brandDark,
height: 1.45,
),
),
),
],
),
);
}
}