- 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>
343 lines
12 KiB
Dart
343 lines
12 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';
|
|
|
|
/// 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<LoginScreen> createState() => _LoginScreenState();
|
|
}
|
|
|
|
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;
|
|
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),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|