Files
halobestie-clone/client_app/lib/features/home/home_screen.dart
ramadhan sjamsani 706149c75e Phase 4 Stage 3: payment shell (multi-screen flow)
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>
2026-05-10 16:28:59 +08:00

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),
],
),
),
),
);
}
}