Phase 4 Stage 10 follow-up: restore BestieHistoryList picker for §4 curhat-lagi

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 21:26:57 +08:00
parent 1908e98012
commit 22b10c4bbf
5 changed files with 352 additions and 18 deletions

View File

@@ -61,22 +61,22 @@ the buttons have nowhere to live.
- `loginGoogle` / `loginApple` on `authProvider` are still intact, so the - `loginGoogle` / `loginApple` on `authProvider` are still intact, so the
wiring is one button widget away. 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:** wire `BestieOfflinePopup` with `variant='returning'` on offline-row
tap. The popup widget already exists from Stage 8 (Tanya Admin sheet ships
**Fix options (pick one with product):** with the wiring); just needs to be triggered here.
- **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).
### `[2026-05-12]` S5 ESP screen retired from spec — code still ships it ### `[2026-05-12]` S5 ESP screen retired from spec — code still ships it

View File

@@ -114,18 +114,19 @@ env:
- assertVisible: "bestie yang udah kenal" - assertVisible: "bestie yang udah kenal"
- assertVisible: "bestie baru" - 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" - tapOn: "bestie yang udah kenal"
- extendedWaitUntil: - extendedWaitUntil:
visible: visible:
text: "Riwayat Chat" text: "bestie kamu sebelumnya"
timeout: 5000 timeout: 5000
- assertVisible: "ONLINE" - 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. # screen title; the targeted-payment flow itself is covered by Stage 5.
- tapOn: "curhat lagi" - tapOn: "bestie ${output.MITRA_NAME}"
- extendedWaitUntil: - extendedWaitUntil:
visible: visible:
text: "Chat lagi dengan" text: "Chat lagi dengan"

View File

@@ -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: <String, dynamic>{
'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<List<Map<String, dynamic>>>((ref) async {
final api = ref.read(apiClientProvider);
final response = await api.get('/api/client/chat/history');
return ((response['data']['items'] as List?) ?? const [])
.cast<Map<String, dynamic>>();
});
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,
),
),
);
}
}

View File

@@ -51,7 +51,7 @@ class BestieChoiceSheet extends StatelessWidget {
icon: Icons.favorite_outline, icon: Icons.favorite_outline,
onTap: () { onTap: () {
Navigator.of(context).pop(); Navigator.of(context).pop();
context.push('/chat'); context.push('/bestie/history');
}, },
), ),
const SizedBox(height: HaloSpacing.s12), const SizedBox(height: HaloSpacing.s12),

View File

@@ -12,6 +12,7 @@ import 'features/onboarding/screens/notif_gate_screen.dart';
import 'features/onboarding/screens/usp_screen.dart'; import 'features/onboarding/screens/usp_screen.dart';
import 'features/splash/splash_screen.dart'; import 'features/splash/splash_screen.dart';
import 'features/home/home_screen.dart'; import 'features/home/home_screen.dart';
import 'features/home/screens/bestie_history_list_screen.dart';
import 'features/profile/profile_screen.dart'; import 'features/profile/profile_screen.dart';
import 'core/constants.dart'; import 'core/constants.dart';
import 'features/chat/screens/searching_screen.dart'; import 'features/chat/screens/searching_screen.dart';
@@ -256,6 +257,13 @@ GoRouter buildRouter(Ref ref) {
GoRoute(path: '/chat/transcript/:sessionId', builder: (context, state) { GoRoute(path: '/chat/transcript/:sessionId', builder: (context, state) {
return ChatTranscriptScreen(sessionId: state.pathParameters['sessionId']!); 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(),
),
], ],
); );
} }