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:
@@ -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');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,12 @@ import 'package:go_router/go_router.dart';
|
||||
import '../../core/auth/auth_notifier.dart';
|
||||
import '../../core/status/status_notifier.dart';
|
||||
import '../../core/chat/chat_request_notifier.dart';
|
||||
import '../../core/chat/unread_notifier.dart';
|
||||
import '../../core/theme/halo_tokens.dart';
|
||||
import '../../core/theme/widgets/widgets.dart';
|
||||
|
||||
/// Bestie Home (mitra). Mirrors `figma-bestie/project/screens/v4.jsx::BestieHome`
|
||||
/// + `v5.jsx::BestieHomeOffline`. Bottom nav (BestieTabBar) is deferred until
|
||||
/// the Profil + Chat tabs have screen implementations.
|
||||
class HomeScreen extends ConsumerWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@@ -14,11 +18,13 @@ class HomeScreen extends ConsumerWidget {
|
||||
final authState = ref.watch(mitraAuthProvider);
|
||||
final authData = authState.valueOrNull;
|
||||
final displayName = authData is MitraAuthAuthenticatedData
|
||||
? authData.profile['display_name'] as String
|
||||
: '';
|
||||
? (authData.profile['display_name'] as String? ?? 'Bestie')
|
||||
: 'Bestie';
|
||||
|
||||
// Load pending requests if mitra is already online
|
||||
final statusState = ref.watch(onlineStatusProvider);
|
||||
final isOnline = statusState is StatusLoadedData && statusState.isOnline;
|
||||
|
||||
// Load pending requests if mitra is already online (existing logic).
|
||||
if (statusState is StatusLoadedData && statusState.isOnline) {
|
||||
final requestState = ref.watch(chatRequestProvider);
|
||||
if (requestState is ChatRequestIdleData) {
|
||||
@@ -29,7 +35,6 @@ class HomeScreen extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for status changes to start/stop chat request listening
|
||||
ref.listen(onlineStatusProvider, (prev, next) {
|
||||
if (next is StatusLoadedData && next.isOnline) {
|
||||
ref.read(chatRequestProvider.notifier).startListening();
|
||||
@@ -40,119 +45,153 @@ class HomeScreen extends ConsumerWidget {
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Halo Bestie Mitra'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.logout),
|
||||
onPressed: () => ref.read(mitraAuthProvider.notifier).logout(),
|
||||
backgroundColor: HaloTokens.bg,
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 28),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_Header(displayName: displayName, isOnline: isOnline),
|
||||
const SizedBox(height: 18),
|
||||
const _TilesGrid(),
|
||||
const SizedBox(height: 14),
|
||||
_StatusCard(isOnline: isOnline),
|
||||
const SizedBox(height: 10),
|
||||
const _GantiStatusButton(),
|
||||
const SizedBox(height: 22),
|
||||
const _Pengingat(),
|
||||
const SizedBox(height: 16),
|
||||
// Functional shortcuts (no figma equivalent — kept until the
|
||||
// Chat tab is built so the user can still reach sessions /
|
||||
// history pages from home).
|
||||
const _ShortcutTile(
|
||||
icon: Icons.chat_bubble_outline,
|
||||
title: 'Sesi Aktif',
|
||||
route: '/sessions',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const _ShortcutTile(
|
||||
icon: Icons.history,
|
||||
title: 'Riwayat Chat',
|
||||
route: '/chat/history',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
Text('Halo, $displayName!', style: const TextStyle(fontSize: 24)),
|
||||
const SizedBox(height: 32),
|
||||
const _StatusToggle(),
|
||||
const SizedBox(height: 16),
|
||||
const _ActiveSessionsButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusToggle extends ConsumerWidget {
|
||||
const _StatusToggle();
|
||||
class _Header extends ConsumerWidget {
|
||||
final String displayName;
|
||||
final bool isOnline;
|
||||
const _Header({required this.displayName, required this.isOnline});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final statusState = ref.watch(onlineStatusProvider);
|
||||
final isOnline = statusState is StatusLoadedData && statusState.isOnline;
|
||||
final isLoading = statusState is StatusLoadingData;
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isOnline ? 'Online' : 'Offline',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isOnline ? Colors.green : Colors.grey,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
isOnline
|
||||
? 'Kamu siap menerima chat'
|
||||
: 'Aktifkan untuk menerima chat',
|
||||
style: const TextStyle(fontSize: 14, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
isLoading
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Switch(
|
||||
value: isOnline,
|
||||
activeColor: Colors.green,
|
||||
onChanged: (_) {
|
||||
final notifier = ref.read(onlineStatusProvider.notifier);
|
||||
if (isOnline) {
|
||||
notifier.toggleOffline();
|
||||
} else {
|
||||
notifier.toggleOnline();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActiveSessionsButton extends ConsumerWidget {
|
||||
const _ActiveSessionsButton();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final unreadCounts = ref.watch(unreadSessionsProvider);
|
||||
final totalUnread = unreadCounts.values.fold(0, (a, b) => a + b);
|
||||
|
||||
return Column(
|
||||
final greetingSuffix = isOnline ? '🌸' : '🌙';
|
||||
return Row(
|
||||
children: [
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: Badge(
|
||||
isLabelVisible: totalUnread > 0,
|
||||
label: Text('$totalUnread'),
|
||||
child: const Icon(Icons.chat_bubble_outline),
|
||||
),
|
||||
title: const Text('Sesi Aktif'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => context.push('/sessions'),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Hei,',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 13,
|
||||
color: HaloTokens.inkSoft,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Bestie $displayName $greetingSuffix',
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.brandDark,
|
||||
letterSpacing: -0.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const _RequestHistoryButton(),
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.history),
|
||||
title: const Text('Riwayat Chat'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => context.push('/chat/history'),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.more_horiz, color: HaloTokens.ink),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: HaloTokens.surface,
|
||||
shape: const CircleBorder(),
|
||||
),
|
||||
onPressed: () => _showMenu(context, ref),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showMenu(BuildContext context, WidgetRef ref) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.logout, color: HaloTokens.danger),
|
||||
title: const Text(
|
||||
'Keluar',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
onTap: () async {
|
||||
Navigator.of(ctx).pop();
|
||||
await ref.read(mitraAuthProvider.notifier).logout();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TilesGrid extends ConsumerWidget {
|
||||
const _TilesGrid();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ref.watch(chatRequestProvider);
|
||||
final undanganCount =
|
||||
ref.read(chatRequestProvider.notifier).activeRequestCount;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _DarkTile(
|
||||
icon: '📨',
|
||||
label: 'Undangan',
|
||||
subtitle:
|
||||
undanganCount > 0 ? 'Menunggu: $undanganCount' : 'Belum ada',
|
||||
badgeCount: undanganCount,
|
||||
onTap: () => context.push('/chat/requests/history'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
// Perpanjang tile — backend wiring (extension request count) isn't
|
||||
// exposed to the home yet, so render the static "Belum ada" state to
|
||||
// match the figma. Wire to the same notifier once an extension-count
|
||||
// provider exists.
|
||||
const Expanded(
|
||||
child: _DarkTile(
|
||||
icon: '⚡',
|
||||
label: 'Perpanjang',
|
||||
subtitle: 'Belum ada',
|
||||
badgeCount: 0,
|
||||
onTap: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -160,54 +199,284 @@ class _ActiveSessionsButton extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _RequestHistoryButton extends ConsumerWidget {
|
||||
const _RequestHistoryButton();
|
||||
class _DarkTile extends StatelessWidget {
|
||||
final String icon;
|
||||
final String label;
|
||||
final String subtitle;
|
||||
final int badgeCount;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _DarkTile({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.subtitle,
|
||||
required this.badgeCount,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Watch state to rebuild when requests arrive/clear; count comes from
|
||||
// the notifier which tracks both displayed + queued requests.
|
||||
ref.watch(chatRequestProvider);
|
||||
final count = ref.read(chatRequestProvider.notifier).activeRequestCount;
|
||||
|
||||
final hasPending = count > 0;
|
||||
final trailing = hasPending
|
||||
? Row(
|
||||
Widget build(BuildContext context) {
|
||||
final card = Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2A1820),
|
||||
borderRadius: HaloRadius.lg,
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
Text(icon, style: const TextStyle(fontSize: 18)),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 11,
|
||||
color: Color(0xB3FFFFFF),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (badgeCount > 0)
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
width: 18,
|
||||
height: 18,
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFFF4D6A),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
'$count',
|
||||
'$badgeCount',
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.chevron_right),
|
||||
],
|
||||
)
|
||||
: const Icon(Icons.chevron_right);
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.notifications_outlined),
|
||||
title: const Text('Riwayat Permintaan'),
|
||||
subtitle: Text(
|
||||
hasPending
|
||||
? '$count permintaan baru'
|
||||
: 'Lihat permintaan chat sebelumnya',
|
||||
),
|
||||
trailing: trailing,
|
||||
onTap: () => context.push('/chat/requests/history'),
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: HaloRadius.lg,
|
||||
onTap: onTap,
|
||||
child: card,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusCard extends StatelessWidget {
|
||||
final bool isOnline;
|
||||
const _StatusCard({required this.isOnline});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bgColor = isOnline ? const Color(0xFFE8F7EE) : const Color(0xFFFCE8E8);
|
||||
final borderColor =
|
||||
isOnline ? const Color(0xFF9DD9B1) : const Color(0xFFF5B5B5);
|
||||
final titleColor =
|
||||
isOnline ? const Color(0xFF1F6B3B) : const Color(0xFF7A2828);
|
||||
final subColor =
|
||||
isOnline ? const Color(0xFF3F8956) : const Color(0xFF9C4040);
|
||||
final dot = isOnline ? '🟢' : '🔴';
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: HaloRadius.md,
|
||||
border: Border.all(color: borderColor),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(dot, style: const TextStyle(fontSize: 14)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Kamu lagi ${isOnline ? 'ONLINE' : 'OFFLINE'}',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: titleColor,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
isOnline
|
||||
? 'siap menerima curhat baru'
|
||||
: 'gak terima curhat dulu',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 11,
|
||||
color: subColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GantiStatusButton extends ConsumerWidget {
|
||||
const _GantiStatusButton();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final statusState = ref.watch(onlineStatusProvider);
|
||||
final isOnline = statusState is StatusLoadedData && statusState.isOnline;
|
||||
final isLoading = statusState is StatusLoadingData;
|
||||
|
||||
return HaloButton(
|
||||
label: isLoading ? 'memproses...' : 'Ganti Status',
|
||||
fullWidth: true,
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () {
|
||||
final notifier = ref.read(onlineStatusProvider.notifier);
|
||||
if (isOnline) {
|
||||
notifier.toggleOffline();
|
||||
} else {
|
||||
notifier.toggleOnline();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Pengingat extends StatelessWidget {
|
||||
const _Pengingat();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
'Pengingat',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.brandDark,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEEE7F5),
|
||||
borderRadius: HaloRadius.md,
|
||||
),
|
||||
child: const Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('💜', style: TextStyle(fontSize: 16)),
|
||||
SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 12.5,
|
||||
color: HaloTokens.ink,
|
||||
height: 1.45,
|
||||
),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: 'Opening protocol: ',
|
||||
style: TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
TextSpan(
|
||||
text:
|
||||
'selalu mulai dengan pertanyaan terbuka yang hangat ya, Bestie.',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ShortcutTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String route;
|
||||
const _ShortcutTile({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.route,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: HaloRadius.lg,
|
||||
onTap: () => context.push(route),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: HaloTokens.surface,
|
||||
borderRadius: HaloRadius.lg,
|
||||
border: Border.all(color: HaloTokens.border),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: HaloTokens.brandDark, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: HaloTokens.ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right,
|
||||
color: HaloTokens.inkMuted, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user