Files
halobestie-clone/mitra_app/lib/features/home/home_screen.dart
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

478 lines
15 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';
import '../undangan/undangan_screen.dart' show undanganTabProvider;
/// 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});
@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;
// 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) {
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: isOnline
? _OnlineHome(displayName: displayName)
: _OfflineHome(displayName: displayName),
),
);
}
}
// ─── 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) {
final greetingSuffix = isOnline ? '🌸' : '🌙';
return 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,
),
),
],
);
}
}
class _TilesGrid extends ConsumerWidget {
const _TilesGrid();
@override
Widget build(BuildContext context, WidgetRef ref) {
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(
child: _DarkTile(
icon: '📨',
label: 'Undangan',
subtitle:
undanganCount > 0 ? 'Menunggu: $undanganCount' : 'Belum ada',
badgeCount: undanganCount,
onTap: () {
ref.read(undanganTabProvider.notifier).state = 0;
shell.goBranch(1);
},
),
),
const SizedBox(width: 10),
// 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: () {
ref.read(undanganTabProvider.notifier).state = 1;
shell.goBranch(1);
},
),
),
],
);
}
}
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: const BoxDecoration(
color: 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 {
/// 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) {
final statusState = ref.watch(onlineStatusProvider);
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: label,
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: const BoxDecoration(
color: 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.',
),
],
),
),
),
],
),
),
],
);
}
}