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:
@@ -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 {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user