Files
halobestie-clone/mitra_app/lib/features/undangan/undangan_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

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),
),
);
}
}