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:
528
mitra_app/lib/features/undangan/undangan_screen.dart
Normal file
528
mitra_app/lib/features/undangan/undangan_screen.dart
Normal file
@@ -0,0 +1,528 @@
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user