Six new screens under /payment/* + a paymentDraftProvider holding
mode/durationId/durationMinutes/priceIDR/paymentId/isFirstSessionDiscount
across the flow. PaymentEntryScreen handles the routing decision
(eligible+enabled -> /payment/discount-paywall, else /payment/method-pick)
and clears the draft on fresh entry.
Screens:
- discount_paywall_screen: S6 first-session discount with struck-through
gimmick price + actual price + 'mulai · Rp{actual}' CTA -> /payment/method
- method_pick_screen: chat vs call cards
- duration_pick_screen: tier list with chat|call mode toggle that resets
the selection on swap
- payment_method_screen: QRIS-first list, posts to existing
/api/client/payment-sessions with mode/duration/price/discount/method
- waiting_payment_screen: qr_flutter QR (encodes paymentId in mock mode),
20-min countdown header, 3s polling for status, pauses on background
via WidgetsBindingObserver
- payment_expired_screen: retry CTA -> /payment/method with draft retained
Status mapping: real payment_sessions.status uses 'confirmed'/'consumed'
for paid (not 'paid' as in plan) and 'expired'/'abandoned' as terminal.
home_screen 'Mulai Curhat' CTA now pushes /payment/entry.
Dev-only /internal/_test/force-expire-payment endpoint to drive Maestro
flow 04_payment_expired.yaml without waiting 20 minutes. Gated behind
NODE_ENV !== 'production'.
chat_opening_provider PricingData extended to carry Phase 4 chat/call
groups + firstSessionDiscount, back-compat with the Phase 3 shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
238 lines
8.4 KiB
Dart
238 lines
8.4 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';
|
|
|
|
/// 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);
|
|
}
|
|
}
|
|
|
|
void _onStartChatPressed(BuildContext context) {
|
|
// 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 3: enter the new multi-screen payment shell. The entry
|
|
// route picks discount-paywall vs. method-pick based on first-session
|
|
// eligibility. The legacy `/payment` route is preserved for the
|
|
// chat-history "Curhat lagi" path until Stage 5 migrates it.
|
|
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: const EdgeInsets.all(32),
|
|
children: [
|
|
const 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),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|