Files
Ramadhan Sjamsani fbc94daac7 Mitra Bestie §1–§3: shell + Undangan + popup + chat polish
Brings the mitra app to figma-bestie parity for Home (§1), Undangan
inbox with Curhat Baru + Perpanjang tabs (§2), and the incoming-popup
+ active-chat flow (§3). Home now lives inside a StatefulShellRoute
with BestieTabBar so Profil + Undangan + Home share one shell.

- Shell: features/shell/ (StatefulShellRoute, BestieTabBar, 3 branches)
- Undangan: features/undangan/ — Curhat Baru reads
  chatRequestProvider.pendingInvites; row Terima delegates accept to
  the notifier and ChatRequestOverlay owns nav (no double-push).
  Perpanjang tab stubbed (empty state) until backend exposes
  pendingExtensionsProvider.
- Profil: features/profile/ — Bestie-styled stub
- Home: refactored to body-only (shell owns chrome)
- Popup: chat_request_overlay + chat_request_notifier updated to
  serve the list rows, not just the modal
- Chat: mitra_chat_screen polish
- Theme: accentAmber tokens for the Perpanjang tab + halo_orb widget
  (loading spinner used by undangan list states)
- Login: replace broken GoRouterState location guard with
  _expectOtpPush flag — was stacking duplicate /otp pages on OTP
  resend (see project-otp-nav-bug-fixed-2026-05-21)

Maestro:
- 17 new flows under .maestro/flows/ts-mitra-{1,2,3}-* covering home
  online/offline variants, undangan empty/populated/tolak states,
  popup curhat-baru → accept → chat → ended banner, plus popup
  dismiss/expire/cancelled edge cases
- 4 new §A OTP flows (07/08/09/10) for invalid/mismatch/expired/cooldown
- Helper scripts: force_mitra_online/offline, force_pairing_timeout,
  force_session_expires_at, delete_mitra_status_row,
  customer_blast_now (js), customer_cancel_latest_blast
- Backend: POST /internal/_test/delete-mitra-status-row supports the
  "fresh mitra with no status row" test setup

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:14:30 +08:00

351 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;
// 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<AsyncValue<MitraAuthData>>(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<void> _submit() {
final phone = _e164Phone();
setState(() => _phoneErrorText = null);
_expectOtpPush = true;
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),
),
),
),
],
),
);
}
}