- 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>
500 lines
16 KiB
Dart
500 lines
16 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/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});
|
|
|
|
@override
|
|
ConsumerState<OtpScreen> createState() => _OtpScreenState();
|
|
}
|
|
|
|
class _OtpScreenState extends ConsumerState<OtpScreen> {
|
|
final List<TextEditingController> _controllers =
|
|
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() {
|
|
super.initState();
|
|
final data = ref.read(mitraAuthProvider).valueOrNull;
|
|
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();
|
|
}
|
|
for (final f in _focusNodes) {
|
|
f.dispose();
|
|
}
|
|
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.isNotEmpty && index < _kOtpLength - 1) {
|
|
_focusNodes[index + 1].requestFocus();
|
|
}
|
|
if (value.isEmpty && index > 0) {
|
|
_focusNodes[index - 1].requestFocus();
|
|
}
|
|
if (_otp.length == _kOtpLength) {
|
|
_submit();
|
|
}
|
|
}
|
|
|
|
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 != _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;
|
|
|
|
final resendLabel = _cooldown > 0
|
|
? 'kirim ulang dalam ${_cooldown}s'
|
|
: 'kirim ulang kode';
|
|
final resendEnabled = _cooldown == 0 && !_isResending && !isLoading;
|
|
|
|
return Scaffold(
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|