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