From 22b10c4bbf43a5d962f23441e14f7efa23254c18 Mon Sep 17 00:00:00 2001 From: ramadhan sjamsani Date: Tue, 12 May 2026 21:26:57 +0800 Subject: [PATCH] =?UTF-8?q?Phase=204=20Stage=2010=20follow-up:=20restore?= =?UTF-8?q?=20BestieHistoryList=20picker=20for=20=C2=A74=20curhat-lagi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original Stage 10 plan retired chat_history_screen.dart on the assumption that the new Chat tab Selesai sub-tab replaced it. That was wrong: Figma has two distinct screens — `extras.jsx::SChatList` (the Chat tab, browse-only) and `v4.jsx::BestieHistoryList` (the picker for mermaid §4 returning-user curhat-lagi). They serve different purposes on row tap: Selesai opens transcript, BestieHistoryList picks a past bestie for targeted-pair. Restoring BestieHistoryList at a new home: - New screen `features/home/screens/bestie_history_list_screen.dart` matching Figma `v4.jsx::BestieHistoryList`: appBar title "bestie kamu sebelumnya" subtitle "{N} bestie yang pernah nemenin kamu" row: orb + "bestie {name}" + ONLINE pill + sessions count + last date + topic + → arrow row tap (online) → /payment with targetedMitraId (Stage-3 flow) row tap (closing-grace) → /chat/session/$id to finish goodbye row (offline) → dimmed, tap disabled Drops the per-row "curhat lagi" secondary button — the row tap IS the pick action now (cleaner, matches Figma). - New route `/bestie/history` in router.dart; cleanly separated from the /chat/* family (which is now exclusively the Chat tab). - BestieChoiceSheet "bestie yang udah kenal" re-pointed from /chat to /bestie/history. - Stage 8 Maestro flow `08_returning_targeted.yaml` updated to assert the new screen title + tap the row by name (uses output.MITRA_NAME from the seed_history_session script). - TECH_DEBT entry retired (curhat-lagi entry point restored). New TECH_DEBT entry tracks the still-pending wire-up of the Bestie Offline Popup variant for offline-row tap per mermaid §4. flutter analyze clean (one pre-existing widget_test scaffolding error unrelated to Stage 10). Co-Authored-By: Claude Opus 4.7 (1M context) --- TECH_DEBT.md | 24 +- .../.maestro/flows/08_returning_targeted.yaml | 11 +- .../screens/bestie_history_list_screen.dart | 325 ++++++++++++++++++ .../home/widgets/bestie_choice_sheet.dart | 2 +- client_app/lib/router.dart | 8 + 5 files changed, 352 insertions(+), 18 deletions(-) create mode 100644 client_app/lib/features/home/screens/bestie_history_list_screen.dart diff --git a/TECH_DEBT.md b/TECH_DEBT.md index f0973c4..8aa2d16 100644 --- a/TECH_DEBT.md +++ b/TECH_DEBT.md @@ -61,22 +61,22 @@ the buttons have nowhere to live. - `loginGoogle` / `loginApple` on `authProvider` are still intact, so the wiring is one button widget away. -### `[2026-05-12]` Stage 10 — "curhat lagi" entry point lost; Stage 8 Maestro flow broken +### `[2026-05-12]` Stage 10 — Bestie Offline Popup variant not wired on BestieHistoryList -**Files:** `client_app/lib/features/home/widgets/bestie_choice_sheet.dart` (line ~54, now routes to `/chat`); `client_app/.maestro/flows/08_returning_targeted.yaml` (asserts "Riwayat Chat" + "curhat lagi" which no longer render). +**File:** `client_app/lib/features/home/screens/bestie_history_list_screen.dart` -**Decision:** Stage 10 retired `chat_history_screen.dart` and re-pointed the BestieChoiceSheet "bestie yang udah kenal" CTA at `/chat` (which redirects to `/chat/aktif`). The Selesai sub-tab matches Figma `SChatList` — transcript-only, no per-row "curhat lagi" button. +**Decision:** Stage 10 follow-up restored `BestieHistoryList` as a separate +picker screen (per mermaid §4) and made offline rows un-tappable (dimmed). +Mermaid §4 actually calls for a **Bestie Offline Popup (returning variant)** +to surface when the user picks an offline bestie — with options "cari bestie +lain" and "tanya admin". -**Why it's debt:** The "curhat lagi" targeted-payment entry point lived only on the deleted history screen (line 213 `label: 'curhat lagi'` → `context.push('/payment', extra: { 'targetedMitraId': ... })`). After Stage 10 there is **no** UI affordance to start a targeted payment against a known mitra from the customer app — only the general "Mulai Curhat → blast" path. The targeted-payment plumbing in `payment_notifier.dart` / `payment_screen.dart` is now orphaned (still wired, no caller). +**Why it's debt:** Today the offline row is just disabled. The user gets no +explicit prompt to redirect them into the blast flow or to contact admin. -Side-effect: `08_returning_targeted.yaml` (Stage 8) expects to navigate from BestieChoiceSheet → bestie-history list → "curhat lagi" tap → targeted payment. The middle screen is gone; the flow will fail at `assertVisible: "Riwayat Chat"`. - -**Fix options (pick one with product):** -- **A.** Add a "curhat lagi" secondary CTA on Selesai rows (deviates from Figma SChatList but restores the feature). Update `08_returning_targeted.yaml` to navigate `/chat/selesai → tap row's curhat-lagi → targeted payment`. -- **B.** Keep Selesai as transcript-only per Figma; reintroduce a "pick a past bestie" picker reachable from BestieChoiceSheet (essentially restore `chat_history_screen.dart` under a new name + route, kept separate from the Chat tab). -- **C.** Drop the "curhat lagi" feature entirely. Update mermaid + BestieChoiceSheet copy to remove the "bestie yang udah kenal" branch. Delete orphaned targeted-payment plumbing. - -Until decided, `08_returning_targeted.yaml` should be marked `.skip` or the Stage 8 flow rewritten against the new home → SHomeReturning history list (which DOES still render bestie rows via `bestieHistoryProvider`, but those rows tap-through to transcripts only — same "no curhat lagi" gap). +**Fix:** wire `BestieOfflinePopup` with `variant='returning'` on offline-row +tap. The popup widget already exists from Stage 8 (Tanya Admin sheet ships +with the wiring); just needs to be triggered here. ### `[2026-05-12]` S5 ESP screen retired from spec — code still ships it diff --git a/client_app/.maestro/flows/08_returning_targeted.yaml b/client_app/.maestro/flows/08_returning_targeted.yaml index bcb5c86..d13c000 100644 --- a/client_app/.maestro/flows/08_returning_targeted.yaml +++ b/client_app/.maestro/flows/08_returning_targeted.yaml @@ -114,18 +114,19 @@ env: - assertVisible: "bestie yang udah kenal" - assertVisible: "bestie baru" -# Choose the known bestie path → history list with v4 layout. +# Choose the known bestie path → BestieHistoryList picker (mermaid §4). +# Stage 10 renamed the screen + dropped the per-row "curhat lagi" button — +# the row tap itself is now the pick action. - tapOn: "bestie yang udah kenal" - extendedWaitUntil: visible: - text: "Riwayat Chat" + text: "bestie kamu sebelumnya" timeout: 5000 - assertVisible: "ONLINE" -- assertVisible: "curhat lagi" -# Tap "curhat lagi" → /payment (legacy targeted-payment route). Verify the +# Tap the seeded bestie row → /payment targeted-payment route. Verify the # screen title; the targeted-payment flow itself is covered by Stage 5. -- tapOn: "curhat lagi" +- tapOn: "bestie ${output.MITRA_NAME}" - extendedWaitUntil: visible: text: "Chat lagi dengan" diff --git a/client_app/lib/features/home/screens/bestie_history_list_screen.dart b/client_app/lib/features/home/screens/bestie_history_list_screen.dart new file mode 100644 index 0000000..5837643 --- /dev/null +++ b/client_app/lib/features/home/screens/bestie_history_list_screen.dart @@ -0,0 +1,325 @@ +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 '../providers/bestie_history_provider.dart'; + +/// `BestieHistoryList` — the picker for the returning-user "curhat lagi" +/// flow (mermaid §4). Visual reference: `screens/v4.jsx::BestieHistoryList`. +/// +/// Reached from `BestieChoiceSheet` → "bestie yang udah kenal". Row tap = +/// pick a past bestie → targeted-pair payment flow. Transcript browsing +/// lives in the Chat-tab Selesai sub-tab, not here. +/// +/// Row interaction rules: +/// - mitra_is_online + status != closing → tap targets `/payment` with +/// `targetedMitraId`, which jumps into the Stage-3.x payment flow and, +/// once confirmed, the Stage-5 targeted-wait overlay. +/// - status == closing → tap drops into the chat session screen so the +/// user can finish the goodbye composer (one-time grace path). +/// - mitra_is_online == false → row is dimmed and tap is disabled. Mermaid +/// §4 calls for a Bestie Offline Popup variant here, deferred until +/// OfflinePopup gets its returning-user copy. +class BestieHistoryListScreen extends ConsumerWidget { + const BestieHistoryListScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final historyAsync = ref.watch(bestieHistoryProvider); + final rawAsync = ref.watch(_rawHistoryProvider); + + return Scaffold( + backgroundColor: HaloTokens.bg, + appBar: AppBar( + backgroundColor: HaloTokens.bg, + foregroundColor: HaloTokens.brandDark, + elevation: 0, + title: const Text( + 'bestie kamu sebelumnya', + style: TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 20, + fontWeight: FontWeight.w700, + color: HaloTokens.brandDark, + ), + ), + ), + 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: Padding( + padding: EdgeInsets.symmetric(horizontal: HaloSpacing.s24), + child: Text( + 'belum ada bestie sebelumnya. coba bestie baru dulu ya.', + textAlign: TextAlign.center, + 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.s20, + vertical: HaloSpacing.s12, + ), + itemCount: items.length + 1, + separatorBuilder: (_, __) => + const SizedBox(height: HaloSpacing.s12), + itemBuilder: (context, index) { + if (index == 0) { + return Padding( + padding: const EdgeInsets.only( + bottom: HaloSpacing.s8, + left: 2, + ), + child: Text( + '${items.length} bestie yang pernah nemenin kamu', + style: const TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 13, + color: HaloTokens.inkSoft, + ), + ), + ); + } + final item = items[index - 1]; + final raw = rawAsync.valueOrNull?[index - 1]; + final isClosing = raw?['status'] == SessionStatus.closing; + return _BestieRow( + item: item, + isClosing: isClosing, + onPick: () { + if (isClosing) { + context.push( + '/chat/session/${item.sessionId}', + extra: item.mitraName, + ); + return; + } + if (item.mitraId == null) return; + 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 onPick; + + const _BestieRow({ + required this.item, + required this.isClosing, + required this.onPick, + }); + + @override + Widget build(BuildContext context) { + final canPick = item.mitraIsOnline && !isClosing && item.mitraId != null; + return Material( + color: HaloTokens.surface, + borderRadius: HaloRadius.lg, + child: InkWell( + onTap: canPick ? onPick : null, + borderRadius: HaloRadius.lg, + child: Opacity( + opacity: canPick ? 1.0 : 0.55, + child: Padding( + padding: const EdgeInsets.all(HaloSpacing.s16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + HaloOrb( + size: 56, + seed: (item.mitraId ?? item.mitraName).hashCode, + label: item.mitraName, + ), + const SizedBox(width: HaloSpacing.s12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Flexible( + child: Text( + 'bestie ${item.mitraName}', + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 15, + 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( + [ + '${item.sessionsCount}× sesi', + if (item.endedAt != null) + 'terakhir ${_formatDate(item.endedAt!)}', + ].join(' · '), + style: const TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 12, + color: HaloTokens.inkSoft, + ), + ), + if (item.topics.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + '"${item.topics.first}"', + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 11.5, + fontStyle: FontStyle.italic, + color: HaloTokens.inkMuted, + ), + ), + ], + ], + ), + ), + const SizedBox(width: HaloSpacing.s8), + Text( + '→', + style: TextStyle( + fontSize: 16, + color: canPick ? HaloTokens.brand : HaloTokens.inkMuted, + ), + ), + ], + ), + ), + ), + ), + ); + } + + 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 _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/home/widgets/bestie_choice_sheet.dart b/client_app/lib/features/home/widgets/bestie_choice_sheet.dart index 913a1c8..2c01f4d 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'); + context.push('/bestie/history'); }, ), const SizedBox(height: HaloSpacing.s12), diff --git a/client_app/lib/router.dart b/client_app/lib/router.dart index a7c5019..24bf25d 100644 --- a/client_app/lib/router.dart +++ b/client_app/lib/router.dart @@ -12,6 +12,7 @@ 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/home/screens/bestie_history_list_screen.dart'; import 'features/profile/profile_screen.dart'; import 'core/constants.dart'; import 'features/chat/screens/searching_screen.dart'; @@ -256,6 +257,13 @@ GoRouter buildRouter(Ref ref) { GoRoute(path: '/chat/transcript/:sessionId', builder: (context, state) { return ChatTranscriptScreen(sessionId: state.pathParameters['sessionId']!); }), + // Returning-user `curhat lagi` picker (mermaid §4). Reached from + // BestieChoiceSheet → "bestie yang udah kenal". Tap row → targeted-pair + // payment. Separate from the Chat tab (which is browse-only). + GoRoute( + path: '/bestie/history', + builder: (_, __) => const BestieHistoryListScreen(), + ), ], ); }