Searching screen: soft-prompt card reskin, pulsing-dots panel replaces
the spinner, inline 5-min timeout panel with `coba cari lagi` (resets
pairing notifier + routes to /payment/entry for a fresh funnel — the
server-side payment is failed_pairing at that point so a stale retry
isn't valid) and `kembali ke home` ghost CTA.
Bestie-found screen: S9 Match-V4 reskin — HaloOrb + status dot +
'halo, aku bestie {name}' + `mulai sesi {N} menit →` with N pulled from
the active session's duration_minutes.
Targeted-wait overlay (new) at /chat/waiting-targeted/:mitraId. Three
sub-states from pairingProvider's PairingTargetedWaitingData:
waiting (20s countdown) / accepted (routes to chat) / declined (stubbed
BestieOfflinePopup with a TODO pointing to Stage 8). Reached via
payment_screen._routeToSearchOnConfirmed when the confirm carried a
targetedMitraId — keeps the mandatory payment-before-pairing invariant.
Dev-only POST /internal/_test/force-pairing-timeout drives the 5-min
timeout shortcut for the Maestro flow without waiting live.
Maestro 05_searching_timeout.yaml + force_pairing_timeout.js helper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
398 lines
13 KiB
Dart
398 lines
13 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import '../../../core/chat/chat_opening_provider.dart';
|
|
import '../../../core/constants.dart';
|
|
import '../../../core/pairing/pairing_notifier.dart';
|
|
import '../payment_notifier.dart';
|
|
|
|
/// Payment screen.
|
|
///
|
|
/// Reuses the mock pricing service (tiers + free trial). The customer picks a
|
|
/// duration (or auto-selects the free trial); on tap the screen creates a
|
|
/// `pending` payment session, then on "Bayar" / "Mulai" confirms it and routes
|
|
/// to the searching screen carrying `paymentSessionId` (and `targetedMitraId`
|
|
/// if this is a "Curhat lagi" flow).
|
|
///
|
|
/// Reachable from:
|
|
/// - Home "Mulai Curhat" CTA → no targeted mitra, normal blast follows.
|
|
/// - Chat history "Curhat lagi" CTA → targetedMitraId set, returning-chat
|
|
/// flow follows.
|
|
class PaymentScreen extends ConsumerStatefulWidget {
|
|
/// "Curhat lagi" only — when set, the eventual chat-request goes through
|
|
/// the returning-chat endpoint targeting this mitra.
|
|
final String? targetedMitraId;
|
|
|
|
/// Optional display name for the targeted mitra, surfaced in the screen
|
|
/// header so the customer knows who they're paying to chat with again.
|
|
final String? mitraName;
|
|
|
|
/// The topic-sensitivity choice the customer made in the topic-selection
|
|
/// bottom sheet on the home screen. Carried through here to be passed into
|
|
/// the chat-request API after confirm. Defaults to regular.
|
|
final TopicSensitivity topicSensitivity;
|
|
|
|
const PaymentScreen({
|
|
super.key,
|
|
this.targetedMitraId,
|
|
this.mitraName,
|
|
this.topicSensitivity = TopicSensitivity.regular,
|
|
});
|
|
|
|
@override
|
|
ConsumerState<PaymentScreen> createState() => _PaymentScreenState();
|
|
}
|
|
|
|
class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
|
/// Local UI selection (not in the notifier) — the duration the customer is
|
|
/// previewing before they tap to lock it in via createSession.
|
|
int? _selectedDurationMinutes;
|
|
|
|
/// True once we've kicked off `createSession()` for the current selection;
|
|
/// used to suppress double-taps while the round-trip is in flight.
|
|
bool _creatingSession = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// Make sure no stale state leaks in from a previous payment attempt.
|
|
Future.microtask(() => ref.read(paymentProvider.notifier).reset());
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
// Best-effort cancel on back/dispose if we still have a `pending` row.
|
|
// The notifier checks state before calling the API, so this is safe to
|
|
// call unconditionally.
|
|
// ignore: discarded_futures
|
|
ref.read(paymentProvider.notifier).cancelIfPending();
|
|
super.dispose();
|
|
}
|
|
|
|
|
|
Future<void> _onTierTapped({
|
|
required int durationMinutes,
|
|
required int price,
|
|
}) async {
|
|
if (_creatingSession) return;
|
|
// `price` is informational (already shown in the tier card) — the source
|
|
// of truth for the amount comes back from the backend.
|
|
setState(() {
|
|
_selectedDurationMinutes = durationMinutes;
|
|
_creatingSession = true;
|
|
});
|
|
await ref.read(paymentProvider.notifier).createSession(
|
|
durationMinutes: durationMinutes,
|
|
targetedMitraId: widget.targetedMitraId,
|
|
);
|
|
if (mounted) setState(() => _creatingSession = false);
|
|
}
|
|
|
|
Future<void> _onConfirmTapped() async {
|
|
final notifier = ref.read(paymentProvider.notifier);
|
|
await notifier.confirm();
|
|
}
|
|
|
|
Future<void> _routeToSearchOnConfirmed(PaymentConfirmedData payment) async {
|
|
// Kick off the right pairing flow against the freshly-confirmed payment.
|
|
final pairing = ref.read(pairingProvider.notifier);
|
|
if (payment.targetedMitraId != null) {
|
|
await pairing.startTargetedSearch(
|
|
paymentSessionId: payment.paymentSessionId,
|
|
mitraId: payment.targetedMitraId!,
|
|
mitraName: widget.mitraName ?? 'Bestie',
|
|
topicSensitivity: widget.topicSensitivity,
|
|
);
|
|
} else {
|
|
await pairing.startSearch(
|
|
paymentSessionId: payment.paymentSessionId,
|
|
topicSensitivity: widget.topicSensitivity,
|
|
);
|
|
}
|
|
if (!mounted) return;
|
|
// Reset our local notifier so a future payment attempt starts clean.
|
|
ref.read(paymentProvider.notifier).reset();
|
|
// Phase 4 Stage 5: targeted "Curhat lagi" lands on the dedicated
|
|
// SWaitingBestie overlay screen; general blast still uses the searching
|
|
// shell (which renders inline soft-prompt + timeout panels).
|
|
if (payment.targetedMitraId != null) {
|
|
context.go('/chat/waiting-targeted/${payment.targetedMitraId}');
|
|
} else {
|
|
context.go('/chat/searching');
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// One-shot side-effect listener: when the payment lands in `confirmed`,
|
|
// route to the searching screen.
|
|
ref.listen<PaymentSessionData>(paymentProvider, (prev, next) {
|
|
if (next is PaymentConfirmedData) {
|
|
// ignore: discarded_futures
|
|
_routeToSearchOnConfirmed(next);
|
|
}
|
|
});
|
|
|
|
final paymentState = ref.watch(paymentProvider);
|
|
final pricingAsync = ref.watch(chatPricingProvider);
|
|
final isReturning = widget.targetedMitraId != null;
|
|
|
|
return PopScope(
|
|
canPop: true,
|
|
child: Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(isReturning ? 'Chat lagi dengan ${widget.mitraName ?? 'Bestie'}' : 'Pilih Sesi & Bayar'),
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.chevron_left),
|
|
onPressed: () {
|
|
// PopScope above lets canPop fire dispose() which cancels the
|
|
// pending session. If there's no back-stack, fall back to home.
|
|
if (context.canPop()) {
|
|
context.pop();
|
|
} else {
|
|
context.go('/home');
|
|
}
|
|
},
|
|
),
|
|
),
|
|
body: pricingAsync.when(
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (_, __) => const Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(24),
|
|
child: Text('Gagal memuat harga. Coba lagi.', textAlign: TextAlign.center),
|
|
),
|
|
),
|
|
data: (pricing) => _buildBody(pricing, paymentState),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildBody(PricingData pricing, PaymentSessionData paymentState) {
|
|
// Inline error widget per project memory ("Avoid SnackBars for provider errors").
|
|
final errorBanner = paymentState is PaymentErrorData
|
|
? Container(
|
|
margin: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.red.shade50,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: Colors.red.shade200),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.error_outline, color: Colors.red.shade700),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
paymentState.message,
|
|
style: TextStyle(color: Colors.red.shade900),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
: const SizedBox.shrink();
|
|
|
|
return Column(
|
|
children: [
|
|
errorBanner,
|
|
Expanded(
|
|
child: ListView(
|
|
padding: const EdgeInsets.all(24),
|
|
children: [
|
|
const Text(
|
|
'Pilih Durasi Curhat',
|
|
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(height: 16),
|
|
if (pricing.freeTrialEligible) ...[
|
|
_FreeTrialCard(
|
|
durationMinutes: pricing.freeTrialDurationMinutes,
|
|
selected: paymentState is PaymentPendingData && paymentState.isFreeTrial,
|
|
onTap: () => _onTierTapped(
|
|
// For free trial: backend still wants a duration_minutes —
|
|
// pass the trial duration. The backend overrides amount→0
|
|
// when the customer is eligible.
|
|
durationMinutes: pricing.freeTrialDurationMinutes,
|
|
price: 0,
|
|
),
|
|
),
|
|
const Divider(height: 24),
|
|
],
|
|
...pricing.tiers.map((tier) {
|
|
final selected = _selectedDurationMinutes == tier.durationMinutes &&
|
|
paymentState is PaymentPendingData &&
|
|
!paymentState.isFreeTrial;
|
|
return _TierCard(
|
|
label: tier.label,
|
|
priceLabel: formatRupiah(tier.price),
|
|
selected: selected,
|
|
onTap: () => _onTierTapped(
|
|
durationMinutes: tier.durationMinutes,
|
|
price: tier.price,
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
),
|
|
if (paymentState is PaymentPendingData ||
|
|
paymentState is PaymentConfirmingData ||
|
|
paymentState is PaymentCreatingData)
|
|
_ConfirmBar(
|
|
paymentState: paymentState,
|
|
onConfirm: _onConfirmTapped,
|
|
formatPrice: formatRupiah,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _FreeTrialCard extends StatelessWidget {
|
|
final int durationMinutes;
|
|
final bool selected;
|
|
final VoidCallback onTap;
|
|
|
|
const _FreeTrialCard({
|
|
required this.durationMinutes,
|
|
required this.selected,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Card(
|
|
color: selected ? Colors.green.shade100 : Colors.green.shade50,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
side: selected
|
|
? BorderSide(color: Colors.green.shade700, width: 1.5)
|
|
: BorderSide.none,
|
|
),
|
|
child: ListTile(
|
|
leading: const Icon(Icons.card_giftcard, color: Colors.green),
|
|
title: Text('Free Trial ($durationMinutes Menit)'),
|
|
subtitle: const Text('Gratis untuk pertama kali!'),
|
|
trailing: Text(
|
|
'Gratis',
|
|
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.green.shade800),
|
|
),
|
|
onTap: onTap,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TierCard extends StatelessWidget {
|
|
final String label;
|
|
final String priceLabel;
|
|
final bool selected;
|
|
final VoidCallback onTap;
|
|
|
|
const _TierCard({
|
|
required this.label,
|
|
required this.priceLabel,
|
|
required this.selected,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Card(
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
side: selected
|
|
? const BorderSide(color: Colors.pink, width: 1.5)
|
|
: BorderSide.none,
|
|
),
|
|
child: ListTile(
|
|
title: Text(label),
|
|
trailing: Text(
|
|
priceLabel,
|
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
|
),
|
|
onTap: onTap,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ConfirmBar extends StatelessWidget {
|
|
final PaymentSessionData paymentState;
|
|
final Future<void> Function() onConfirm;
|
|
final String Function(int) formatPrice;
|
|
|
|
const _ConfirmBar({
|
|
required this.paymentState,
|
|
required this.onConfirm,
|
|
required this.formatPrice,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isCreating = paymentState is PaymentCreatingData;
|
|
final isConfirming = paymentState is PaymentConfirmingData;
|
|
final pending = paymentState is PaymentPendingData ? paymentState as PaymentPendingData : null;
|
|
|
|
final totalLabel = pending == null
|
|
? '...'
|
|
: pending.isFreeTrial
|
|
? 'Gratis'
|
|
: formatPrice(pending.amount);
|
|
final ctaLabel = pending != null && pending.isFreeTrial ? 'Mulai' : 'Bayar';
|
|
final disabled = isCreating || isConfirming || pending == null;
|
|
|
|
return SafeArea(
|
|
top: false,
|
|
child: Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.04),
|
|
blurRadius: 8,
|
|
offset: const Offset(0, -2),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
const Text('Total', style: TextStyle(fontSize: 16)),
|
|
Text(
|
|
totalLabel,
|
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton(
|
|
style: ElevatedButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
|
),
|
|
onPressed: disabled ? null : onConfirm,
|
|
child: isConfirming || isCreating
|
|
? const SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
|
)
|
|
: Text(ctaLabel, style: const TextStyle(fontSize: 16)),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|