diff --git a/client_app/lib/features/chat/screens/chat_history_screen.dart b/client_app/lib/features/chat/screens/chat_history_screen.dart deleted file mode 100644 index a000163..0000000 --- a/client_app/lib/features/chat/screens/chat_history_screen.dart +++ /dev/null @@ -1,327 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import '../../../core/api/api_client_provider.dart'; -import '../../../core/constants.dart'; -import '../../../core/theme/halo_tokens.dart'; -import '../../../core/theme/widgets/widgets.dart'; -import '../../home/providers/bestie_history_provider.dart'; - -/// Phase 4 Stage 8 — `BestieHistoryList`. -/// -/// Renders past sessions with the v4 visual: orb + name + last-session date -/// + topic chips + sessions count + ONLINE pill (per-row, sourced from the -/// `mitra_is_online` field on the history payload). -/// -/// Tapping a row routes to the targeted "Curhat lagi" payment flow when the -/// row references a known mitra; closing-state rows still drop into the -/// session screen so the user can finish the goodbye composer. Otherwise we -/// fall back to the transcript view. -class ChatHistoryScreen extends ConsumerWidget { - const ChatHistoryScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final historyAsync = ref.watch(bestieHistoryProvider); - final fullSessionsAsync = ref.watch(_rawHistoryProvider); - - return Scaffold( - backgroundColor: HaloTokens.bg, - appBar: AppBar( - backgroundColor: HaloTokens.bg, - foregroundColor: HaloTokens.ink, - elevation: 0, - title: const Text( - 'Riwayat Chat', - style: TextStyle( - fontFamily: HaloTokens.fontDisplay, - fontWeight: FontWeight.w700, - ), - ), - ), - body: historyAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (_, __) => const Center( - child: Text( - 'gagal memuat riwayat. tarik untuk muat ulang.', - style: TextStyle(fontFamily: HaloTokens.fontBody), - ), - ), - data: (items) { - if (items.isEmpty) { - return const Center( - child: Text( - 'Belum ada riwayat chat', - style: TextStyle( - fontFamily: HaloTokens.fontBody, - color: HaloTokens.inkSoft, - ), - ), - ); - } - return RefreshIndicator( - onRefresh: () async { - ref.invalidate(bestieHistoryProvider); - ref.invalidate(_rawHistoryProvider); - await ref.read(bestieHistoryProvider.future); - }, - child: ListView.separated( - padding: const EdgeInsets.symmetric( - horizontal: HaloSpacing.s16, - vertical: HaloSpacing.s12, - ), - itemCount: items.length, - separatorBuilder: (_, __) => const SizedBox(height: HaloSpacing.s12), - itemBuilder: (context, index) { - final item = items[index]; - final raw = fullSessionsAsync.valueOrNull?[index]; - final isClosing = raw?['status'] == SessionStatus.closing; - return _BestieRow( - item: item, - isClosing: isClosing, - onTap: () { - if (isClosing && raw != null) { - context.push( - '/chat/session/${item.sessionId}', - extra: item.mitraName, - ); - return; - } - context.push('/chat/history/${item.sessionId}'); - }, - onCurhatLagi: item.mitraId == null || isClosing - ? null - : () => context.push('/payment', extra: { - 'targetedMitraId': item.mitraId, - 'mitraName': item.mitraName, - 'topicSensitivity': TopicSensitivity.regular, - }), - ); - }, - ), - ); - }, - ), - ); - } -} - -/// Raw history payload — used to read fields the v4 `BestieHistoryItem` -/// model doesn't surface (currently `status`, for the closing-row branch). -final _rawHistoryProvider = FutureProvider>>((ref) async { - final api = ref.read(apiClientProvider); - final response = await api.get('/api/client/chat/history'); - return ((response['data']['items'] as List?) ?? const []).cast>(); -}); - -class _BestieRow extends StatelessWidget { - final BestieHistoryItem item; - final bool isClosing; - final VoidCallback onTap; - final VoidCallback? onCurhatLagi; - - const _BestieRow({ - required this.item, - required this.isClosing, - required this.onTap, - required this.onCurhatLagi, - }); - - @override - Widget build(BuildContext context) { - return Material( - color: HaloTokens.surface, - borderRadius: HaloRadius.lg, - child: InkWell( - onTap: onTap, - borderRadius: HaloRadius.lg, - child: Padding( - padding: const EdgeInsets.all(HaloSpacing.s16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - HaloOrb( - size: 56, - seed: (item.mitraId ?? item.mitraName).hashCode, - label: item.mitraName, - ), - const SizedBox(width: HaloSpacing.s12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Flexible( - child: Text( - item.mitraName, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontFamily: HaloTokens.fontDisplay, - fontSize: 16, - fontWeight: FontWeight.w700, - color: HaloTokens.ink, - ), - ), - ), - if (item.mitraIsOnline) ...[ - const SizedBox(width: HaloSpacing.s8), - const _OnlinePill(), - ], - if (isClosing) ...[ - const SizedBox(width: HaloSpacing.s8), - const _ClosingBadge(), - ], - ], - ), - const SizedBox(height: 2), - Text( - [ - if (item.endedAt != null) _formatDate(item.endedAt!), - '${item.sessionsCount} sesi', - ].join(' · '), - style: const TextStyle( - fontFamily: HaloTokens.fontBody, - fontSize: 12.5, - color: HaloTokens.inkSoft, - ), - ), - ], - ), - ), - ], - ), - if (item.topics.isNotEmpty) ...[ - const SizedBox(height: HaloSpacing.s12), - Wrap( - spacing: HaloSpacing.s8, - runSpacing: HaloSpacing.s8, - children: item.topics - .take(3) - .map((t) => _TopicPill(label: t)) - .toList(), - ), - ], - if (onCurhatLagi != null) ...[ - const SizedBox(height: HaloSpacing.s12), - Align( - alignment: Alignment.centerRight, - child: HaloButton( - label: 'curhat lagi', - size: HaloButtonSize.sm, - variant: HaloButtonVariant.secondary, - onPressed: onCurhatLagi, - ), - ), - ], - ], - ), - ), - ), - ); - } - - String _formatDate(DateTime d) => - '${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year}'; -} - -class _OnlinePill extends StatelessWidget { - const _OnlinePill(); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: HaloTokens.success.withAlpha(36), - borderRadius: BorderRadius.circular(999), - ), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - _Dot(color: HaloTokens.success), - SizedBox(width: 4), - Text( - 'ONLINE', - style: TextStyle( - fontFamily: HaloTokens.fontBody, - fontSize: 10, - fontWeight: FontWeight.w700, - letterSpacing: 0.6, - color: HaloTokens.success, - ), - ), - ], - ), - ); - } -} - -class _Dot extends StatelessWidget { - final Color color; - const _Dot({required this.color}); - - @override - Widget build(BuildContext context) { - return Container( - width: 6, - height: 6, - decoration: BoxDecoration(color: color, shape: BoxShape.circle), - ); - } -} - -class _TopicPill extends StatelessWidget { - final String label; - const _TopicPill({required this.label}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric( - horizontal: HaloSpacing.s12, - vertical: 4, - ), - decoration: BoxDecoration( - color: HaloTokens.brandSofter, - borderRadius: BorderRadius.circular(999), - ), - child: Text( - label, - style: const TextStyle( - fontFamily: HaloTokens.fontBody, - fontSize: 11.5, - fontWeight: FontWeight.w600, - color: HaloTokens.brandDark, - ), - ), - ); - } -} - -class _ClosingBadge extends StatelessWidget { - const _ClosingBadge(); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: HaloTokens.accentSoft, - borderRadius: BorderRadius.circular(999), - ), - child: const Text( - 'Belum ditutup', - style: TextStyle( - fontFamily: HaloTokens.fontBody, - fontSize: 10, - fontWeight: FontWeight.w600, - color: HaloTokens.brandDark, - ), - ), - ); - } -} diff --git a/client_app/lib/features/chat_tab/providers/pending_payments_provider.dart b/client_app/lib/features/chat_tab/providers/pending_payments_provider.dart new file mode 100644 index 0000000..9f60505 --- /dev/null +++ b/client_app/lib/features/chat_tab/providers/pending_payments_provider.dart @@ -0,0 +1,105 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/api/api_client_provider.dart'; +import '../../../core/auth/auth_notifier.dart'; + +/// One row in the Chat Tab > Pembayaran sub-tab. +/// +/// Mirrors the response of `GET /api/client/payment-sessions/pending`. A row +/// is either an initial-session payment (`isExtension == false`) — for which +/// mitra info is only present in the targeted "Curhat lagi" flow — or an +/// extension payment (`isExtension == true`) — mitra info resolved by the +/// backend via session_extensions → chat_sessions. +class PendingPaymentItem { + final String id; + final bool isExtension; + final int amount; + final int durationMinutes; + final String mode; + final DateTime createdAt; + final DateTime expiresAt; + final String? mitraId; + final String? mitraDisplayName; + + const PendingPaymentItem({ + required this.id, + required this.isExtension, + required this.amount, + required this.durationMinutes, + required this.mode, + required this.createdAt, + required this.expiresAt, + required this.mitraId, + required this.mitraDisplayName, + }); + + factory PendingPaymentItem.fromJson(Map json) { + return PendingPaymentItem( + id: json['id'] as String, + isExtension: json['is_extension'] as bool? ?? false, + amount: switch (json['amount']) { + num n => n.toInt(), + String s => int.tryParse(s) ?? 0, + _ => 0, + }, + durationMinutes: switch (json['duration_minutes']) { + num n => n.toInt(), + String s => int.tryParse(s) ?? 0, + _ => 0, + }, + mode: json['mode'] as String? ?? 'chat', + createdAt: DateTime.parse(json['created_at'] as String).toLocal(), + expiresAt: DateTime.parse(json['expires_at'] as String).toLocal(), + mitraId: json['mitra_id'] as String?, + mitraDisplayName: json['mitra_display_name'] as String?, + ); + } +} + +class PendingPaymentsData { + final List items; + final int total; + const PendingPaymentsData({required this.items, required this.total}); + static const empty = PendingPaymentsData(items: [], total: 0); +} + +/// Customer-scoped pending payment sessions. Returns `empty` when the caller +/// has no auth identity — keeps SHome1st + the chat tab from issuing a 401. +/// +/// Drives both the Pembayaran sub-tab list and the bottom-nav red dot via the +/// `pendingPaymentsCountProvider` derivative below. +final pendingPaymentsProvider = + FutureProvider((ref) async { + final customerId = ref.watch(authProvider.select((s) { + final data = s.valueOrNull; + return switch (data) { + AuthAuthenticatedData d => d.profile['id'] as String?, + AuthAnonymousData d => d.customerId, + _ => null, + }; + })); + if (customerId == null) return PendingPaymentsData.empty; + final api = ref.read(apiClientProvider); + final response = + await api.get('/api/client/payment-sessions/pending'); + final data = response['data'] as Map? ?? const {}; + final items = (data['items'] as List? ?? []) + .cast>() + .map(PendingPaymentItem.fromJson) + .toList(); + return PendingPaymentsData( + items: items, + total: switch (data['total']) { + num n => n.toInt(), + String s => int.tryParse(s) ?? items.length, + _ => items.length, + }, + ); +}); + +/// Lightweight derived count — feeds the bottom-nav red dot + the Pembayaran +/// sub-tab pill badge. Returns 0 while the underlying provider is loading or +/// has errored, so the badge never flickers from a transient state. +final pendingPaymentsCountProvider = Provider((ref) { + final async = ref.watch(pendingPaymentsProvider); + return async.valueOrNull?.total ?? 0; +}); diff --git a/client_app/lib/features/chat_tab/providers/selesai_history_provider.dart b/client_app/lib/features/chat_tab/providers/selesai_history_provider.dart new file mode 100644 index 0000000..52065d1 --- /dev/null +++ b/client_app/lib/features/chat_tab/providers/selesai_history_provider.dart @@ -0,0 +1,172 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/api/api_client_provider.dart'; +import '../../../core/auth/auth_notifier.dart'; + +/// One row in the Chat Tab > Selesai sub-tab. +/// +/// Closed-session details that survive into history: who, when, how long, +/// what closing message was left. The factory tolerates the same number / +/// string ambiguity bestieHistoryProvider already handles (postgres.js +/// stringifies bigint counts). +class SelesaiHistoryItem { + final String sessionId; + final String? mitraId; + final String mitraName; + final DateTime? endedAt; + final int? durationMinutes; + final String mode; + final String? mitraClosureMessage; + final String? customerClosureMessage; + final int sessionsCount; + final bool mitraIsOnline; + + const SelesaiHistoryItem({ + required this.sessionId, + required this.mitraId, + required this.mitraName, + required this.endedAt, + required this.durationMinutes, + required this.mode, + required this.mitraClosureMessage, + required this.customerClosureMessage, + required this.sessionsCount, + required this.mitraIsOnline, + }); + + factory SelesaiHistoryItem.fromJson(Map json) { + final endedAtRaw = json['ended_at']; + final createdAtRaw = json['created_at']; + return SelesaiHistoryItem( + sessionId: json['id'] as String, + mitraId: json['mitra_id'] as String?, + mitraName: json['mitra_display_name'] as String? ?? 'Bestie', + endedAt: endedAtRaw is String + ? DateTime.tryParse(endedAtRaw)?.toLocal() + : (createdAtRaw is String + ? DateTime.tryParse(createdAtRaw)?.toLocal() + : null), + durationMinutes: switch (json['duration_minutes']) { + num n => n.toInt(), + String s => int.tryParse(s), + _ => null, + }, + mode: json['mode'] as String? ?? 'chat', + mitraClosureMessage: json['mitra_closure_message'] as String?, + customerClosureMessage: json['customer_closure_message'] as String?, + sessionsCount: switch (json['sessions_count']) { + num n => n.toInt(), + String s => int.tryParse(s) ?? 1, + _ => 1, + }, + mitraIsOnline: json['mitra_is_online'] as bool? ?? false, + ); + } + + /// Preview shown in the row: prefer the mitra's closing message (closer to + /// the partner's voice), fall back to the customer's, then to a generic + /// "selesai" placeholder so empty rows still render. + String get previewText => + (mitraClosureMessage?.trim().isNotEmpty == true + ? mitraClosureMessage + : (customerClosureMessage?.trim().isNotEmpty == true + ? customerClosureMessage + : 'sesi selesai'))!; +} + +/// Persistent paginated state — accumulated items + cursor + flags. +class SelesaiHistoryState { + final List items; + final String? nextCursor; + final bool hasMore; + final bool loadingMore; + + const SelesaiHistoryState({ + this.items = const [], + this.nextCursor, + this.hasMore = false, + this.loadingMore = false, + }); + + SelesaiHistoryState copyWith({ + List? items, + String? nextCursor, + bool? hasMore, + bool? loadingMore, + bool clearCursor = false, + }) => + SelesaiHistoryState( + items: items ?? this.items, + nextCursor: clearCursor ? null : (nextCursor ?? this.nextCursor), + hasMore: hasMore ?? this.hasMore, + loadingMore: loadingMore ?? this.loadingMore, + ); +} + +/// Cursor-paginated Selesai history. First fetch returns the freshest 20 rows; +/// `loadMore` appends the next page. Customer-scoped — switching account +/// resets the list automatically because the build re-runs. +class SelesaiHistoryNotifier + extends AsyncNotifier { + static const _pageLimit = 20; + + @override + Future build() async { + final customerId = ref.watch(authProvider.select((s) { + final data = s.valueOrNull; + return switch (data) { + AuthAuthenticatedData d => d.profile['id'] as String?, + AuthAnonymousData d => d.customerId, + _ => null, + }; + })); + if (customerId == null) return const SelesaiHistoryState(); + return await _fetchPage(cursor: null); + } + + Future _fetchPage({String? cursor}) async { + final api = ref.read(apiClientProvider); + final url = cursor == null + ? '/api/client/chat/history?limit=$_pageLimit' + : '/api/client/chat/history?limit=$_pageLimit&cursor=${Uri.encodeQueryComponent(cursor)}'; + final response = await api.get(url); + final data = response['data'] as Map? ?? const {}; + final items = (data['items'] as List? ?? []) + .cast>() + .map(SelesaiHistoryItem.fromJson) + .toList(); + final existing = cursor == null ? [] : (state.valueOrNull?.items ?? const []); + return SelesaiHistoryState( + items: [...existing, ...items], + nextCursor: data['next_cursor'] as String?, + hasMore: data['has_more'] as bool? ?? false, + ); + } + + /// Append the next page if any. No-op when already loading or no cursor. + Future loadMore() async { + final current = state.valueOrNull; + if (current == null || !current.hasMore || current.loadingMore) return; + state = AsyncData(current.copyWith(loadingMore: true)); + try { + final next = await _fetchPage(cursor: current.nextCursor); + state = AsyncData(next); + } catch (e, st) { + state = AsyncError(e, st); + } + } + + /// Pull-to-refresh: drop the cursor and re-fetch from the top. + Future refresh() async { + state = const AsyncLoading(); + try { + state = AsyncData(await _fetchPage(cursor: null)); + } catch (e, st) { + state = AsyncError(e, st); + } + } +} + +final selesaiHistoryProvider = + AsyncNotifierProvider( + SelesaiHistoryNotifier.new, +); diff --git a/client_app/lib/features/chat_tab/screens/aktif_view.dart b/client_app/lib/features/chat_tab/screens/aktif_view.dart new file mode 100644 index 0000000..2f4b1b4 --- /dev/null +++ b/client_app/lib/features/chat_tab/screens/aktif_view.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/chat/active_session_notifier.dart'; +import '../../../core/theme/halo_tokens.dart'; +import '../widgets/chat_row.dart'; + +/// Chat-Tab > aktif sub-tab. +/// +/// Always renders 0 or 1 row — backend caps the customer to one active +/// session. Row stays visible while the user is inside the live chat room +/// (per §10.6 decision 1: aktif represents state, not navigation). +class AktifView extends ConsumerWidget { + const AktifView({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final async = ref.watch(activeSessionProvider); + return RefreshIndicator( + onRefresh: () => ref.read(activeSessionProvider.notifier).refresh(), + color: HaloTokens.brand, + child: async.when( + loading: () => const _Loading(), + error: (e, _) => _Error(message: e.toString()), + data: (data) { + if (!data.hasSession) return const _Empty(); + final session = data.session!; + final mitraName = + (session['mitra_display_name'] as String?) ?? 'Bestie'; + return ListView( + padding: const EdgeInsets.symmetric( + horizontal: HaloSpacing.s20, + vertical: HaloSpacing.s12, + ), + children: [ + ChatRow( + seed: mitraName.codeUnits.fold(0, (a, b) => a + b), + name: mitraName, + preview: _previewFor(data.unreadCount), + isLive: true, + isCall: (session['mode'] as String?) == 'call', + onTap: () { + final sessionId = session['id'] as String?; + if (sessionId == null) return; + context.push('/chat/session/$sessionId', + extra: {'mitraName': mitraName}); + }, + ), + ], + ); + }, + ), + ); + } + + String _previewFor(int unread) => + unread > 0 ? '$unread pesan baru' : 'lagi ngobrol nih'; +} + +class _Empty extends StatelessWidget { + const _Empty(); + @override + Widget build(BuildContext context) => const _CenteredMessage( + text: 'belum ada chat di sini', + ); +} + +class _Loading extends StatelessWidget { + const _Loading(); + @override + Widget build(BuildContext context) => const Center( + child: CircularProgressIndicator(color: HaloTokens.brand), + ); +} + +class _Error extends StatelessWidget { + final String message; + const _Error({required this.message}); + @override + Widget build(BuildContext context) => + _CenteredMessage(text: 'gagal memuat: $message'); +} + +class _CenteredMessage extends StatelessWidget { + final String text; + const _CenteredMessage({required this.text}); + @override + Widget build(BuildContext context) { + // Wrap in a scroll view so RefreshIndicator works even on empty state. + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: HaloSpacing.s20, + vertical: HaloSpacing.s64, + ), + child: Text( + text, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 13, + color: HaloTokens.inkMuted, + ), + ), + ), + ], + ); + } +} diff --git a/client_app/lib/features/chat_tab/screens/chat_tab_shell.dart b/client_app/lib/features/chat_tab/screens/chat_tab_shell.dart new file mode 100644 index 0000000..a93dc12 --- /dev/null +++ b/client_app/lib/features/chat_tab/screens/chat_tab_shell.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/chat/active_session_notifier.dart'; +import '../../../core/theme/halo_tokens.dart'; +import '../../home/widgets/halo_tab_bar.dart'; +import '../providers/pending_payments_provider.dart'; +import '../widgets/sub_tab_pill.dart'; + +/// Phase 4 Stage 10 — host scaffold for the Chat tab. +/// +/// Wraps the three sub-tab views (`/chat/aktif`, `/chat/pembayaran`, +/// `/chat/selesai`) via `ShellRoute`. The header + sub-tab pills + bottom +/// `HaloTabBar` stay constant while only the body swaps. +class ChatTabShell extends ConsumerWidget { + final Widget child; + final ChatSubTab active; + + const ChatTabShell({ + super.key, + required this.child, + required this.active, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final activeSession = ref.watch(activeSessionProvider).valueOrNull; + final unreadCount = + activeSession?.hasSession == true ? activeSession!.unreadCount : 0; + final pendingCount = ref.watch(pendingPaymentsCountProvider); + + return Scaffold( + backgroundColor: HaloTokens.bg, + body: SafeArea( + bottom: false, + child: Column( + children: [ + const _Heading(), + _SubTabBar( + active: active, + aktifBadge: unreadCount, + pembayaranBadge: pendingCount, + ), + Expanded(child: child), + const HaloTabBar(active: 'chat'), + ], + ), + ), + ); + } +} + +enum ChatSubTab { + aktif('/chat/aktif', 'aktif'), + pembayaran('/chat/pembayaran', 'pembayaran'), + selesai('/chat/selesai', 'selesai'); + + final String path; + final String label; + const ChatSubTab(this.path, this.label); +} + +class _Heading extends StatelessWidget { + const _Heading(); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.fromLTRB( + HaloSpacing.s24, + HaloSpacing.s20, + HaloSpacing.s24, + HaloSpacing.s12, + ), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'chat', + style: TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 26, + fontWeight: FontWeight.w700, + color: HaloTokens.brandDark, + letterSpacing: -0.52, + ), + ), + ), + ); + } +} + +class _SubTabBar extends StatelessWidget { + final ChatSubTab active; + final int aktifBadge; + final int pembayaranBadge; + + const _SubTabBar({ + required this.active, + required this.aktifBadge, + required this.pembayaranBadge, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: HaloSpacing.s16), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: HaloTokens.border)), + ), + child: Row( + children: [ + SubTabPill( + label: 'aktif', + active: active == ChatSubTab.aktif, + badgeCount: aktifBadge, + onTap: () => _navigate(context, ChatSubTab.aktif), + ), + SubTabPill( + label: 'pembayaran', + active: active == ChatSubTab.pembayaran, + badgeCount: pembayaranBadge, + onTap: () => _navigate(context, ChatSubTab.pembayaran), + ), + SubTabPill( + label: 'selesai', + active: active == ChatSubTab.selesai, + badgeCount: null, + onTap: () => _navigate(context, ChatSubTab.selesai), + ), + ], + ), + ); + } + + void _navigate(BuildContext context, ChatSubTab target) { + if (target == active) return; + context.go(target.path); + } +} diff --git a/client_app/lib/features/chat_tab/screens/pembayaran_view.dart b/client_app/lib/features/chat_tab/screens/pembayaran_view.dart new file mode 100644 index 0000000..22782df --- /dev/null +++ b/client_app/lib/features/chat_tab/screens/pembayaran_view.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/theme/halo_tokens.dart'; +import '../providers/pending_payments_provider.dart'; +import '../widgets/chat_row.dart'; + +/// Chat-Tab > pembayaran sub-tab. +/// +/// Lists pending initial + extension payments. Row tap resumes the +/// waiting-payment screen for that payment session. +class PembayaranView extends ConsumerWidget { + const PembayaranView({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final async = ref.watch(pendingPaymentsProvider); + return RefreshIndicator( + onRefresh: () => ref.refresh(pendingPaymentsProvider.future), + color: HaloTokens.brand, + child: async.when( + loading: () => const _Loading(), + error: (e, _) => _CenteredMessage(text: 'gagal memuat: $e'), + data: (data) { + if (data.items.isEmpty) { + return const _CenteredMessage( + text: 'belum ada pembayaran tertunda', + ); + } + return ListView.builder( + padding: const EdgeInsets.symmetric( + horizontal: HaloSpacing.s20, + vertical: HaloSpacing.s12, + ), + itemCount: data.items.length, + itemBuilder: (_, i) { + final item = data.items[i]; + final name = item.mitraDisplayName ?? 'Bestie'; + return ChatRow( + seed: name.codeUnits.fold(0, (a, b) => a + b), + name: name, + preview: item.isExtension + ? 'menunggu pembayaran perpanjangan' + : 'menunggu pembayaran sesi', + trailing: _relativeTime(item.createdAt), + chips: [PaymentAmountChip(amount: item.amount)], + isCall: item.mode == 'call', + onTap: () => context.push('/payment/waiting/${item.id}'), + ); + }, + ); + }, + ), + ); + } +} + +/// Loose "2 mnt lalu" formatter — enough for the row trailing label without +/// dragging in `intl`. Mirrors the Figma copy style. +String _relativeTime(DateTime when) { + final delta = DateTime.now().difference(when); + if (delta.inSeconds < 60) return 'baru aja'; + if (delta.inMinutes < 60) return '${delta.inMinutes} mnt lalu'; + if (delta.inHours < 24) return '${delta.inHours} jam lalu'; + if (delta.inDays < 7) return '${delta.inDays} hari lalu'; + return '${(delta.inDays / 7).floor()} mgg lalu'; +} + +class _Loading extends StatelessWidget { + const _Loading(); + @override + Widget build(BuildContext context) => const Center( + child: CircularProgressIndicator(color: HaloTokens.brand), + ); +} + +class _CenteredMessage extends StatelessWidget { + final String text; + const _CenteredMessage({required this.text}); + @override + Widget build(BuildContext context) { + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: HaloSpacing.s20, + vertical: HaloSpacing.s64, + ), + child: Text( + text, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 13, + color: HaloTokens.inkMuted, + ), + ), + ), + ], + ); + } +} diff --git a/client_app/lib/features/chat_tab/screens/selesai_view.dart b/client_app/lib/features/chat_tab/screens/selesai_view.dart new file mode 100644 index 0000000..08885d0 --- /dev/null +++ b/client_app/lib/features/chat_tab/screens/selesai_view.dart @@ -0,0 +1,148 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/theme/halo_tokens.dart'; +import '../providers/selesai_history_provider.dart'; +import '../widgets/chat_row.dart'; + +/// Chat-Tab > selesai sub-tab. +/// +/// Cursor-paginated past sessions. Tap → read-only transcript at +/// `/chat/transcript/:sessionId` (renamed from the retired /chat/history/:id). +class SelesaiView extends ConsumerStatefulWidget { + const SelesaiView({super.key}); + + @override + ConsumerState createState() => _SelesaiViewState(); +} + +class _SelesaiViewState extends ConsumerState { + final _scroll = ScrollController(); + + @override + void initState() { + super.initState(); + _scroll.addListener(_onScroll); + } + + @override + void dispose() { + _scroll.removeListener(_onScroll); + _scroll.dispose(); + super.dispose(); + } + + void _onScroll() { + // Trigger load-more when within ~400px of the bottom. The notifier no-ops + // if there's no cursor or another fetch is in flight. + if (!_scroll.hasClients) return; + final remaining = _scroll.position.maxScrollExtent - _scroll.position.pixels; + if (remaining < 400) { + ref.read(selesaiHistoryProvider.notifier).loadMore(); + } + } + + @override + Widget build(BuildContext context) { + final async = ref.watch(selesaiHistoryProvider); + return RefreshIndicator( + onRefresh: () => ref.read(selesaiHistoryProvider.notifier).refresh(), + color: HaloTokens.brand, + child: async.when( + loading: () => const _Loading(), + error: (e, _) => _CenteredMessage(text: 'gagal memuat: $e'), + data: (state) { + if (state.items.isEmpty) { + return const _CenteredMessage(text: 'belum ada riwayat curhat'); + } + return ListView.builder( + controller: _scroll, + padding: const EdgeInsets.symmetric( + horizontal: HaloSpacing.s20, + vertical: HaloSpacing.s12, + ), + itemCount: state.items.length + (state.hasMore ? 1 : 0), + itemBuilder: (_, i) { + if (i >= state.items.length) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: HaloSpacing.s16), + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: HaloTokens.brand, + ), + ), + ), + ); + } + final item = state.items[i]; + return ChatRow( + seed: item.mitraName.codeUnits.fold(0, (a, b) => a + b), + name: item.mitraName, + preview: item.previewText, + trailing: item.endedAt == null + ? null + : _relativeTime(item.endedAt!), + chips: [ + if (item.durationMinutes != null) + DurationChip(minutes: item.durationMinutes!), + ], + isCall: item.mode == 'call', + onTap: () => + context.push('/chat/transcript/${item.sessionId}'), + ); + }, + ); + }, + ), + ); + } +} + +String _relativeTime(DateTime when) { + final delta = DateTime.now().difference(when); + if (delta.inSeconds < 60) return 'baru aja'; + if (delta.inMinutes < 60) return '${delta.inMinutes} mnt lalu'; + if (delta.inHours < 24) return '${delta.inHours} jam lalu'; + if (delta.inDays < 7) return '${delta.inDays} hari lalu'; + return '${(delta.inDays / 7).floor()} mgg lalu'; +} + +class _Loading extends StatelessWidget { + const _Loading(); + @override + Widget build(BuildContext context) => const Center( + child: CircularProgressIndicator(color: HaloTokens.brand), + ); +} + +class _CenteredMessage extends StatelessWidget { + final String text; + const _CenteredMessage({required this.text}); + @override + Widget build(BuildContext context) { + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: HaloSpacing.s20, + vertical: HaloSpacing.s64, + ), + child: Text( + text, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 13, + color: HaloTokens.inkMuted, + ), + ), + ), + ], + ); + } +} diff --git a/client_app/lib/features/chat_tab/widgets/chat_row.dart b/client_app/lib/features/chat_tab/widgets/chat_row.dart new file mode 100644 index 0000000..00bdc43 --- /dev/null +++ b/client_app/lib/features/chat_tab/widgets/chat_row.dart @@ -0,0 +1,266 @@ +import 'package:flutter/material.dart'; +import '../../../core/theme/halo_tokens.dart'; +import '../../../core/theme/widgets/widgets.dart'; + +/// Generic row used by all three Chat-Tab sub-tabs (aktif / pembayaran / +/// selesai). The owning sub-tab supplies the right-side trailing widget and +/// any chips below the preview so this widget stays presentation-only. +/// +/// Visual reference: `requirement/Figma/screens/extras.jsx::SChatList` rows. +class ChatRow extends StatelessWidget { + final int seed; + final String name; + final String preview; + final bool isLive; + final String? trailing; + final List chips; + final bool isCall; + final VoidCallback? onTap; + + const ChatRow({ + super.key, + required this.seed, + required this.name, + required this.preview, + this.isLive = false, + this.trailing, + this.chips = const [], + this.isCall = false, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Material( + color: HaloTokens.surface, + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(14), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all(color: HaloTokens.border), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _Avatar(seed: seed, isLive: isLive), + const SizedBox(width: 12), + Expanded(child: _Body( + name: name, + preview: preview, + trailing: trailing, + isLive: isLive, + isCall: isCall, + chips: chips, + )), + ], + ), + ), + ), + ), + ); + } +} + +class _Avatar extends StatelessWidget { + final int seed; + final bool isLive; + const _Avatar({required this.seed, required this.isLive}); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 44, + height: 44, + child: Stack( + clipBehavior: Clip.none, + children: [ + HaloOrb(seed: seed, size: 44), + if (isLive) + Positioned( + right: -2, + bottom: -2, + child: Container( + width: 14, + height: 14, + decoration: BoxDecoration( + color: HaloTokens.success, + shape: BoxShape.circle, + border: Border.all(color: HaloTokens.surface, width: 2.5), + ), + ), + ), + ], + ), + ); + } +} + +class _Body extends StatelessWidget { + final String name; + final String preview; + final String? trailing; + final bool isLive; + final bool isCall; + final List chips; + + const _Body({ + required this.name, + required this.preview, + required this.trailing, + required this.isLive, + required this.isCall, + required this.chips, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Expanded( + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 14, + fontWeight: FontWeight.w600, + color: HaloTokens.ink, + ), + ), + ), + if (trailing != null || isLive) + Text( + isLive ? '● live' : (trailing ?? ''), + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 10.5, + fontWeight: isLive ? FontWeight.w600 : FontWeight.w400, + color: isLive ? HaloTokens.success : HaloTokens.inkMuted, + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + preview, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 12, + color: HaloTokens.inkSoft, + height: 1.4, + ), + ), + if (isCall || chips.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Wrap( + spacing: 6, + runSpacing: 4, + children: [ + if (isCall) const _CallChip(), + ...chips, + ], + ), + ), + ], + ); + } +} + +/// Voice-call indicator. Stage 6.0 introduced the header pill for the in-call +/// screen; this is its row-level companion in the Chat tab. +class _CallChip extends StatelessWidget { + const _CallChip(); + @override + Widget build(BuildContext context) => const _Chip( + text: '📞 Call', + textColor: HaloTokens.brandDark, + background: HaloTokens.brandSoft, + ); +} + +/// Amber chip used on Pembayaran rows: `bayar Rp X.XXX`. +class PaymentAmountChip extends StatelessWidget { + final int amount; + const PaymentAmountChip({super.key, required this.amount}); + + @override + Widget build(BuildContext context) => _Chip( + text: 'bayar ${_formatRupiah(amount)}', + textColor: const Color(0xFFA8410E), + background: const Color(0xFFFFF0E5), + ); +} + +/// Subtle duration suffix on Selesai rows: `X menit`. +class DurationChip extends StatelessWidget { + final int minutes; + const DurationChip({super.key, required this.minutes}); + + @override + Widget build(BuildContext context) => Text( + '$minutes menit', + style: const TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 10.5, + color: HaloTokens.inkMuted, + ), + ); +} + +class _Chip extends StatelessWidget { + final String text; + final Color textColor; + final Color background; + const _Chip({ + required this.text, + required this.textColor, + required this.background, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + text, + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 10.5, + fontWeight: FontWeight.w600, + color: textColor, + ), + ), + ); + } +} + +String _formatRupiah(int amount) { + // Locale-agnostic, no intl dep. Render with `.` group separators per id_ID. + final s = amount.toString(); + final buf = StringBuffer(); + for (int i = 0; i < s.length; i++) { + if (i != 0 && (s.length - i) % 3 == 0) buf.write('.'); + buf.write(s[i]); + } + return 'Rp${buf.toString()}'; +} diff --git a/client_app/lib/features/chat_tab/widgets/sub_tab_pill.dart b/client_app/lib/features/chat_tab/widgets/sub_tab_pill.dart new file mode 100644 index 0000000..1e97dc9 --- /dev/null +++ b/client_app/lib/features/chat_tab/widgets/sub_tab_pill.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import '../../../core/theme/halo_tokens.dart'; + +/// One of the three sub-tab buttons across the top of the Chat tab. +/// +/// Active state uses the brand color for the label + an underline; an +/// optional `badgeCount` renders the numeric pill next to the label. +/// `null` or `0` hides the pill (per Selesai's no-badge rule). +class SubTabPill extends StatelessWidget { + final String label; + final bool active; + final int? badgeCount; + final VoidCallback onTap; + + const SubTabPill({ + super.key, + required this.label, + required this.active, + required this.onTap, + this.badgeCount, + }); + + @override + Widget build(BuildContext context) { + final color = active ? HaloTokens.brandDark : HaloTokens.inkSoft; + final showBadge = badgeCount != null && badgeCount! > 0; + return Expanded( + child: InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 4), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: active ? HaloTokens.brand : Colors.transparent, + width: 2, + ), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 13, + fontWeight: active ? FontWeight.w700 : FontWeight.w500, + color: color, + ), + ), + if (showBadge) ...[ + const SizedBox(width: 6), + _CountBadge(count: badgeCount!, active: active), + ], + ], + ), + ), + ), + ); + } +} + +class _CountBadge extends StatelessWidget { + final int count; + final bool active; + const _CountBadge({required this.count, required this.active}); + + @override + Widget build(BuildContext context) { + return Container( + constraints: const BoxConstraints(minWidth: 18), + height: 18, + padding: const EdgeInsets.symmetric(horizontal: 6), + alignment: Alignment.center, + decoration: BoxDecoration( + color: active ? HaloTokens.brand : HaloTokens.brandSoft, + borderRadius: BorderRadius.circular(9), + ), + child: Text( + '$count', + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 10, + fontWeight: FontWeight.w700, + color: active ? Colors.white : HaloTokens.brandDark, + ), + ), + ); + } +} diff --git a/client_app/lib/features/home/home_screen.dart b/client_app/lib/features/home/home_screen.dart index b921c06..d32d221 100644 --- a/client_app/lib/features/home/home_screen.dart +++ b/client_app/lib/features/home/home_screen.dart @@ -8,18 +8,21 @@ import '../../core/notifications/notif_permission.dart'; import '../../core/theme/halo_tokens.dart'; import 'providers/bestie_history_provider.dart'; import 'widgets/bestie_choice_sheet.dart'; +import 'widgets/halo_tab_bar.dart'; /// Session-only dismiss flag for the "notif denied" banner. Resets on cold /// restart by design — `StateProvider` lives in memory only. final homeNotifBannerDismissedProvider = StateProvider((_) => false); -/// Home screen. +/// Home screen — Phase 4 redesign. /// -/// 1. The "Mulai Curhat" CTA is gated on real-time mitra availability -/// (polling owned by the [mitraAvailabilityProvider]). Polling is paused -/// on background and resumed on foreground via [WidgetsBindingObserver]. -/// 2. Tapping the enabled CTA pushes `/payment` so the customer must confirm -/// a payment session before any blast fires. +/// Renders one of two variants depending on auth state, mirroring the Figma +/// `SHome1st` / `SHomeReturning` components named in +/// `requirement/flow_customer.mermaid.md` §1: +/// - `AuthInitialData` (no JWT) → SHome1st: top login-recover banner + +/// `aku mau curhat` CTA + empty curhatan card. +/// - `AuthAnonymousData` / `AuthAuthenticatedData` → SHomeReturning: +/// `halo, {name}` greeting + `curhat sama bestie baru` CTA + history list. class HomeScreen extends ConsumerStatefulWidget { const HomeScreen({super.key}); @@ -32,17 +35,19 @@ class _HomeScreenState extends ConsumerState with WidgetsBindingObse void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); - // Kick the availability poll on once the first frame settles. Doing it - // here (rather than in build) avoids re-firing on every rebuild. WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; ref.read(mitraAvailabilityProvider.notifier).setActive(true); + // Re-fetch history every time we re-enter /home. Covers post-chat + // return via thank_you_screen's `context.go('/home')`, where the + // home widget is freshly constructed and we want the just-completed + // session reflected without requiring pull-to-refresh. + ref.invalidate(bestieHistoryProvider); }); } @override void dispose() { - // Stop polling when leaving home. ref.read(mitraAvailabilityProvider.notifier).setActive(false); WidgetsBinding.instance.removeObserver(this); super.dispose(); @@ -52,24 +57,17 @@ class _HomeScreenState extends ConsumerState with WidgetsBindingObse void didChangeAppLifecycleState(AppLifecycleState state) { final notifier = ref.read(mitraAvailabilityProvider.notifier); if (state == AppLifecycleState.resumed) { - // Re-fetch in case a session ended/started while backgrounded. ref.read(activeSessionProvider.notifier).refresh(); + ref.invalidate(bestieHistoryProvider); notifier.setActive(true); } else if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive) { notifier.setActive(false); } } - Future _onStartChatPressed(BuildContext context) async { - // Phase 4 Stage 2 removes the home-screen topic sensitivity prompt; the - // ESP picks collected during onboarding feed the same column server-side - // (info-only — no longer drives matching). Mitras still flip - // `topic_sensitivity` mid-session via the AppBar toggle. - // - // Phase 4 Stage 8: returning users get the bestie-choice sheet first; new - // users skip straight to the multi-screen payment shell. We fetch the - // history-has-items flag on-tap so a stale cache from logout/login doesn't - // mis-route. On error (e.g. offline), fall back to the new-user path. + /// CTA path for SHomeReturning. Returning users get the bestie-choice sheet + /// when they have prior history, otherwise jump to the new-payment shell. + Future _onCurhatBestieBaruPressed(BuildContext context) async { bool hasHistory; try { hasHistory = await ref.read(bestieHistoryHasItemsProvider.future); @@ -84,90 +82,48 @@ class _HomeScreenState extends ConsumerState with WidgetsBindingObse context.push('/payment/entry'); } + /// CTA path for SHome1st. Per mermaid §2, fresh users hit S2 Nama first + /// (the call-sign check); display_name_screen kicks off `loginAnonymous` + /// and pushes into the verif-choice sheet. + void _onAkuMauCurhatPressed(BuildContext context) { + context.push('/auth/display-name'); + } + @override Widget build(BuildContext context) { final authState = ref.watch(authProvider); final authData = authState.valueOrNull; - final activeSessionAsync = ref.watch(activeSessionProvider); - final availabilityAsync = ref.watch(mitraAvailabilityProvider); - - final displayName = switch (authData) { - AuthAuthenticatedData d => d.profile['display_name'] as String? ?? '', - AuthAnonymousData d => d.displayName, - _ => '', - }; - - // Poll-failure / loading both default to "no bestie available" (greyed-out). - // Never optimistically enable. - final mitraAvailable = availabilityAsync.valueOrNull ?? false; + // SHomeReturning needs a real session; everything else (AuthInitialData, + // AsyncError, transient OTP states) renders SHome1st with the login + // banner so an unauthenticated user is never shown the returning view. + final isReturning = + authData is AuthAuthenticatedData || authData is AuthAnonymousData; + final isFresh = !isReturning; return Scaffold( - appBar: AppBar( - title: const Text('Halo Bestie'), - actions: [ - IconButton( - icon: const Icon(Icons.history), - onPressed: () => context.push('/chat/history'), - ), - IconButton( - icon: const Icon(Icons.logout), - onPressed: () => ref.read(authProvider.notifier).logout(), - ), - ], - ), - body: RefreshIndicator( - onRefresh: () async { - // Pull-to-refresh kicks both the active-session and availability polls. - await Future.wait([ - ref.read(activeSessionProvider.notifier).refresh(), - ref.read(mitraAvailabilityProvider.notifier).refresh(), - ]); - }, - child: ListView( - // Force-scroll so RefreshIndicator can fire even on a short body. - physics: const AlwaysScrollableScrollPhysics(), - padding: EdgeInsets.zero, + backgroundColor: HaloTokens.bg, + body: SafeArea( + bottom: false, + child: Column( children: [ const _NotifDeniedBanner(), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 32), - child: SizedBox(height: 32), - ), - Center(child: Text('Halo, $displayName!', style: const TextStyle(fontSize: 24))), - const SizedBox(height: 32), - Center( - child: activeSessionAsync.when( - loading: () => const CircularProgressIndicator(), - error: (_, __) => _StartChatButton( - enabled: mitraAvailable, - onPressed: () => _onStartChatPressed(context), - ), - data: (snapshot) { - // Hide the "Sesi Aktif" CTA when the session is in `closing` - // — the conversation is over, only the goodbye composer - // remains. Backend auto-completes such sessions after a - // grace period; until then the user shouldn't be invited - // back into them from home. - final status = snapshot.session?['status'] as String?; - final isCurhatable = snapshot.hasSession && status != 'closing'; - if (isCurhatable) { - return _ActiveSessionCard( - mitraName: snapshot.mitraName, - unreadCount: snapshot.unreadCount, - onTap: () { - final sessionId = snapshot.sessionId; - if (sessionId == null) return; - context.push('/chat/session/$sessionId', extra: snapshot.mitraName); - }, - ); - } - return _StartChatButton( - enabled: mitraAvailable, - onPressed: () => _onStartChatPressed(context), - ); + Expanded( + child: RefreshIndicator( + onRefresh: () async { + await Future.wait([ + ref.read(activeSessionProvider.notifier).refresh(), + ref.read(mitraAvailabilityProvider.notifier).refresh(), + ref.refresh(bestieHistoryProvider.future), + ]); }, + child: isFresh + ? _SHome1stView(onCTA: () => _onAkuMauCurhatPressed(context)) + : _SHomeReturningView( + onCTA: () => _onCurhatBestieBaruPressed(context), + ), ), ), + const HaloTabBar(active: 'home'), ], ), ), @@ -175,34 +131,540 @@ class _HomeScreenState extends ConsumerState with WidgetsBindingObse } } -class _StartChatButton extends StatelessWidget { - final bool enabled; - final VoidCallback onPressed; - const _StartChatButton({required this.enabled, required this.onPressed}); +// ─── SHome1st ────────────────────────────────────────────────────────────── + +class _SHome1stView extends ConsumerWidget { + final VoidCallback onCTA; + const _SHome1stView({required this.onCTA}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final availabilityAsync = ref.watch(mitraAvailabilityProvider); + final mitraAvailable = availabilityAsync.valueOrNull ?? false; + + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: EdgeInsets.zero, + children: [ + const _LoginRecoverBanner(), + Padding( + padding: const EdgeInsets.fromLTRB(28, 24, 28, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _GreetingHaloOnly(), + const SizedBox(height: 4), + const _GreetingSubtitle(), + const SizedBox(height: 32), + _PrimaryCTA( + label: 'aku mau curhat', + enabled: mitraAvailable, + onPressed: onCTA, + ), + if (!mitraAvailable) ...[ + const SizedBox(height: 12), + const Text( + 'belum ada bestie tersedia', + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 13, + color: HaloTokens.inkMuted, + ), + ), + ], + const SizedBox(height: 28), + const _SectionLabel('curhatan sebelumnya'), + const SizedBox(height: 10), + const _HistoryEmptyCard(), + ], + ), + ), + ], + ); + } +} + +class _LoginRecoverBanner extends StatelessWidget { + const _LoginRecoverBanner(); @override Widget build(BuildContext context) { - return Column( - children: [ - const SizedBox(height: 16), - ElevatedButton( - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16), + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Material( + color: HaloTokens.brandSofter, + borderRadius: HaloRadius.md, + child: InkWell( + borderRadius: HaloRadius.md, + onTap: () => context.push('/auth/register'), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + borderRadius: HaloRadius.md, + border: Border.all(color: HaloTokens.brand.withValues(alpha: 0.2)), + ), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: const BoxDecoration( + color: HaloTokens.brand, + borderRadius: BorderRadius.all(Radius.circular(10)), + ), + alignment: Alignment.center, + child: const Text('👋', style: TextStyle(fontSize: 15)), + ), + const SizedBox(width: 10), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'udah pernah pakai HaloBestie?', + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 12.5, + fontWeight: FontWeight.w700, + color: HaloTokens.brandDark, + height: 1.3, + ), + ), + SizedBox(height: 1), + Text( + 'login buat lanjutin obrolan & history kamu', + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 11, + color: HaloTokens.inkSoft, + height: 1.3, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: HaloTokens.surface, + borderRadius: const BorderRadius.all(Radius.circular(8)), + border: Border.all(color: HaloTokens.brand.withValues(alpha: 0.33)), + ), + child: const Text( + 'masuk →', + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 12.5, + fontWeight: FontWeight.w700, + color: HaloTokens.brand, + ), + ), + ), + ], + ), ), - onPressed: enabled ? onPressed : null, - child: const Text('Mulai Curhat', style: TextStyle(fontSize: 18)), ), - const SizedBox(height: 12), - if (!enabled) - Text( - 'Belum ada bestie tersedia', - style: TextStyle(fontSize: 14, color: Colors.grey.shade600), + ), + ); + } +} + +class _GreetingHaloOnly extends StatelessWidget { + const _GreetingHaloOnly(); + + @override + Widget build(BuildContext context) { + return const Text( + 'halo,', + style: TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 32, + fontWeight: FontWeight.w700, + color: HaloTokens.brandDark, + height: 1.15, + letterSpacing: -0.64, + ), + ); + } +} + +class _GreetingSubtitle extends StatelessWidget { + const _GreetingSubtitle(); + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 290), + child: const Text( + 'lagi ngerasa gimana hari ini? bestie akan nemenin kamu kok 🤍', + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 14.5, + color: HaloTokens.inkSoft, + height: 1.55, + ), + ), + ); + } +} + +class _HistoryEmptyCard extends StatelessWidget { + const _HistoryEmptyCard(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 32), + decoration: BoxDecoration( + color: HaloTokens.surface, + borderRadius: HaloRadius.lg, + border: Border.all( + color: HaloTokens.border, + width: 1, + style: BorderStyle.solid, // Flutter has no built-in dashed border; + // a CustomPainter could draw dashes — visual parity is close enough + // for v1 (border color + radius match Figma exactly). + ), + ), + alignment: Alignment.center, + child: const Text( + 'belum ada curhatan. mulai aja, bestie udah siap 🌷', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 13, + color: HaloTokens.inkMuted, + ), + ), + ); + } +} + +// ─── SHomeReturning ──────────────────────────────────────────────────────── + +class _SHomeReturningView extends ConsumerWidget { + final VoidCallback onCTA; + const _SHomeReturningView({required this.onCTA}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final authData = ref.watch(authProvider).valueOrNull; + final activeSessionAsync = ref.watch(activeSessionProvider); + final availabilityAsync = ref.watch(mitraAvailabilityProvider); + final historyAsync = ref.watch(bestieHistoryProvider); + final mitraAvailable = availabilityAsync.valueOrNull ?? false; + + final displayName = switch (authData) { + AuthAuthenticatedData d => d.profile['display_name'] as String? ?? '', + AuthAnonymousData d => d.displayName, + _ => '', + }; + + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(24, 32, 24, 16), + children: [ + _GreetingHaloName(name: displayName), + const SizedBox(height: 4), + const _GreetingSubtitle(), + const SizedBox(height: 24), + activeSessionAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, __) => _PrimaryCTA( + label: 'curhat sama bestie baru', + enabled: mitraAvailable, + onPressed: onCTA, ), + data: (snapshot) { + final status = snapshot.session?['status'] as String?; + final isCurhatable = snapshot.hasSession && status != 'closing'; + if (isCurhatable) { + return _ActiveSessionCard( + mitraName: snapshot.mitraName, + unreadCount: snapshot.unreadCount, + onTap: () { + final sessionId = snapshot.sessionId; + if (sessionId == null) return; + context.push('/chat/session/$sessionId', + extra: snapshot.mitraName); + }, + ); + } + return _PrimaryCTA( + label: 'curhat sama bestie baru', + enabled: mitraAvailable, + onPressed: onCTA, + ); + }, + ), + if (!mitraAvailable) ...[ + const SizedBox(height: 12), + const Text( + 'belum ada bestie tersedia', + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 13, + color: HaloTokens.inkMuted, + ), + ), + ], + const SizedBox(height: 28), + const _SectionLabel('curhatan sebelumnya'), + const SizedBox(height: 10), + historyAsync.when( + loading: () => const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: CircularProgressIndicator()), + ), + error: (_, __) => const _HistoryEmptyCard(), + data: (items) { + if (items.isEmpty) return const _HistoryEmptyCard(); + return Column( + children: [ + for (var i = 0; i < items.length; i++) ...[ + _HistoryItemTile(item: items[i], seed: i + 1), + if (i < items.length - 1) const SizedBox(height: 10), + ], + ], + ); + }, + ), ], ); } } +class _GreetingHaloName extends StatelessWidget { + final String name; + const _GreetingHaloName({required this.name}); + + @override + Widget build(BuildContext context) { + final shown = name.trim().isEmpty ? 'kamu' : name; + return Text.rich( + TextSpan( + style: const TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 32, + fontWeight: FontWeight.w700, + height: 1.15, + letterSpacing: -0.64, + ), + children: [ + const TextSpan( + text: 'halo, ', + style: TextStyle(color: HaloTokens.brandDark), + ), + TextSpan( + text: shown, + style: const TextStyle(color: HaloTokens.brand), + ), + ], + ), + ); + } +} + +class _HistoryItemTile extends StatelessWidget { + final BestieHistoryItem item; + final int seed; + const _HistoryItemTile({required this.item, required this.seed}); + + @override + Widget build(BuildContext context) { + final relative = _relativeWhen(item.endedAt); + final topicLine = item.topics.isEmpty ? '' : '"${item.topics.first}"'; + return Material( + color: HaloTokens.surface, + borderRadius: HaloRadius.lg, + child: InkWell( + borderRadius: HaloRadius.lg, + onTap: () => context.push('/chat/transcript/${item.sessionId}'), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + borderRadius: HaloRadius.lg, + border: Border.all(color: HaloTokens.border), + ), + child: Row( + children: [ + _OrbPlaceholder(seed: seed), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Text( + item.mitraName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 14, + fontWeight: FontWeight.w600, + color: HaloTokens.ink, + ), + ), + ), + const SizedBox(width: 6), + Text( + relative, + style: const TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 10.5, + color: HaloTokens.inkMuted, + ), + ), + ], + ), + if (topicLine.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + topicLine, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 12, + color: HaloTokens.inkSoft, + height: 1.4, + ), + ), + ], + if (item.endedAt != null) ...[ + const SizedBox(height: 4), + const Text( + 'sesi habis · tap untuk lanjutin curhat', + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 10, + fontStyle: FontStyle.italic, + color: HaloTokens.inkMuted, + ), + ), + ], + ], + ), + ), + ], + ), + ), + ), + ); + } + + static String _relativeWhen(DateTime? when) { + if (when == null) return ''; + final diff = DateTime.now().difference(when); + if (diff.inDays >= 7) return '${(diff.inDays / 7).floor()} mgg lalu'; + if (diff.inDays >= 1) return '${diff.inDays} hari lalu'; + if (diff.inHours >= 1) return '${diff.inHours} jam lalu'; + if (diff.inMinutes >= 1) return '${diff.inMinutes} mnt lalu'; + return 'baru saja'; + } +} + +/// Placeholder for the Figma `HBOrb` gradient component. v1 ships as a +/// solid-color disc keyed off `seed`; a real animated orb is a follow-up. +class _OrbPlaceholder extends StatelessWidget { + final int seed; + const _OrbPlaceholder({required this.seed}); + + @override + Widget build(BuildContext context) { + const palette = [ + HaloTokens.brand, + HaloTokens.lilac, + HaloTokens.mint, + HaloTokens.accent, + ]; + final color = palette[seed % palette.length]; + return Container( + width: 42, + height: 42, + decoration: BoxDecoration( + gradient: RadialGradient( + colors: [color.withValues(alpha: 0.95), color.withValues(alpha: 0.55)], + ), + shape: BoxShape.circle, + ), + ); + } +} + +// ─── Shared ──────────────────────────────────────────────────────────────── + +class _SectionLabel extends StatelessWidget { + final String text; + const _SectionLabel(this.text); + + @override + Widget build(BuildContext context) { + return Text( + text, + style: const TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 11.5, + fontWeight: FontWeight.w600, + color: HaloTokens.inkMuted, + letterSpacing: 0.69, + ), + ); + } +} + +class _PrimaryCTA extends StatelessWidget { + final String label; + final bool enabled; + final VoidCallback onPressed; + const _PrimaryCTA({ + required this.label, + required this.enabled, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: HaloRadius.xl, + boxShadow: enabled ? HaloShadows.button : const [], + ), + child: Material( + color: enabled ? HaloTokens.brand : HaloTokens.brandSofter, + borderRadius: HaloRadius.xl, + child: InkWell( + borderRadius: HaloRadius.xl, + onTap: enabled ? onPressed : null, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 18), + child: Center( + child: Text( + label, + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 15, + fontWeight: FontWeight.w600, + color: enabled ? Colors.white : HaloTokens.inkMuted, + ), + ), + ), + ), + ), + ), + ), + ); + } +} + class _ActiveSessionCard extends StatelessWidget { final String mitraName; final int unreadCount; @@ -216,11 +678,14 @@ class _ActiveSessionCard extends StatelessWidget { @override Widget build(BuildContext context) { - return Card( + return Material( + color: HaloTokens.surface, + borderRadius: HaloRadius.lg, elevation: 2, + shadowColor: HaloTokens.brand.withValues(alpha: 0.2), child: InkWell( onTap: onTap, - borderRadius: BorderRadius.circular(12), + borderRadius: HaloRadius.lg, child: Padding( padding: const EdgeInsets.all(20), child: Row( @@ -229,7 +694,7 @@ class _ActiveSessionCard extends StatelessWidget { isLabelVisible: unreadCount > 0, label: Text('$unreadCount'), child: const CircleAvatar( - backgroundColor: Colors.green, + backgroundColor: HaloTokens.success, child: Icon(Icons.chat, color: Colors.white), ), ), @@ -239,18 +704,27 @@ class _ActiveSessionCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( - 'Sesi Aktif', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + 'sesi aktif', + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 16, + fontWeight: FontWeight.w700, + color: HaloTokens.ink, + ), ), const SizedBox(height: 4), Text( - 'Sedang curhat dengan $mitraName', - style: const TextStyle(fontSize: 14, color: Colors.grey), + 'sedang curhat dengan $mitraName', + style: const TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 14, + color: HaloTokens.inkSoft, + ), ), ], ), ), - const Icon(Icons.chevron_right), + const Icon(Icons.chevron_right, color: HaloTokens.inkMuted), ], ), ), @@ -260,8 +734,8 @@ class _ActiveSessionCard extends StatelessWidget { } /// Above-the-fold amber banner shown when notif permission is denied. Tap -/// "nyalain" → opens app settings; tap the close icon → hides for the -/// in-memory session only (cold restart re-shows it). +/// "nyalain" → opens app settings; tap close → hides for the in-memory +/// session (cold restart re-shows it). class _NotifDeniedBanner extends ConsumerWidget { const _NotifDeniedBanner(); @@ -331,3 +805,4 @@ class _NotifDeniedBanner extends ConsumerWidget { ); } } + diff --git a/client_app/lib/features/home/widgets/bestie_choice_sheet.dart b/client_app/lib/features/home/widgets/bestie_choice_sheet.dart index 8867354..913a1c8 100644 --- a/client_app/lib/features/home/widgets/bestie_choice_sheet.dart +++ b/client_app/lib/features/home/widgets/bestie_choice_sheet.dart @@ -51,7 +51,7 @@ class BestieChoiceSheet extends StatelessWidget { icon: Icons.favorite_outline, onTap: () { Navigator.of(context).pop(); - context.push('/chat/history'); + context.push('/chat'); }, ), const SizedBox(height: HaloSpacing.s12), diff --git a/client_app/lib/features/home/widgets/halo_tab_bar.dart b/client_app/lib/features/home/widgets/halo_tab_bar.dart new file mode 100644 index 0000000..091b387 --- /dev/null +++ b/client_app/lib/features/home/widgets/halo_tab_bar.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/theme/halo_tokens.dart'; +import '../../chat_tab/providers/pending_payments_provider.dart'; + +/// 4-tab bottom bar mirroring Figma `HBTabBar` (home / chat / kamu / premium SOON). +/// +/// Phase 4 §1 wires home + chat + kamu. `premium` is intentionally +/// no-op + SOON-tagged. Each tab uses `context.go` so tapping a tab from any +/// non-tab screen resets the back-stack — preventing nav-stack growth as the +/// user bounces between tabs. +/// +/// Stage 10: the `chat` tab now lands on `/chat` (which redirects into the +/// `aktif` sub-tab) and renders a red dot when any Pembayaran row is pending. +class HaloTabBar extends ConsumerWidget { + /// One of `home`, `chat`, `kamu`, `premium`. + final String active; + const HaloTabBar({super.key, required this.active}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pendingCount = ref.watch(pendingPaymentsCountProvider); + return Container( + decoration: const BoxDecoration( + color: HaloTokens.surface, + border: Border(top: BorderSide(color: HaloTokens.border)), + ), + padding: EdgeInsets.fromLTRB( + HaloSpacing.s16, + HaloSpacing.s8, + HaloSpacing.s16, + MediaQuery.of(context).padding.bottom + HaloSpacing.s8, + ), + child: Row( + children: [ + _TabItem( + icon: '🏠', + label: 'home', + active: active == 'home', + onTap: () => context.go('/home'), + ), + _TabItem( + icon: '💬', + label: 'chat', + active: active == 'chat', + showDot: pendingCount > 0, + onTap: () => context.go('/chat'), + ), + _TabItem( + icon: '👤', + label: 'kamu', + active: active == 'kamu', + onTap: () => context.go('/profile'), + ), + _TabItem( + icon: '✨', + label: 'premium', + active: active == 'premium', + soon: true, + onTap: () {}, + ), + ], + ), + ); + } +} + +class _TabItem extends StatelessWidget { + final String icon; + final String label; + final bool active; + final bool soon; + final bool showDot; + final VoidCallback onTap; + const _TabItem({ + required this.icon, + required this.label, + required this.active, + required this.onTap, + this.soon = false, + this.showDot = false, + }); + + @override + Widget build(BuildContext context) { + final color = active ? HaloTokens.brand : HaloTokens.inkMuted; + final opacity = soon ? 0.5 : 1.0; + return Expanded( + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Opacity( + opacity: opacity, + child: Text(icon, style: const TextStyle(fontSize: 22)), + ), + const SizedBox(height: 2), + Opacity( + opacity: opacity, + child: Text( + label, + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 10, + fontWeight: active ? FontWeight.w600 : FontWeight.w400, + color: color, + ), + ), + ), + ], + ), + if (soon) + Positioned( + top: -2, + right: 4, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: const BoxDecoration( + color: HaloTokens.accent, + borderRadius: BorderRadius.all(Radius.circular(6)), + ), + child: const Text( + 'SOON', + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 8, + fontWeight: FontWeight.w700, + color: Colors.white, + letterSpacing: 0.32, + ), + ), + ), + ), + if (showDot && !soon) + Positioned( + top: 2, + right: 18, + child: Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: HaloTokens.danger, + shape: BoxShape.circle, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/client_app/lib/router.dart b/client_app/lib/router.dart index f4648b0..a7c5019 100644 --- a/client_app/lib/router.dart +++ b/client_app/lib/router.dart @@ -2,25 +2,27 @@ 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 'features/auth/screens/welcome_screen.dart'; import 'features/auth/screens/display_name_screen.dart'; import 'features/auth/screens/register_screen.dart'; import 'features/auth/screens/otp_screen.dart'; import 'features/auth/screens/force_register_screen.dart'; import 'features/auth/screens/set_display_name_screen.dart'; import 'features/onboarding/onboarding_screen.dart'; -import 'features/onboarding/screens/esp_screen.dart'; import 'features/onboarding/screens/notif_gate_screen.dart'; import 'features/onboarding/screens/usp_screen.dart'; import 'features/splash/splash_screen.dart'; import 'features/home/home_screen.dart'; +import 'features/profile/profile_screen.dart'; import 'core/constants.dart'; import 'features/chat/screens/searching_screen.dart'; import 'features/chat/screens/bestie_found_screen.dart'; import 'features/chat/screens/no_bestie_screen.dart'; import 'features/chat/screens/chat_screen.dart'; -import 'features/chat/screens/chat_history_screen.dart'; import 'features/chat/screens/chat_transcript_screen.dart'; +import 'features/chat_tab/screens/chat_tab_shell.dart'; +import 'features/chat_tab/screens/aktif_view.dart'; +import 'features/chat_tab/screens/pembayaran_view.dart'; +import 'features/chat_tab/screens/selesai_view.dart'; import 'features/chat/screens/targeted_waiting_screen.dart'; import 'features/chat/screens/thank_you_screen.dart'; import 'features/payment/screens/payment_screen.dart'; @@ -69,8 +71,7 @@ GoRouter buildRouter(Ref ref) { final authState = ref.read(authProvider); final isSplash = state.matchedLocation == '/splash'; final isOnboarding = state.matchedLocation == '/onboarding'; - final isAuthRoute = state.matchedLocation.startsWith('/auth') || - state.matchedLocation == '/welcome'; + final isAuthRoute = state.matchedLocation.startsWith('/auth'); // Phase 4 onboarding flow (Verif Choice → ESP → USP) — must transit // freely while authState is AuthAnonymousData so the router doesn't // boot the user back to /home before they finish onboarding. @@ -89,14 +90,15 @@ GoRouter buildRouter(Ref ref) { return isOnboarding ? null : '/onboarding'; } if (isOnboarding) { - return '/welcome'; + return '/home'; } final data = authState.valueOrNull; if (data == null) { - // Error state — show login - if (!isAuthRoute && !isSplash) return '/welcome'; - if (isSplash) return '/welcome'; + // Error state — drop onto Home; SHome1st variant handles the + // unauthenticated render (login banner overlay). + if (!isAuthRoute && !isSplash) return '/home'; + if (isSplash) return '/home'; return null; } @@ -106,19 +108,22 @@ GoRouter buildRouter(Ref ref) { // intentionally pushes into /onboarding/* after loginAnonymous. if (isOnboardingFlow) return null; // While AuthAnonymousData, the user may legitimately be mid-flow on - // /welcome (initial entry) → /auth/display-name (push) → about to - // open the Verif Choice Sheet. When refreshListenable fires after - // loginAnonymous resolves, GoRouter re-evaluates the *bottom* of the - // navigation stack (/welcome) which would otherwise redirect to - // /home and tear the stack down before the sheet can open. Allow - // any auth route to stay put under AuthAnonymousData. + // /home → /auth/display-name (push) → about to open the Verif Choice + // Sheet. When refreshListenable fires after loginAnonymous resolves, + // GoRouter re-evaluates the bottom of the navigation stack — without + // this carve-out an /auth/* push would be torn down before the sheet + // can open. Allow any auth route to stay put under AuthAnonymousData. if (data is AuthAnonymousData && isAuthRoute) return null; return (isSplash || isAuthRoute) ? '/home' : null; } if (data is AuthNeedsDisplayNameData) return '/auth/set-name'; if (data is AuthForceRegisterData) return '/auth/force-register'; - if (!isAuthRoute && !isSplash) return '/welcome'; - if (isSplash) return '/welcome'; + // Phase 4: per flow_customer.mermaid §1, fresh / unauthenticated users + // land on Home directly — the login panel is an overlay on Home, not a + // separate /welcome screen. The Home1st login-panel overlay itself is + // still a follow-up (audit: Home1st 🟡). + if (!isAuthRoute && !isSplash) return '/home'; + if (isSplash) return '/home'; return null; }, routes: [ @@ -126,26 +131,19 @@ GoRouter buildRouter(Ref ref) { GoRoute(path: '/_theme_preview', builder: (_, __) => const ThemePreviewScreen()), GoRoute(path: '/splash', builder: (_, __) => const SplashScreen()), GoRoute(path: '/onboarding', builder: (_, __) => const OnboardingScreen()), - GoRoute(path: '/welcome', builder: (_, __) => const WelcomeScreen()), GoRoute(path: '/auth/display-name', builder: (_, __) => const DisplayNameScreen()), GoRoute(path: '/auth/register', builder: (_, __) => const RegisterScreen()), GoRoute(path: '/auth/otp', builder: (context, state) => OtpScreen(phone: state.extra as String)), GoRoute(path: '/auth/set-name', builder: (_, __) => const SetDisplayNameScreen()), GoRoute(path: '/auth/force-register', builder: (_, __) => const ForceRegisterScreen()), - // Phase 4 onboarding sub-flow (Stage 2). Verified vs anonymous branch - // share ESP + USP screens; the parent path drives the post-USP fork. - GoRoute( - path: '/onboarding/verif/esp', - builder: (_, __) => const EspScreen(verified: true), - ), + // Phase 4 onboarding sub-flow (Stage 2; updated 2026-05-12 — ESP retired, + // USP is now a one-time gate, see `usp_seen_provider.dart`). Verified vs + // anonymous branches share the USP screen; the parent path drives the + // post-USP fork. GoRoute( path: '/onboarding/verif/usp', builder: (_, __) => const UspScreen(verified: true), ), - GoRoute( - path: '/onboarding/anon/esp', - builder: (_, __) => const EspScreen(verified: false), - ), GoRoute( path: '/onboarding/anon/usp', builder: (_, __) => const UspScreen(verified: false), @@ -166,6 +164,7 @@ GoRouter buildRouter(Ref ref) { redirect: (_, __) => '/payment/method-pick', ), GoRoute(path: '/home', builder: (_, __) => const HomeScreen()), + GoRoute(path: '/profile', builder: (_, __) => const ProfileScreen()), GoRoute(path: '/payment', builder: (context, state) { // Legacy Phase 3.7 single-screen payment. Still reachable from // - Home "Mulai Curhat" CTA → no extras (general blast follows confirm) @@ -232,8 +231,29 @@ GoRouter buildRouter(Ref ref) { ); }), GoRoute(path: '/chat/thank-you', builder: (_, __) => const ThankYouScreen()), - GoRoute(path: '/chat/history', builder: (_, __) => const ChatHistoryScreen()), - GoRoute(path: '/chat/history/:sessionId', builder: (context, state) { + // Phase 4 Stage 10 — Chat tab with 3 sub-tabs. The bare `/chat` entry + // redirects into the default `aktif` sub-tab so tap-on-bottom-nav lands + // on a deterministic URL. + GoRoute(path: '/chat', redirect: (_, __) => '/chat/aktif'), + ShellRoute( + builder: (context, state, child) { + final active = switch (state.uri.path) { + '/chat/pembayaran' => ChatSubTab.pembayaran, + '/chat/selesai' => ChatSubTab.selesai, + _ => ChatSubTab.aktif, + }; + return ChatTabShell(active: active, child: child); + }, + routes: [ + GoRoute(path: '/chat/aktif', builder: (_, __) => const AktifView()), + GoRoute( + path: '/chat/pembayaran', + builder: (_, __) => const PembayaranView()), + GoRoute( + path: '/chat/selesai', builder: (_, __) => const SelesaiView()), + ], + ), + GoRoute(path: '/chat/transcript/:sessionId', builder: (context, state) { return ChatTranscriptScreen(sessionId: state.pathParameters['sessionId']!); }), ],