- 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>
484 lines
14 KiB
Dart
484 lines
14 KiB
Dart
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/status/status_notifier.dart';
|
|
import '../../core/chat/chat_request_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});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final authState = ref.watch(mitraAuthProvider);
|
|
final authData = authState.valueOrNull;
|
|
final displayName = authData is MitraAuthAuthenticatedData
|
|
? (authData.profile['display_name'] as String? ?? 'Bestie')
|
|
: 'Bestie';
|
|
|
|
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) {
|
|
Future.microtask(() {
|
|
ref.read(chatRequestProvider.notifier).startListening();
|
|
ref.read(chatRequestProvider.notifier).loadPendingRequests();
|
|
});
|
|
}
|
|
}
|
|
|
|
ref.listen(onlineStatusProvider, (prev, next) {
|
|
if (next is StatusLoadedData && next.isOnline) {
|
|
ref.read(chatRequestProvider.notifier).startListening();
|
|
ref.read(chatRequestProvider.notifier).loadPendingRequests();
|
|
} else if (next is StatusLoadedData && !next.isOnline) {
|
|
ref.read(chatRequestProvider.notifier).stopListening();
|
|
}
|
|
});
|
|
|
|
return Scaffold(
|
|
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',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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 greetingSuffix = isOnline ? '🌸' : '🌙';
|
|
return Row(
|
|
children: [
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
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) {
|
|
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: [
|
|
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(
|
|
'$badgeCount',
|
|
style: const TextStyle(
|
|
fontFamily: HaloTokens.fontBody,
|
|
color: Colors.white,
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
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),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|