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/theme/halo_tokens.dart'; import '../../../core/theme/widgets/widgets.dart'; /// S3a · Input WhatsApp (mitra). /// /// Visual contract mirrors `figma-bestie/project/screens/onboarding.jsx::S3Phone` /// (first half — phone-input view). Differences from the customer S3a: /// - Greeting heading "Halo Mitra Bestie" (no name-set step pre-OTP). /// - No "lanjut tanpa verifikasi" footer (mitra has no anonymous path). /// - Privacy reassurance card is omitted (audience is internal). class LoginScreen extends ConsumerStatefulWidget { const LoginScreen({super.key}); @override ConsumerState createState() => _LoginScreenState(); } class _LoginScreenState extends ConsumerState { final _phoneController = TextEditingController(); ProviderSubscription>? _authSub; String? _phoneErrorText; // Server-imposed lockout from /otp/request 429s. Drives both the CTA's // disabled state and its label so the mitra sees a live countdown. int _lockoutSeconds = 0; Timer? _lockoutTimer; // Set true in _submit() right before requestOtp; cleared after the listener // pushes /otp. Without this flag the listener fires on every subsequent // auth-state transition (verifyOtp's AsyncLoading / AsyncError preserve the // OtpSentData via Riverpod's copyWithPrevious) and stacks duplicate /otp // pages on top of itself, because GoRouterState.of(context) returns the // LoginScreen's own page state (/login), not the navigator's top route. bool _expectOtpPush = false; @override void initState() { super.initState(); _phoneController.addListener(() { // Re-render so the +62 pill border + CTA enabled-state respond to // the user typing. setState(() {}); }); _authSub = ref.listenManual>(mitraAuthProvider, (prev, next) async { if (!mounted) return; final data = next.valueOrNull; if (data is MitraAuthOtpSentData && _expectOtpPush) { _expectOtpPush = false; context.push('/otp', extra: _e164Phone()); return; } if (next is! AsyncError) return; // Only handle errors for our own requestOtp call. verifyOtp errors // belong to OtpScreen — without this gate LoginScreen's default // snackbar would paint on top of OtpScreen's inline error. if (!_expectOtpPush) return; _expectOtpPush = false; final err = next.error; if (err is! MitraAuthError) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(err.toString())), ); return; } switch (err.code) { case 'PHONE_INVALID': setState(() => _phoneErrorText = err.message); break; case 'OTP_COOLDOWN': if (err.retryAfterSeconds != null) { _startLockout(err.retryAfterSeconds!); } ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(err.message)), ); break; case 'OTP_RATE_LIMIT_PHONE': if (err.retryAfterSeconds != null) { _startLockout(err.retryAfterSeconds!); } await _showRateLimitDialog(err, isIp: false); break; case 'OTP_RATE_LIMIT_IP': if (err.retryAfterSeconds != null) { _startLockout(err.retryAfterSeconds!); } await _showRateLimitDialog(err, isIp: true); break; default: ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(err.message)), ); } }); } @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 any country-code / leading-zero noise stripped. /// Accepts these user-typed formats (all normalize to `8xxxxxxxxx`): /// `8xxxxxxxxx` — subscriber only /// `08xxxxxxxxx` — local format with leading 0 /// `628xxxxxxxxx` — country code without + /// `+628xxxxxxxxx` — full E.164 /// `0628xxxxxxxxx` — typo combo (rare but seen) /// /// Strategy: strip non-digits, strip leading zeros, strip leading `62`. /// Indonesian mobile subscriber numbers always start with `8`, so a /// leading `62` after the zero-strip is always the country code. String _subscriberDigits() { var digits = _phoneController.text.replaceAll(RegExp(r'\D'), ''); digits = digits.replaceFirst(RegExp(r'^0+'), ''); if (digits.startsWith('62')) { digits = digits.substring(2); } return digits; } String _e164Phone() => '+62${_subscriberDigits()}'; String _formatCountdown(int seconds) { if (seconds < 60) return '${seconds}s'; final mins = seconds ~/ 60; final secs = seconds % 60; return '${mins}m ${secs.toString().padLeft(2, '0')}s'; } Future _submit() { final phone = _e164Phone(); setState(() => _phoneErrorText = null); _expectOtpPush = true; return ref.read(mitraAuthProvider.notifier).requestOtp(phone); } Future _showRateLimitDialog(MitraAuthError err, {required bool isIp}) { final retryText = err.retryAfterSeconds != null ? '\n\nCoba lagi dalam ${_formatCountdown(err.retryAfterSeconds!)}.' : ''; return showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(isIp ? 'Terlalu banyak permintaan dari jaringan ini' : 'Terlalu banyak permintaan untuk nomor ini'), content: Text('${err.message}$retryText'), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(), child: const Text('Tutup'), ), ], ), ); } @override Widget build(BuildContext context) { final authState = ref.watch(mitraAuthProvider); final isLoading = authState is AsyncLoading; final hasMinDigits = _subscriberDigits().length >= 9; final isLockedOut = _lockoutSeconds > 0; final canSubmit = hasMinDigits && !isLoading && !isLockedOut; return Scaffold( backgroundColor: HaloTokens.bg, body: SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(28, 8, 28, 28), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ 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 Text( 'Halo Mitra Bestie', style: TextStyle( fontFamily: HaloTokens.fontDisplay, fontSize: 28, fontWeight: FontWeight.w700, color: HaloTokens.brandDark, height: 1.15, letterSpacing: -0.56, ), ), const SizedBox(height: 10), const Text( 'masukin nomor wa kamu untuk lanjut', style: TextStyle( fontFamily: HaloTokens.fontBody, fontSize: 14.5, color: HaloTokens.inkSoft, height: 1.5, ), ), const SizedBox(height: 24), _PhoneRow( controller: _phoneController, borderColor: _phoneErrorText != null ? HaloTokens.danger : hasMinDigits ? HaloTokens.brand : HaloTokens.border, ), if (_phoneErrorText != null) ...[ const SizedBox(height: 8), Text( _phoneErrorText!, 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 ? _submit : null, ), ], ), ), ), ); } } 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, // Allow + alongside digits so users can paste/type +62...; // _subscriberDigits() strips it during normalization. inputFormatters: [ FilteringTextInputFormatter.allow(RegExp(r'[\d+]')), ], maxLength: 16, 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 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), ), ), ), ], ), ); } }