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>
529 lines
17 KiB
Dart
529 lines
17 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../core/chat/chat_request_notifier.dart';
|
|
import '../../core/theme/halo_tokens.dart';
|
|
import '../../core/theme/widgets/halo_button.dart';
|
|
import '../../core/theme/widgets/halo_orb.dart';
|
|
|
|
/// Single source of truth for which Undangan sub-tab is currently selected.
|
|
/// 0 = Curhat Baru, 1 = Perpanjang Curhat. Home tiles write this before
|
|
/// switching to the Chat tab; UndanganScreen reads it on init to position
|
|
/// its TabController.
|
|
final undanganTabProvider = StateProvider<int>((_) => 0);
|
|
|
|
/// Undangan (Invitations) screen for the Chat tab of the Bestie shell.
|
|
///
|
|
/// Mirrors `figma-bestie/project/screens/v4.jsx::BestieInvites` for the
|
|
/// Curhat Baru tab and `figma-bestie/project/screens/v5.jsx::BestieInvitesExtend`
|
|
/// for the Perpanjang Curhat tab.
|
|
///
|
|
/// The Curhat Baru tab is wired to `chatRequestProvider` and lists every
|
|
/// pending invitation (the popup overlay shows ONE — this screen shows ALL).
|
|
/// Accept / Tolak buttons share the same notifier methods as the popup so
|
|
/// both surfaces stay in sync.
|
|
///
|
|
/// The Perpanjang tab is an empty-state placeholder until the backend
|
|
/// exposes a queryable stream of pending extension invitations.
|
|
///
|
|
/// `BestieTabBar` is rendered by the parent `ShellScreen`; this widget
|
|
/// only owns its own header + tabs + content area.
|
|
class UndanganScreen extends ConsumerStatefulWidget {
|
|
const UndanganScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<UndanganScreen> createState() => _UndanganScreenState();
|
|
}
|
|
|
|
class _UndanganScreenState extends ConsumerState<UndanganScreen>
|
|
with SingleTickerProviderStateMixin {
|
|
late final TabController _tabController;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// Use `ref.read` (not watch) — initState runs once, we don't want a rebuild.
|
|
// Home tiles set this provider before calling `goBranch(1)` so the tab
|
|
// controller lands on the correct sub-tab from the very first frame.
|
|
final initialIndex = ref.read(undanganTabProvider);
|
|
_tabController = TabController(
|
|
length: 2,
|
|
vsync: this,
|
|
initialIndex: initialIndex,
|
|
);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
// TabController doesn't touch ref — safe to dispose here.
|
|
_tabController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// If we're already mounted on this screen and a Home tile re-selects a
|
|
// sub-tab, animate to it. (Initial position is handled by initState.)
|
|
ref.listen<int>(undanganTabProvider, (prev, next) {
|
|
if (next != _tabController.index) {
|
|
_tabController.animateTo(next);
|
|
}
|
|
});
|
|
|
|
// Watch so the list rebuilds on every state change (new request, accept,
|
|
// decline, queue advance). The actual list is read via the notifier getter
|
|
// so we don't tie the rebuild to one specific state subtype.
|
|
ref.watch(chatRequestProvider);
|
|
final invites = ref.read(chatRequestProvider.notifier).pendingInvites;
|
|
|
|
return Scaffold(
|
|
backgroundColor: HaloTokens.bg,
|
|
body: SafeArea(
|
|
child: Column(
|
|
children: [
|
|
const _UndanganHeader(),
|
|
_UndanganTabBar(
|
|
controller: _tabController,
|
|
newCount: invites.length,
|
|
extendCount: 0,
|
|
),
|
|
Expanded(
|
|
child: TabBarView(
|
|
controller: _tabController,
|
|
children: [
|
|
_CurhatBaruTab(invites: invites),
|
|
const _PerpanjangTab(),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Header ────────────────────────────────────────────────────────────────
|
|
|
|
class _UndanganHeader extends StatelessWidget {
|
|
const _UndanganHeader();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return const Padding(
|
|
padding: EdgeInsets.fromLTRB(20, 20, 20, 8),
|
|
child: Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Text(
|
|
'Undangan',
|
|
style: TextStyle(
|
|
fontFamily: HaloTokens.fontDisplay,
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.w700,
|
|
color: HaloTokens.ink,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Tab bar ───────────────────────────────────────────────────────────────
|
|
|
|
class _UndanganTabBar extends StatelessWidget {
|
|
final TabController controller;
|
|
final int newCount;
|
|
final int extendCount;
|
|
|
|
const _UndanganTabBar({
|
|
required this.controller,
|
|
required this.newCount,
|
|
required this.extendCount,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
decoration: const BoxDecoration(
|
|
border: Border(
|
|
bottom: BorderSide(color: HaloTokens.border, width: 1),
|
|
),
|
|
),
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
child: TabBar(
|
|
controller: controller,
|
|
labelColor: HaloTokens.brand,
|
|
unselectedLabelColor: HaloTokens.inkSoft,
|
|
indicatorColor: HaloTokens.brand,
|
|
indicatorWeight: 2,
|
|
labelStyle: const TextStyle(
|
|
fontFamily: HaloTokens.fontBody,
|
|
fontSize: 13.5,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
unselectedLabelStyle: const TextStyle(
|
|
fontFamily: HaloTokens.fontBody,
|
|
fontSize: 13.5,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
tabs: [
|
|
_TabLabel(label: 'Curhat Baru', count: newCount),
|
|
_TabLabel(
|
|
label: 'Perpanjang Curhat',
|
|
count: extendCount,
|
|
accent: HaloTokens.accentAmber,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TabLabel extends StatelessWidget {
|
|
final String label;
|
|
final int count;
|
|
final Color? accent;
|
|
|
|
const _TabLabel({required this.label, required this.count, this.accent});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Tab(
|
|
height: 44,
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(label),
|
|
if (count > 0) ...[
|
|
const SizedBox(width: 6),
|
|
Container(
|
|
width: 16,
|
|
height: 16,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: accent ?? const Color(0xFFFF4D6A),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Text(
|
|
count > 9 ? '9+' : '$count',
|
|
style: const TextStyle(
|
|
fontFamily: HaloTokens.fontBody,
|
|
fontSize: 9,
|
|
fontWeight: FontWeight.w700,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Curhat Baru tab ──────────────────────────────────────────────────────
|
|
|
|
class _CurhatBaruTab extends ConsumerWidget {
|
|
final List<PendingInvite> invites;
|
|
const _CurhatBaruTab({required this.invites});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
if (invites.isEmpty) {
|
|
return const _EmptyState(
|
|
message: 'Belum ada undangan masuk 💛',
|
|
);
|
|
}
|
|
|
|
return ListView.builder(
|
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 20),
|
|
itemCount: invites.length,
|
|
itemBuilder: (_, i) {
|
|
final invite = invites[i];
|
|
return Padding(
|
|
padding: EdgeInsets.only(bottom: i == invites.length - 1 ? 0 : 12),
|
|
child: _InviteCard(
|
|
invite: invite,
|
|
variant: _InviteCardVariant.curhatBaru,
|
|
onAccept: () => _accept(context, invite),
|
|
onReject: () => _reject(ref, invite),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _accept(
|
|
BuildContext context,
|
|
PendingInvite invite,
|
|
) async {
|
|
// Call the same notifier method as the popup overlay's Terima button.
|
|
// Navigation to `/chat/session/:id` is handled by `ChatRequestOverlay`
|
|
// (mounted at the app root in `main.dart`) when the state transitions
|
|
// to `ChatRequestAcceptedData` — so we deliberately do NOT push the route
|
|
// here. If we did, the overlay's `ref.listen` would push it again.
|
|
//
|
|
// Capture the container before any await so a widget rebuild between the
|
|
// Accepting + Accepted states can't invalidate our ref.
|
|
final container = ProviderScope.containerOf(context, listen: false);
|
|
await container
|
|
.read(chatRequestProvider.notifier)
|
|
.accept(invite.sessionId);
|
|
}
|
|
|
|
void _reject(WidgetRef ref, PendingInvite invite) {
|
|
// `decline` posts to the backend then advances the internal queue —
|
|
// identical to the popup-overlay reject button.
|
|
ref.read(chatRequestProvider.notifier).decline(invite.sessionId);
|
|
}
|
|
}
|
|
|
|
// ─── Perpanjang tab (placeholder) ─────────────────────────────────────────
|
|
|
|
class _PerpanjangTab extends StatelessWidget {
|
|
const _PerpanjangTab();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// TODO(stage-3): wire to a pendingExtensionsProvider once backend exposes
|
|
// a queryable list of pending extension invitations.
|
|
return Container(
|
|
color: HaloTokens.accentAmberBg,
|
|
child: const _EmptyState(
|
|
message: 'Belum ada permintaan perpanjangan 💛',
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Empty state ──────────────────────────────────────────────────────────
|
|
|
|
class _EmptyState extends StatelessWidget {
|
|
final String message;
|
|
const _EmptyState({required this.message});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
|
child: Text(
|
|
message,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
fontFamily: HaloTokens.fontBody,
|
|
fontSize: 14,
|
|
color: HaloTokens.inkSoft,
|
|
height: 1.5,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Invite card ──────────────────────────────────────────────────────────
|
|
|
|
enum _InviteCardVariant { curhatBaru, perpanjang }
|
|
|
|
class _InviteCard extends StatelessWidget {
|
|
final PendingInvite invite;
|
|
final _InviteCardVariant variant;
|
|
final VoidCallback onAccept;
|
|
final VoidCallback onReject;
|
|
|
|
const _InviteCard({
|
|
required this.invite,
|
|
required this.variant,
|
|
required this.onAccept,
|
|
required this.onReject,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isExtend = variant == _InviteCardVariant.perpanjang;
|
|
final borderColor =
|
|
isExtend ? const Color(0xFFF5C97A) : HaloTokens.brandSoft;
|
|
final bg = isExtend ? const Color(0xFFFFF8EB) : HaloTokens.brandSofter;
|
|
|
|
// Stable orb color from the session id so repeat visits look consistent.
|
|
final orbSeed = invite.sessionId.hashCode;
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: bg,
|
|
borderRadius: HaloRadius.lg,
|
|
border: Border.all(color: borderColor, width: 1.5),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
HaloOrb(size: 40, seed: orbSeed),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Flexible(
|
|
child: Text(
|
|
_displayName(invite),
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontFamily: HaloTokens.fontBody,
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w700,
|
|
color: HaloTokens.ink,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
_ModeBadge(invite: invite, isExtend: isExtend),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
_expirySubtitle(invite, isExtend),
|
|
style: const TextStyle(
|
|
fontFamily: HaloTokens.fontBody,
|
|
fontSize: 12,
|
|
color: HaloTokens.inkSoft,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
flex: 1,
|
|
child: HaloButton(
|
|
label: 'Tolak',
|
|
onPressed: onReject,
|
|
variant: HaloButtonVariant.soft,
|
|
size: HaloButtonSize.sm,
|
|
fullWidth: true,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
flex: 2,
|
|
child: isExtend
|
|
? _PrimaryAmberButton(
|
|
label: 'Terima Perpanjangan →',
|
|
onPressed: onAccept,
|
|
)
|
|
: HaloButton(
|
|
label: 'Terima →',
|
|
onPressed: onAccept,
|
|
variant: HaloButtonVariant.primary,
|
|
size: HaloButtonSize.sm,
|
|
fullWidth: true,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String _displayName(PendingInvite invite) {
|
|
// The chat-request notifier doesn't carry the customer display_name; the
|
|
// popup shows generic copy too ("Ada permintaan chat baru!"). Until that
|
|
// payload is enriched on the backend, fall back to a short, neutral
|
|
// placeholder rather than leaking the session id.
|
|
return 'Customer';
|
|
}
|
|
|
|
String _expirySubtitle(PendingInvite invite, bool isExtend) {
|
|
final duration = invite.durationMinutes;
|
|
if (isExtend) {
|
|
// Per v5.jsx: "klien lama · expired <HH:mm> · tersisa ~N mnt"
|
|
return duration != null ? 'klien lama · +$duration menit' : 'klien lama';
|
|
}
|
|
if (invite.isFreeTrial == true) return 'Free Trial';
|
|
return duration != null ? 'Durasi: $duration menit' : '';
|
|
}
|
|
}
|
|
|
|
class _ModeBadge extends StatelessWidget {
|
|
final PendingInvite invite;
|
|
final bool isExtend;
|
|
const _ModeBadge({required this.invite, required this.isExtend});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// The chat-request payload doesn't carry SessionMode today — popup shows
|
|
// generic copy. Default to chat (💬). The extend variant shows "+N mnt".
|
|
final showAddMins = isExtend && invite.durationMinutes != null;
|
|
final label = showAddMins ? '+${invite.durationMinutes} mnt' : '💬 Chat';
|
|
final bg =
|
|
isExtend ? HaloTokens.accentAmberSoft : HaloTokens.surface;
|
|
final fg = isExtend ? const Color(0xFF7A4A0E) : HaloTokens.brandDark;
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: bg,
|
|
borderRadius: HaloRadius.pill,
|
|
),
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontFamily: HaloTokens.fontBody,
|
|
fontSize: 11,
|
|
fontWeight: isExtend ? FontWeight.w700 : FontWeight.w600,
|
|
color: fg,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Amber-tinted primary button used for "Terima Perpanjangan". HaloButton
|
|
/// hard-codes the brand pink for `primary`, so we render an inline
|
|
/// ElevatedButton that matches the variant's geometry but with the amber
|
|
/// accent from the Figma extend palette.
|
|
class _PrimaryAmberButton extends StatelessWidget {
|
|
final String label;
|
|
final VoidCallback? onPressed;
|
|
const _PrimaryAmberButton({required this.label, required this.onPressed});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton(
|
|
onPressed: onPressed,
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: HaloTokens.accentAmber,
|
|
foregroundColor: Colors.white,
|
|
elevation: 0,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: HaloSpacing.s16,
|
|
vertical: HaloSpacing.s8,
|
|
),
|
|
shape: const RoundedRectangleBorder(borderRadius: HaloRadius.pill),
|
|
textStyle: const TextStyle(
|
|
fontFamily: HaloTokens.fontBody,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
child: Text(label),
|
|
),
|
|
);
|
|
}
|
|
}
|