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((_) => 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 createState() => _UndanganScreenState(); } class _UndanganScreenState extends ConsumerState 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(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 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 _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 · 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), ), ); } }