Files
halobestie-clone/client_app/lib/features/payment/screens/payment_screen.dart
ramadhan sjamsani a09f37135c Phase 4 checkpoint: chat-screen perf refactor + retryable blast-failure + repo-wide dispose-ref guardrail
Chat-screen performance (customer + mitra):
- Parent screens have zero `ref.watch` — only `ref.listen` for side effects
- Body extracted into its own `ConsumerStatefulWidget`; AppBar parts split
  into narrow `.select` consumers (mode, sensitivity, timer)
- Per-second timer ticks routed to dedicated providers
  (`chatRemainingSecondsProvider` + new `mitraChatRemainingSecondsProvider`)
  so WS `session_tick` frames don't invalidate the rest of the chat state

Dispose-in-ref bug fix:
- `home_screen.dart`, `payment_screen.dart`, `mitra_chat_screen.dart` —
  ref-using cleanup moved from `dispose()` to `deactivate()`. Modern
  Riverpod invalidates `ref` the moment `dispose()` runs; the resulting
  silent error corrupts the widget-tree finalize and the next screen
  appears frozen
- `halo_lints` package added at repo root with `no_ref_in_dispose` rule
  to catch this pattern in CI / IDE analysis
- `custom_lint` activated in both apps' `analysis_options.yaml`
  (was installed but never wired in — also brings `riverpod_lint`'s
  `avoid_ref_inside_state_dispose` online)
- CLAUDE.md Pitfalls section added to client_app + mitra_app

Phase 4 §3 retryable blast-failure (Option A):
- Backend `expirePairingRequest` + all-rejected use
  `recordIntermediateFailure` instead of `failPaymentSession` so the
  payment session stays `confirmed` for re-blast
- WS `pairing_failed` payload carries `is_terminal: false` on the
  retryable paths; client parses the flag and exposes `retryBlast()`
- "Coba cari lagi" CTA on S7 Timeout now re-blasts on the same payment
- Pairing service test updated to reflect the new semantics

Customer waiting-payment screen navigation patch:
- `_navigateTerminal` uses `Future.microtask` + `addPostFrameCallback`
  redundancy after a release-mode bug where polling stopped but
  `context.go` never fired, leaving the screen visually stuck on
  "menunggu pembayaran"

See requirement/resume-2026-05-15.md for next-day pickup checklist
(mitra release rebuild + S21 Ultra install + retest is the gating item).

Bundles unrelated in-flight Phase 4 §2.x work that was already on disk
(ESP screen removal, USP one-time gate scaffolding, bestie-availability
public route, OTP service edits, Maestro flow tweaks) — kept together
to avoid a partial-rebase mess.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:12:34 +08:00

401 lines
14 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 deactivate() {
// Best-effort cancel on back/leave if we still have a `pending` row.
// The notifier checks state before calling the API, so this is safe to
// call unconditionally. Lives in deactivate(), not dispose(), because
// modern Riverpod invalidates `ref` once dispose() starts — the resulting
// `Bad state: Cannot use "ref" after the widget was disposed.` corrupts
// the widget-tree finalize and leaves the next screen frozen.
// ignore: discarded_futures
ref.read(paymentProvider.notifier).cancelIfPending();
super.deactivate();
}
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)),
),
),
],
),
),
);
}
}