Mitra §A: pre-home (S3a/S3b/AccountInactive) + design system + Bestie Home

- Port halo_tokens + halo_theme + HaloButton to mitra_app (rose palette,
  Bricolage display, Poppins body, JetBrainsMono).
- Build S3a Input WhatsApp (figma-bestie BestieS3 first half) with
  +62 chip, leading-zero/62 normalization, allow '+' in input.
- Build S3b OTP verification (6-digit, 60s resend timer, attempts hint,
  Focus(canRequestFocus:false) for maestro inputText compat) with full
  error branching (CODE_MISMATCH, OTP_EXPIRED, OTP_USED, ATTEMPTS_EXCEEDED,
  WRONG_FLOW, ACCOUNT_INACTIVE).
- Add AccountInactive terminal screen for is_active=false mitras.
- Typed MitraAuthError with Indonesian-first localized messages +
  retryAfterSeconds passthrough.
- Rebuild home_screen.dart to match figma BestieHome (greeting + status
  card + Ganti Status CTA + Pengingat + 2-tile dark grid).
- Backend: POST /internal/_test/seed-mitra (idempotent) and
  PATCH /internal/mitras/:id (display_name update).
- Control center: inline Edit Nama on mitras row + expandable inline log
  table under clicked mitra (vs old below-table panel).
- 5 maestro flows ts-mitra-A-01/03/04/05/06 covering invalid input, happy
  path, account inactive, phone-format normalization, and the back-to-S3a
  regression. All green.

Plan + memory documented in:
- requirement/phase4-mitra-prehome-plan.md
- requirement/flow_mitra.md / flow_mitra.mermaid.md §A

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 22:01:28 +08:00
parent ad02ee252d
commit 9696eadeaf
37 changed files with 3406 additions and 326 deletions

View File

@@ -0,0 +1,83 @@
import 'package:flutter/material.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';
/// Terminal state shown when OTP verification succeeds but the mitra's
/// account is not yet approved (`ACCOUNT_INACTIVE` 403 from the backend).
///
/// Mitras are onboarded internally and reach out via their existing
/// internal channel — no public WhatsApp/Telegram CTAs here.
class AccountInactiveScreen extends ConsumerWidget {
const AccountInactiveScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return PopScope(
canPop: false,
child: Scaffold(
backgroundColor: HaloTokens.bg,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(28, 8, 28, 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Text(
'',
style: TextStyle(fontSize: 56),
),
),
SizedBox(height: 24),
Text(
'akun belum aktif',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 28,
fontWeight: FontWeight.w700,
color: HaloTokens.brandDark,
height: 1.15,
letterSpacing: -0.56,
),
),
SizedBox(height: 12),
Text(
'tim halobestie sedang memverifikasi akun kamu. '
'hubungi koordinator kalau butuh update.',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14.5,
color: HaloTokens.inkSoft,
height: 1.5,
),
),
],
),
),
HaloButton(
label: 'pakai nomor lain',
variant: HaloButtonVariant.secondary,
fullWidth: true,
onPressed: () async {
await ref.read(mitraAuthProvider.notifier).logout();
if (context.mounted) context.go('/login');
},
),
],
),
),
),
),
);
}
}

View File

@@ -1,8 +1,20 @@
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});
@@ -12,61 +24,242 @@ class LoginScreen extends ConsumerStatefulWidget {
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _phoneController = TextEditingController();
ProviderSubscription<AsyncValue<MitraAuthData>>? _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;
@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<AsyncValue<MitraAuthData>>(mitraAuthProvider,
(prev, next) async {
if (!mounted) return;
final data = next.valueOrNull;
// Push to /otp only when the *current top route* is /login. This
// protects against the OtpScreen's resend stacking a second /otp on
// top of itself (login_screen's listener stays alive on the nav stack
// and would otherwise fire on every fresh MitraAuthOtpSentData).
if (data is MitraAuthOtpSentData) {
final location = GoRouterState.of(context).matchedLocation;
if (location == '/login') {
context.push('/otp', extra: _e164Phone());
}
return;
}
if (next is! AsyncError) return;
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<void> _submit() {
final phone = _e164Phone();
setState(() => _phoneErrorText = null);
return ref.read(mitraAuthProvider.notifier).requestOtp(phone);
}
Future<void> _showRateLimitDialog(MitraAuthError err, {required bool isIp}) {
final retryText = err.retryAfterSeconds != null
? '\n\nCoba lagi dalam ${_formatCountdown(err.retryAfterSeconds!)}.'
: '';
return showDialog<void>(
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;
ref.listen(mitraAuthProvider, (prev, next) {
final data = next.valueOrNull;
if (data is MitraAuthOtpSentData) {
context.push('/otp', extra: _phoneController.text.trim());
}
if (next is AsyncError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
}
});
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.all(24),
padding: const EdgeInsets.fromLTRB(28, 8, 28, 28),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Halo Bestie Mitra',
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
TextField(
controller: _phoneController,
decoration: const InputDecoration(
labelText: 'Nomor HP',
hintText: '+628xxxxxxxxxx',
border: OutlineInputBorder(),
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,
),
),
],
],
),
),
),
),
),
keyboardType: TextInputType.phone,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: isLoading ? null : () {
final phone = _phoneController.text.trim();
if (phone.isEmpty) return;
ref.read(mitraAuthProvider.notifier).requestOtp(phone);
},
child: isLoading
? const CircularProgressIndicator()
: const Text('Kirim OTP'),
HaloButton(
label: isLoading
? 'memproses...'
: isLockedOut
? 'coba lagi dalam ${_formatCountdown(_lockoutSeconds)}'
: 'kirim kode',
fullWidth: true,
onPressed: canSubmit ? _submit : null,
),
],
),
@@ -75,3 +268,75 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
);
}
}
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),
),
),
),
],
),
);
}
}

View File

@@ -1,8 +1,22 @@
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';
const int _kOtpLength = 6;
const int _kMaxAttempts = 5;
const int _kResendCooldownSeconds = 60;
/// S3b · OTP verification (6-digit) for mitra.
///
/// Visual contract mirrors `figma-bestie/project/screens/onboarding.jsx::S3Phone`
/// (OTP-step view) with the customer's 4 boxes scaled up to 6 (mitra uses
/// 6-digit OTPs from Fazpass).
class OtpScreen extends ConsumerStatefulWidget {
final String phone;
const OtpScreen({super.key, required this.phone});
@@ -13,9 +27,20 @@ class OtpScreen extends ConsumerStatefulWidget {
class _OtpScreenState extends ConsumerState<OtpScreen> {
final List<TextEditingController> _controllers =
List.generate(6, (_) => TextEditingController());
final List<FocusNode> _focusNodes = List.generate(6, (_) => FocusNode());
List.generate(_kOtpLength, (_) => TextEditingController());
final List<FocusNode> _focusNodes =
List.generate(_kOtpLength, (_) => FocusNode());
String? _otpRequestId;
int _attemptsUsed = 0;
String? _inlineError;
Timer? _cooldownTicker;
int _cooldown = _kResendCooldownSeconds;
bool _isResending = false;
bool _dialogShown = false;
ProviderSubscription<AsyncValue<MitraAuthData>>? _authSub;
@override
void initState() {
@@ -24,10 +49,44 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
if (data is MitraAuthOtpSentData) {
_otpRequestId = data.otpRequestId;
}
_startCooldown();
_authSub = ref.listenManual<AsyncValue<MitraAuthData>>(mitraAuthProvider,
(prev, next) {
if (!mounted) return;
// Resend completed — the notifier emits a fresh MitraAuthOtpSentData
// with a new otp_request_id. Reset local state without bouncing back
// to S3a (which the router would otherwise do if we let the listener
// fire on the same OtpSentData state).
final data = next.valueOrNull;
if (data is MitraAuthOtpSentData && data.otpRequestId != _otpRequestId) {
_otpRequestId = data.otpRequestId;
setState(() {
_attemptsUsed = 0;
_inlineError = null;
});
_clearFieldsAndFocus();
_startCooldown();
return;
}
if (next is! AsyncError) return;
final err = next.error;
if (err is MitraAuthError) {
_handleAuthError(err);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(err.toString())),
);
}
});
}
@override
void dispose() {
_authSub?.close();
_cooldownTicker?.cancel();
for (final c in _controllers) {
c.dispose();
}
@@ -37,108 +96,404 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
super.dispose();
}
void _startCooldown() {
_cooldownTicker?.cancel();
setState(() => _cooldown = _kResendCooldownSeconds);
_cooldownTicker = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!mounted) {
timer.cancel();
return;
}
if (_cooldown <= 1) {
timer.cancel();
setState(() => _cooldown = 0);
} else {
setState(() => _cooldown -= 1);
}
});
}
String get _otp => _controllers.map((c) => c.text).join();
void _clearFieldsAndFocus() {
for (final c in _controllers) {
c.clear();
}
_focusNodes[0].requestFocus();
}
void _onChanged(int index, String value) {
if (value.length == 1 && index < 5) {
if (value.isNotEmpty && index < _kOtpLength - 1) {
_focusNodes[index + 1].requestFocus();
}
if (_otp.length == 6) {
if (value.isEmpty && index > 0) {
_focusNodes[index - 1].requestFocus();
}
if (_otp.length == _kOtpLength) {
_submit();
}
}
void _onKeyDown(int index, KeyEvent event) {
KeyEventResult _onKeyEvent(int index, KeyEvent event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.backspace &&
_controllers[index].text.isEmpty &&
index > 0) {
_controllers[index - 1].clear();
_focusNodes[index - 1].requestFocus();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
void _submit() {
final otp = _otp;
if (otp.length != 6 || _otpRequestId == null) return;
if (otp.length != _kOtpLength || _otpRequestId == null) return;
setState(() => _inlineError = null);
ref.read(mitraAuthProvider.notifier).verifyOtp(_otpRequestId!, otp);
}
Future<void> _resend() async {
if (_cooldown > 0 || _isResending) return;
setState(() {
_isResending = true;
_inlineError = null;
});
await ref.read(mitraAuthProvider.notifier).requestOtp(widget.phone);
if (!mounted) return;
setState(() => _isResending = false);
}
Future<void> _showBlockedDialog() {
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: const Text('Terlalu banyak percobaan'),
content: const Text(
'Kode OTP sudah salah terlalu banyak kali. Minta kode baru untuk lanjut.',
),
actions: [
TextButton(
onPressed: () {
Navigator.of(ctx).pop();
if (mounted) context.pop();
},
child: const Text('Minta kode baru'),
),
],
),
);
}
Future<void> _showResetDialog(MitraAuthError err, {required String title}) {
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: Text(title),
content: Text(err.message),
actions: [
TextButton(
onPressed: () {
Navigator.of(ctx).pop();
if (mounted) context.pop();
},
child: const Text('Minta kode baru'),
),
],
),
);
}
Future<void> _showWrongFlowDialog(MitraAuthError err) {
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: const Text('Bukan akun mitra'),
content: Text(
'${err.message}\n\nPastikan kamu pakai aplikasi yang benar.',
),
actions: [
TextButton(
onPressed: () {
Navigator.of(ctx).pop();
if (mounted) context.pop();
},
child: const Text('Kembali'),
),
],
),
);
}
Future<void> _handleAuthError(MitraAuthError err) async {
if (_dialogShown) return;
switch (err.code) {
case 'CODE_INVALID':
setState(() => _inlineError = err.message);
break;
case 'CODE_MISMATCH':
setState(() {
_attemptsUsed = (_attemptsUsed + 1).clamp(0, _kMaxAttempts);
final remaining = _kMaxAttempts - _attemptsUsed;
_inlineError = remaining > 0
? 'Kode salah. Tersisa $remaining percobaan.'
: 'Kode salah.';
});
_clearFieldsAndFocus();
break;
case 'OTP_ATTEMPTS_EXCEEDED':
_dialogShown = true;
await _showBlockedDialog();
_dialogShown = false;
break;
case 'OTP_EXPIRED':
_dialogShown = true;
await _showResetDialog(err, title: 'Kode kedaluwarsa');
_dialogShown = false;
break;
case 'OTP_USED':
_dialogShown = true;
await _showResetDialog(err, title: 'Kode sudah dipakai');
_dialogShown = false;
break;
case 'WRONG_FLOW':
_dialogShown = true;
await _showWrongFlowDialog(err);
_dialogShown = false;
break;
case 'ACCOUNT_INACTIVE':
if (mounted) context.go('/auth/inactive');
break;
case 'OTP_COOLDOWN':
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(err.message)),
);
break;
default:
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(err.message)),
);
}
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(mitraAuthProvider);
final isLoading = authState is AsyncLoading;
// Update OTP request id if state changes (e.g. resend)
final data = authState.valueOrNull;
if (data is MitraAuthOtpSentData) {
_otpRequestId = data.otpRequestId;
}
ref.listen(mitraAuthProvider, (prev, next) {
if (next is AsyncError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
for (final c in _controllers) {
c.clear();
}
_focusNodes[0].requestFocus();
}
});
final resendLabel = _cooldown > 0
? 'kirim ulang dalam ${_cooldown}s'
: 'kirim ulang kode';
final resendEnabled = _cooldown == 0 && !_isResending && !isLoading;
return Scaffold(
appBar: AppBar(title: const Text('Masukkan OTP')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Kode OTP telah dikirim ke ${widget.phone}',
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(6, (index) {
return SizedBox(
width: 48,
child: KeyboardListener(
focusNode: FocusNode(),
onKeyEvent: (event) => _onKeyDown(index, event),
child: TextField(
controller: _controllers[index],
focusNode: _focusNodes[index],
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
maxLength: 1,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
backgroundColor: HaloTokens.bg,
appBar: AppBar(
backgroundColor: HaloTokens.bg,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: HaloTokens.ink),
onPressed: () => context.pop(),
),
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(28, 0, 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(
'masukin 6 digit kode',
style: TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 28,
fontWeight: FontWeight.w700,
color: HaloTokens.brandDark,
height: 1.15,
letterSpacing: -0.56,
),
),
const SizedBox(height: 10),
RichText(
text: TextSpan(
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14.5,
color: HaloTokens.inkSoft,
height: 1.5,
),
children: [
const TextSpan(text: 'kami baru kirim ke WA '),
TextSpan(
text: widget.phone,
style: const TextStyle(
fontWeight: FontWeight.w700,
color: HaloTokens.ink,
),
),
],
),
),
const SizedBox(height: 28),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.generate(_kOtpLength, (i) {
return _OtpBox(
controller: _controllers[i],
focusNode: _focusNodes[i],
autofocus: i == 0,
onChanged: (v) => _onChanged(i, v),
onKeyEvent: (e) => _onKeyEvent(i, e),
hasError: _inlineError != null,
);
}),
),
if (_inlineError != null) ...[
const SizedBox(height: 12),
Text(
_inlineError!,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
color: HaloTokens.danger,
fontSize: 13,
),
),
],
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'gak nyampe? ',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13,
color: HaloTokens.inkSoft,
),
),
GestureDetector(
onTap: resendEnabled ? _resend : null,
child: Text(
resendLabel,
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13,
fontWeight: FontWeight.w600,
color: resendEnabled
? HaloTokens.brandDark
: HaloTokens.inkMuted,
),
),
),
],
),
],
),
),
decoration: const InputDecoration(
counterText: '',
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(vertical: 14),
),
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
onChanged: (value) => _onChanged(index, value),
),
),
);
}),
),
const SizedBox(height: 32),
ElevatedButton(
onPressed: isLoading ? null : _submit,
child: isLoading
? const CircularProgressIndicator()
: const Text('Verifikasi'),
),
],
),
),
HaloButton(
label: isLoading ? 'memproses...' : 'verifikasi',
fullWidth: true,
onPressed: (isLoading || _otp.length != _kOtpLength) ? null : _submit,
),
],
),
),
),
);
}
}
class _OtpBox extends StatelessWidget {
final TextEditingController controller;
final FocusNode focusNode;
final bool autofocus;
final ValueChanged<String> onChanged;
// Use `Focus(canRequestFocus: false)` not `KeyboardListener` — the latter
// spawns an extra FocusNode that swallows IME input on Android, which
// breaks both maestro `inputText` and SMS auto-paste. Matches the customer
// app pattern in client_app/.../otp_screen.dart::_buildBox.
final KeyEventResult Function(KeyEvent) onKeyEvent;
final bool hasError;
const _OtpBox({
required this.controller,
required this.focusNode,
required this.autofocus,
required this.onChanged,
required this.onKeyEvent,
required this.hasError,
});
@override
Widget build(BuildContext context) {
final filled = controller.text.isNotEmpty;
final borderColor = hasError
? HaloTokens.danger
: filled
? HaloTokens.brand
: HaloTokens.border;
return SizedBox(
width: 48,
height: 60,
child: Focus(
canRequestFocus: false,
onKeyEvent: (_, event) => onKeyEvent(event),
child: TextField(
controller: controller,
focusNode: focusNode,
autofocus: autofocus,
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
maxLength: 1,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 24,
fontWeight: FontWeight.w700,
color: HaloTokens.ink,
),
decoration: InputDecoration(
counterText: '',
filled: true,
fillColor: HaloTokens.surface,
contentPadding: const EdgeInsets.symmetric(vertical: 14),
border: OutlineInputBorder(
borderRadius: HaloRadius.md,
borderSide: BorderSide(color: borderColor, width: 1.5),
),
enabledBorder: OutlineInputBorder(
borderRadius: HaloRadius.md,
borderSide: BorderSide(color: borderColor, width: 1.5),
),
focusedBorder: OutlineInputBorder(
borderRadius: HaloRadius.md,
borderSide: BorderSide(
color: hasError ? HaloTokens.danger : HaloTokens.brand,
width: 2,
),
),
),
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
onChanged: onChanged,
),
),
);
}
}