Bestie Choice Sheet on home Mulai Curhat CTA. When the user has at least one prior session (bestieHistoryHasItemsProvider hits the chat- sessions history endpoint), the CTA opens a HaloBottomSheet with two cards: 'bestie yang udah kenal' -> /chat/history, 'bestie baru' -> /payment/entry. Empty history -> direct to /payment/entry. Bestie history list visual upgrade: HaloOrb (mitraId seed) + name + last-session date + topic pills + sessions count + ONLINE pill. Backend getCustomerHistory now returns topics, mitra_is_online, sessions_count in a single payload (no per-row presence round-trip). BestieOfflinePopup with two variants (returning | new_) replacing the legacy BestieUnavailableDialog. tanya admin ghost CTA on both variants opens the new TanyaAdminSheet. Stage 5's targeted-wait declined stub + Stage 7's chat-screen 409 stub + searching-screen call site all migrated to the real component. TanyaAdminSheet: HaloBottomSheet with WA + Telegram buttons, deeplinks fetched via supportHandlesProvider (CC-config-driven). url_launcher added to client_app; ios LSApplicationQueriesSchemes covers https/http/whatsapp/tg. Stage 2's OTP-blocked popup hubungi admin SnackBar stub also migrated to TanyaAdminSheet. Dev-only POST /internal/_test/seed-history-session lets Maestro 08 flow seed a history row before exercising the choice sheet. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
334 lines
12 KiB
Dart
334 lines
12 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import '../../core/auth/auth_notifier.dart';
|
|
import '../../core/availability/mitra_availability_notifier.dart';
|
|
import '../../core/chat/active_session_notifier.dart';
|
|
import '../../core/notifications/notif_permission.dart';
|
|
import '../../core/theme/halo_tokens.dart';
|
|
import 'providers/bestie_history_provider.dart';
|
|
import 'widgets/bestie_choice_sheet.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.
|
|
///
|
|
/// 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.
|
|
class HomeScreen extends ConsumerStatefulWidget {
|
|
const HomeScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<HomeScreen> createState() => _HomeScreenState();
|
|
}
|
|
|
|
class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObserver {
|
|
@override
|
|
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);
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
// Stop polling when leaving home.
|
|
ref.read(mitraAvailabilityProvider.notifier).setActive(false);
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
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();
|
|
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.
|
|
bool hasHistory;
|
|
try {
|
|
hasHistory = await ref.read(bestieHistoryHasItemsProvider.future);
|
|
} catch (_) {
|
|
hasHistory = false;
|
|
}
|
|
if (!context.mounted) return;
|
|
if (hasHistory) {
|
|
await BestieChoiceSheet.show(context);
|
|
return;
|
|
}
|
|
context.push('/payment/entry');
|
|
}
|
|
|
|
@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;
|
|
|
|
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,
|
|
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),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StartChatButton extends StatelessWidget {
|
|
final bool enabled;
|
|
final VoidCallback onPressed;
|
|
const _StartChatButton({required this.enabled, required this.onPressed});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
children: [
|
|
const SizedBox(height: 16),
|
|
ElevatedButton(
|
|
style: ElevatedButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16),
|
|
),
|
|
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 _ActiveSessionCard extends StatelessWidget {
|
|
final String mitraName;
|
|
final int unreadCount;
|
|
final VoidCallback onTap;
|
|
|
|
const _ActiveSessionCard({
|
|
required this.mitraName,
|
|
required this.unreadCount,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Card(
|
|
elevation: 2,
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Row(
|
|
children: [
|
|
Badge(
|
|
isLabelVisible: unreadCount > 0,
|
|
label: Text('$unreadCount'),
|
|
child: const CircleAvatar(
|
|
backgroundColor: Colors.green,
|
|
child: Icon(Icons.chat, color: Colors.white),
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'Sesi Aktif',
|
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'Sedang curhat dengan $mitraName',
|
|
style: const TextStyle(fontSize: 14, color: Colors.grey),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Icon(Icons.chevron_right),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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).
|
|
class _NotifDeniedBanner extends ConsumerWidget {
|
|
const _NotifDeniedBanner();
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final statusAsync = ref.watch(notifPermissionStatusProvider);
|
|
final dismissed = ref.watch(homeNotifBannerDismissedProvider);
|
|
final isDenied = statusAsync.valueOrNull == NotifPermStatus.denied;
|
|
if (!isDenied || dismissed) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
return Container(
|
|
width: double.infinity,
|
|
color: HaloTokens.accentSoft,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: HaloSpacing.s16,
|
|
vertical: HaloSpacing.s8,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const Icon(
|
|
Icons.notifications_off_outlined,
|
|
size: 18,
|
|
color: HaloTokens.brandDark,
|
|
),
|
|
const SizedBox(width: HaloSpacing.s8),
|
|
const Expanded(
|
|
child: Text(
|
|
'notifikasi off — kamu bisa kelewat chat dari bestie',
|
|
style: TextStyle(
|
|
fontFamily: HaloTokens.fontBody,
|
|
fontSize: 12.5,
|
|
color: HaloTokens.ink,
|
|
),
|
|
),
|
|
),
|
|
TextButton(
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: HaloTokens.brandDark,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: HaloSpacing.s8,
|
|
),
|
|
minimumSize: const Size(0, 32),
|
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
textStyle: const TextStyle(
|
|
fontFamily: HaloTokens.fontBody,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
onPressed: () =>
|
|
ref.read(notifPermissionStatusProvider.notifier).openAppSettings(),
|
|
child: const Text('nyalain'),
|
|
),
|
|
IconButton(
|
|
iconSize: 18,
|
|
visualDensity: VisualDensity.compact,
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(),
|
|
color: HaloTokens.inkSoft,
|
|
icon: const Icon(Icons.close),
|
|
onPressed: () =>
|
|
ref.read(homeNotifBannerDismissedProvider.notifier).state = true,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|