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>
This commit is contained in:
@@ -6,10 +6,15 @@ 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';
|
||||
import '../undangan/undangan_screen.dart' show undanganTabProvider;
|
||||
|
||||
/// 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.
|
||||
/// Bestie Home (mitra). Mirrors
|
||||
/// `figma-bestie/project/screens/v4.jsx::BestieHome` (online variant) +
|
||||
/// `figma-bestie/project/screens/v5.jsx::BestieHomeOffline` (offline variant).
|
||||
///
|
||||
/// Lives inside the Home branch of the shell (`router.dart`), so the
|
||||
/// `BestieTabBar` is rendered by `ShellScreen` — this screen owns body
|
||||
/// content only.
|
||||
class HomeScreen extends ConsumerWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@@ -24,7 +29,7 @@ class HomeScreen extends ConsumerWidget {
|
||||
final statusState = ref.watch(onlineStatusProvider);
|
||||
final isOnline = statusState is StatusLoadedData && statusState.isOnline;
|
||||
|
||||
// Load pending requests if mitra is already online (existing logic).
|
||||
// Boot chat-request listener whenever mitra is online (existing logic).
|
||||
if (statusState is StatusLoadedData && statusState.isOnline) {
|
||||
final requestState = ref.watch(chatRequestProvider);
|
||||
if (requestState is ChatRequestIdleData) {
|
||||
@@ -47,116 +52,135 @@ class HomeScreen extends ConsumerWidget {
|
||||
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',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: isOnline
|
||||
? _OnlineHome(displayName: displayName)
|
||||
: _OfflineHome(displayName: displayName),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Header extends ConsumerWidget {
|
||||
// ─── Online variant — matches v4.jsx:417 ──────────────────────────────
|
||||
class _OnlineHome extends StatelessWidget {
|
||||
final String displayName;
|
||||
const _OnlineHome({required this.displayName});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 28),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_Header(displayName: displayName, isOnline: true),
|
||||
const SizedBox(height: 18),
|
||||
const _TilesGrid(),
|
||||
const SizedBox(height: 14),
|
||||
const _StatusCard(isOnline: true),
|
||||
const SizedBox(height: 10),
|
||||
const _GantiStatusButton(),
|
||||
const SizedBox(height: 22),
|
||||
const _Pengingat(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Offline variant — matches v5.jsx:188 ─────────────────────────────
|
||||
class _OfflineHome extends StatelessWidget {
|
||||
final String displayName;
|
||||
const _OfflineHome({required this.displayName});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 28),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_Header(displayName: displayName, isOnline: false),
|
||||
const SizedBox(height: 28),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFCE8E8),
|
||||
borderRadius: HaloRadius.lg,
|
||||
border: Border.all(color: const Color(0xFFF5B5B5), width: 1.5),
|
||||
),
|
||||
child: const Column(
|
||||
children: [
|
||||
Text('😴', style: TextStyle(fontSize: 44)),
|
||||
SizedBox(height: 10),
|
||||
Text(
|
||||
'Kamu lagi OFFLINE',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF7A2828),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
SizedBox(
|
||||
width: 260,
|
||||
child: Text(
|
||||
'Gak terima curhat dulu. Istirahat dulu ya — nyalain ONLINE kalo udah siap lagi 💛',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 13,
|
||||
height: 1.5,
|
||||
color: Color(0xFF9C4040),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const _GantiStatusButton(offlineLabel: 'Nyalain Status (Online)'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Home header — greeting block. Logout moved to Profil tab in Stage 4
|
||||
/// (was a `more_horiz` bottom-sheet menu here pre-Stage-4).
|
||||
class _Header extends StatelessWidget {
|
||||
final String displayName;
|
||||
final bool isOnline;
|
||||
const _Header({required this.displayName, required this.isOnline});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
Widget build(BuildContext context) {
|
||||
final greetingSuffix = isOnline ? '🌸' : '🌙';
|
||||
return Row(
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
const Text(
|
||||
'Hei,',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 13,
|
||||
color: HaloTokens.inkSoft,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.more_horiz, color: HaloTokens.ink),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: HaloTokens.surface,
|
||||
shape: const CircleBorder(),
|
||||
Text(
|
||||
'Bestie $displayName $greetingSuffix',
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.brandDark,
|
||||
letterSpacing: -0.4,
|
||||
),
|
||||
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 {
|
||||
@@ -167,7 +191,11 @@ class _TilesGrid extends ConsumerWidget {
|
||||
ref.watch(chatRequestProvider);
|
||||
final undanganCount =
|
||||
ref.read(chatRequestProvider.notifier).activeRequestCount;
|
||||
final shell = StatefulNavigationShell.of(context);
|
||||
|
||||
// Both tiles route to the Chat tab's Undangan screen, differing only in
|
||||
// which sub-tab they pre-select via `undanganTabProvider`. Tiles must
|
||||
// stay tappable even at count=0 (destination shows its empty state).
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -177,21 +205,28 @@ class _TilesGrid extends ConsumerWidget {
|
||||
subtitle:
|
||||
undanganCount > 0 ? 'Menunggu: $undanganCount' : 'Belum ada',
|
||||
badgeCount: undanganCount,
|
||||
onTap: () => context.push('/chat/requests/history'),
|
||||
onTap: () {
|
||||
ref.read(undanganTabProvider.notifier).state = 0;
|
||||
shell.goBranch(1);
|
||||
},
|
||||
),
|
||||
),
|
||||
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(
|
||||
// Perpanjang tile — `MitraExtension` notifier holds per-flow state
|
||||
// only (idle/responding/...), no pending-list. Keep static "Belum ada"
|
||||
// until backend exposes a pending-extension count.
|
||||
// TODO(stage-2): wire extension count when extension_notifier
|
||||
// exposes it (or once a dedicated pending-extensions provider exists).
|
||||
Expanded(
|
||||
child: _DarkTile(
|
||||
icon: '⚡',
|
||||
label: 'Perpanjang',
|
||||
subtitle: 'Belum ada',
|
||||
badgeCount: 0,
|
||||
onTap: null,
|
||||
onTap: () {
|
||||
ref.read(undanganTabProvider.notifier).state = 1;
|
||||
shell.goBranch(1);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -218,8 +253,8 @@ class _DarkTile extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final card = Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2A1820),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF2A1820),
|
||||
borderRadius: HaloRadius.lg,
|
||||
),
|
||||
child: Stack(
|
||||
@@ -347,7 +382,11 @@ class _StatusCard extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _GantiStatusButton extends ConsumerWidget {
|
||||
const _GantiStatusButton();
|
||||
/// Optional label override for the offline variant (v5.jsx uses
|
||||
/// "Nyalain Status (Online)"). Defaults to "Ganti Status" for the online
|
||||
/// variant (v4.jsx).
|
||||
final String? offlineLabel;
|
||||
const _GantiStatusButton({this.offlineLabel});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -355,8 +394,12 @@ class _GantiStatusButton extends ConsumerWidget {
|
||||
final isOnline = statusState is StatusLoadedData && statusState.isOnline;
|
||||
final isLoading = statusState is StatusLoadingData;
|
||||
|
||||
final label = isLoading
|
||||
? 'memproses...'
|
||||
: (isOnline ? 'Ganti Status' : (offlineLabel ?? 'Ganti Status'));
|
||||
|
||||
return HaloButton(
|
||||
label: isLoading ? 'memproses...' : 'Ganti Status',
|
||||
label: label,
|
||||
fullWidth: true,
|
||||
onPressed: isLoading
|
||||
? null
|
||||
@@ -394,8 +437,8 @@ class _Pengingat extends StatelessWidget {
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEEE7F5),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFEEE7F5),
|
||||
borderRadius: HaloRadius.md,
|
||||
),
|
||||
child: const Row(
|
||||
@@ -432,52 +475,3 @@ class _Pengingat extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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