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/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`: /// - HaloStepDots at the top (step 3 of 4: S2 Nama → S5 ESP → S5b USP → S3a) /// - 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 createState() => _RegisterScreenState(); } class _RegisterScreenState extends ConsumerState { final _phoneController = TextEditingController(); ProviderSubscription>? _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>(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; return Scaffold( backgroundColor: HaloTokens.bg, body: SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(28, 8, 28, 28), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const Padding( padding: EdgeInsets.only(top: 8, bottom: 8), child: HaloStepDots(total: 4, current: 3), ), Expanded( child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ 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 ? () => ref .read(authProvider.notifier) .requestOtp(_e164Phone()) : null, ), 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 _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, ), ), ), ], ), ); } }