Phase 3.7: paid pairing flow + returning chat + extension flip
- Backend: payment_sessions + pairing_failures tables; payment.service.js and pairing-failure.service.js (new); rewritten pairing.service.js (payment-gated blast + targeted "Curhat lagi" + cancel + fallback); rewritten extension.service.js (data-driven auto-approve with offline safeguard, charge-at-approval); pricing.service.js (extension tiers without free trial); mitra-status.service.js (countAvailableMitras cached path); 60s sweeper for stale payment sessions - Backend routes: client.payment.routes, client.mitra-availability.routes, internal/failed-pairings.routes; client.chat.routes rewritten for payment-gated start + /returning + /cancel + /fallback-to-blast; internal/config.routes adds 4 new keys with Valkey invalidate publish - client_app: mitra-availability poll, payment screen + notifier, pairing notifier rewrite (PairingTargetedWaiting + PairingFailed states), targeted-waiting overlay + bestie-unavailable dialog, "Curhat lagi" CTA, failed-pairing terminal, extension via payment-session - mitra_app: PairingRequestType enum, returning-chat 20s countdown auto-dismiss, extension card "otomatis disetujui" copy - control_center: 4 new config rows in Settings, Failed Pairings page (filter + paginate + action menu), sidebar + route registered - Test infrastructure: Vitest backend (7/7 pass), Playwright CC (4/4 pass), Maestro mobile scaffold (CLI install pending) - Bugs found via Playwright + fixed: LoginPage labels not associated with inputs (a11y); backend internal CORS missing PATCH/PUT/DELETE in allow-methods (silent settings breakage in browsers since Stage 4) - Docs: phase3.7.md PRD, phase3.7-plan.md, phase3.7-questions.md (Q&A), phase3.7-testing.md (E2E checklist), phase3.7-test-run-2026-05-03.md (today's run results) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
390
client_app/lib/features/payment/screens/payment_screen.dart
Normal file
390
client_app/lib/features/payment/screens/payment_screen.dart
Normal file
@@ -0,0 +1,390 @@
|
||||
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();
|
||||
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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user