Phase 4 Stage 10 client_app: Chat tab UI (3 sub-tabs + retire bestie_history)

Flutter half of Stage 10 — the new Chat tab landing in the bottom nav.
The CTA target swaps from /chat/history to /chat, which redirects into
/chat/aktif. Three sibling routes under a single ShellRoute share a
header + sub-tab pills + the existing HaloTabBar footer:

  /chat/aktif        — the current active session (0 or 1 row)
  /chat/pembayaran   — pending initial + extension payments
  /chat/selesai      — past sessions, cursor-paginated infinite scroll

URL is the source of truth for the active sub-tab so deep links, back
stack, and Maestro all agree on state.

New feature dir `lib/features/chat_tab/`:
- providers/pending_payments_provider.dart — FutureProvider against the
  Stage-10 backend endpoint, plus pendingPaymentsCountProvider for the
  red-dot derivative
- providers/selesai_history_provider.dart — AsyncNotifier over
  GET /api/client/chat/history; tracks accumulated items + next_cursor +
  hasMore; loadMore() and refresh()
- widgets/chat_row.dart — generic row used by all 3 sub-tabs, with
  optional PaymentAmountChip / DurationChip / 📞 Call indicator
- widgets/sub_tab_pill.dart — pill with active underline + optional
  numeric badge (null hides; matches Selesai's no-badge rule)
- screens/chat_tab_shell.dart — ShellRoute scaffold + ChatSubTab enum
- screens/{aktif,pembayaran,selesai}_view.dart — the three sub-tab bodies

Router (`router.dart`):
- /chat → redirect → /chat/aktif
- ShellRoute hosts /chat/aktif, /chat/pembayaran, /chat/selesai
- /chat/history retired; /chat/history/:sessionId → /chat/transcript/:sessionId
- ChatHistoryScreen import + file deleted

HaloTabBar (`features/home/widgets/halo_tab_bar.dart` — new in the
working tree from Stage 9 sweep): now a ConsumerWidget. Chat tab goes
to /chat. Red dot renders when pendingPaymentsCountProvider > 0.

Inbound call-site updates:
- bestie_choice_sheet.dart: /chat/history → /chat
- home_screen.dart history-row tap: /chat/history/:id → /chat/transcript/:id

This commit also carries the larger Stage 9 sweep + ESP-removal + USP
gate edits that were already staged in the working tree on
`home_screen.dart` and `router.dart` from the prior session.

flutter analyze: clean except for the pre-existing scaffold
test/widget_test.dart MyApp reference (unrelated, present on master).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 20:14:22 +08:00
parent 350b92f1f3
commit e3ea1d793e
13 changed files with 1943 additions and 478 deletions

View File

@@ -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<bool>((_) => 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<HomeScreen> 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<HomeScreen> 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<void> _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<void> _onCurhatBestieBaruPressed(BuildContext context) async {
bool hasHistory;
try {
hasHistory = await ref.read(bestieHistoryHasItemsProvider.future);
@@ -84,90 +82,48 @@ class _HomeScreenState extends ConsumerState<HomeScreen> 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<HomeScreen> 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 {
);
}
}

View File

@@ -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),

View File

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