Phase 4 §4: payment-before-pair for returning users + Maestro suite

Stages 5.1, 5.3, 5.4 of the returning-user flow rework. All three §4
entry paths now require payment BEFORE pairing, matching the updated
mermaid spec.

* Spec (requirement/flow_customer.mermaid.md §4): payment block converges
  three call-sites (bestie-yang-udah-kenal-online, bestie-baru,
  offline-popup → cari bestie lain). PairRoute dispatches lama → targeted
  pair, baru/cari-lain → §3 blast. §3 retains its post-payment-shared
  contract.

* Stage 5.1 (client_app): PaymentDraft carries targetedMitraId +
  topicSensitivity. bestie_history_list seeds the draft + pushes
  /payment/entry (was legacy /payment). searching_screen branches on
  draft.targetedMitraId for blast-vs-targeted dispatch.
  payment_entry uses resetExceptTarget(); bestie_choice_sheet + home
  _onCurhatBestieBaruPressed call explicit reset() before push so
  the keepAlive draft can't leak stale targeting into a blast.

* Stage 5.3 (client_app): new BestieOfflineVariant.prePayReturning.
  Bestie-history-list _BestieRow splits tappable from dim so offline
  rows render dimmed but route taps into the popup. CTA "cari bestie
  lain" resets the draft + pushes /payment/entry.

* Stage 5.4 (client_app): deleted legacy /payment route,
  payment_screen.dart, payment_notifier.dart(+.g.dart). router cleaned.

* Tests (requirement/phase4-customer-flow.md + client_app/.maestro/):
  six Maestro flows TS-01..TS-06 covering every §4 branching point,
  all passing end-to-end. Shared onboarding prelude under
  .maestro/subflows/. New helper scripts: accept_latest_pending,
  force_mitra_offline, force_other_mitra_online,
  reset_all_mitras_online, mitra_accept_latest_internal. New backend
  _test endpoints to match. /reset-phone now cascade-deletes
  customer_transactions (FK was blocking). /force-pairing-timeout
  branches targeted (RETURNING_CHAT_TIMEOUT via
  expireTargetedPairingRequest, now exported) vs blast (PAIRING_FAILED).
  seed_history_session also outputs MITRA_NAME_RE (regex-escaped) for
  reliable selectors against display names containing regex specials.

* mitra_app: dispose-during-deactivate guardrail for back-press on the
  mitra chat screen after the customer's goodbye message. Pending real
  emulator repro verification (carried over from 2026-05-15).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 20:25:15 +08:00
parent 1c9d81d81d
commit e09f76ceb6
32 changed files with 1755 additions and 680 deletions

View File

@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/constants.dart';
import '../../../core/pairing/pairing_notifier.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
@@ -44,18 +43,32 @@ class _SearchingScreenState extends ConsumerState<SearchingScreen> {
ref.listenManual<PairingData>(pairingProvider, _onPairingState);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
// Kick off the blast if pairing hasn't started yet — Phase 4's
// multi-screen payment flow lands here without a startSearch call
// Kick off pairing if it hasn't started yet — Phase 4's multi-screen
// payment flow lands here without an upstream startSearch call
// (waiting → notif-gate → /chat/searching, no intermediate that
// owned the call).
// owned the call). Branch on draft.targetedMitraId: a returning-user
// "Curhat lagi" flow stamped the targeted mitra onto the draft before
// payment, so we fire the targeted request and bounce to the dedicated
// wait overlay; everything else is a general blast.
final state = ref.read(pairingProvider);
if (state is PairingInitialData) {
final draft = ref.read(paymentDraftNotifierProvider);
if (draft.paymentId != null) {
if (draft.targetedMitraId != null) {
// ignore: discarded_futures
ref.read(pairingProvider.notifier).startTargetedSearch(
paymentSessionId: draft.paymentId!,
mitraId: draft.targetedMitraId!,
mitraName: draft.targetedMitraName ?? 'Bestie',
topicSensitivity: draft.topicSensitivity,
);
context.go('/chat/waiting-targeted/${draft.targetedMitraId}');
return;
}
// ignore: discarded_futures
ref.read(pairingProvider.notifier).startSearch(
paymentSessionId: draft.paymentId!,
topicSensitivity: TopicSensitivity.regular,
topicSensitivity: draft.topicSensitivity,
);
}
}

View File

@@ -6,24 +6,31 @@ import '../../../core/constants.dart';
import '../../../core/pairing/pairing_notifier.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
import '../../payment/state/payment_draft_provider.dart';
import '../../support/widgets/tanya_admin_sheet.dart';
/// Phase 4 Stage 8 — `BestieOfflinePopup`.
///
/// Two variants:
/// Three variants:
/// - [BestieOfflineVariant.returning] — the customer tried to chat with a
/// specific mitra (history "Curhat lagi"); the targeted attempt failed
/// (409 `targeted_mitra_offline`, or WS `returning_chat_timeout` /
/// `returning_chat_rejected`). Payment session is still `confirmed`, so we
/// surface a `Chat dengan bestie lain` primary CTA when other besties are
/// reachable (calls [Pairing.fallbackToBlast]).
/// - [BestieOfflineVariant.prePayReturning] — Stage 5.3: the customer tapped
/// a dimmed (offline) row in `BestieHistoryList` BEFORE any payment. No
/// payment session exists yet, so the "cari bestie lain" CTA resets the
/// payment draft and pushes `/payment/entry` for a fresh blast-payment
/// flow. This branch never calls [Pairing.fallbackToBlast] because there's
/// no `paymentSessionId` to attach to.
/// - [BestieOfflineVariant.new_] — the customer triggered a general blast
/// that bottomed out (no online besties). No fallback button; just a
/// ghost `tanya admin` and a `kembali ke home` exit.
///
/// Both variants expose `tanya admin` via a ghost CTA that opens the
/// All variants expose `tanya admin` via a ghost CTA that opens the
/// [TanyaAdminSheet].
enum BestieOfflineVariant { returning, new_ }
enum BestieOfflineVariant { returning, prePayReturning, new_ }
class BestieOfflinePopup extends ConsumerWidget {
final BestieOfflineVariant variant;
@@ -65,8 +72,12 @@ class BestieOfflinePopup extends ConsumerWidget {
final hasOtherAvailable = availabilityAsync.valueOrNull ?? false;
final isReturning = variant == BestieOfflineVariant.returning;
final title = isReturning ? '$mitraName lagi nggak online' : 'semua bestie lagi istirahat';
final body = isReturning
final isPrePayReturning = variant == BestieOfflineVariant.prePayReturning;
final mentionsBestie = isReturning || isPrePayReturning;
final title = mentionsBestie
? '$mitraName lagi nggak online'
: 'semua bestie lagi istirahat';
final body = mentionsBestie
? 'bestie kamu belum bisa nerima chat sekarang. coba bestie lain atau balik ke beranda dulu ya.'
: 'lagi nggak ada bestie yang siap dengerin. coba lagi bentar, atau hubungin admin biar dibantu.';
@@ -139,6 +150,19 @@ class BestieOfflinePopup extends ConsumerWidget {
);
},
)
else if (isPrePayReturning)
HaloButton(
label: 'cari bestie lain',
fullWidth: true,
onPressed: () {
// No payment session yet — clear any targeted-mitra intent
// on the draft so the fresh `/payment/entry` flow falls
// through to the blast branch.
ref.read(paymentDraftNotifierProvider.notifier).reset();
Navigator.of(context).pop();
context.push('/payment/entry');
},
)
else
HaloButton(
label: 'kembali ke home',
@@ -161,7 +185,7 @@ class BestieOfflinePopup extends ConsumerWidget {
TanyaAdminSheet.show(context);
},
),
if (canFallbackToBlast) ...[
if (canFallbackToBlast || isPrePayReturning) ...[
const SizedBox(height: HaloSpacing.s4),
HaloButton(
label: 'kembali ke home',

View File

@@ -6,6 +6,7 @@ import '../../core/availability/mitra_availability_notifier.dart';
import '../../core/chat/active_session_notifier.dart';
import '../../core/notifications/notif_permission.dart';
import '../../core/theme/halo_tokens.dart';
import '../payment/state/payment_draft_provider.dart';
import 'providers/bestie_history_provider.dart';
import 'widgets/bestie_choice_sheet.dart';
import 'widgets/halo_tab_bar.dart';
@@ -90,6 +91,8 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
await BestieChoiceSheet.show(context);
return;
}
// explicit reset — this branch is blast-only, clear any stale targeted mitra
ref.read(paymentDraftNotifierProvider.notifier).reset();
context.push('/payment/entry');
}

View File

@@ -5,6 +5,8 @@ import '../../../core/api/api_client_provider.dart';
import '../../../core/constants.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
import '../../chat/widgets/bestie_unavailable_dialog.dart';
import '../../payment/state/payment_draft_provider.dart';
import '../providers/bestie_history_provider.dart';
/// `BestieHistoryList` — the picker for the returning-user "curhat lagi"
@@ -15,14 +17,19 @@ import '../providers/bestie_history_provider.dart';
/// lives in the Chat-tab Selesai sub-tab, not here.
///
/// Row interaction rules:
/// - mitra_is_online + status != closing → tap targets `/payment` with
/// `targetedMitraId`, which jumps into the Stage-3.x payment flow and,
/// once confirmed, the Stage-5 targeted-wait overlay.
/// - mitra_is_online + status != closing → tap stamps the picked mitra
/// onto `paymentDraftNotifierProvider` (via `setTargetedMitra`) and
/// pushes `/payment/entry`. The Phase-4 multi-screen payment flow
/// (entry → method-pick → duration-pick → method → waiting → notif-gate
/// → searching) reads the targeting back off the draft to fire the
/// returning-chat request after the customer pays.
/// - status == closing → tap drops into the chat session screen so the
/// user can finish the goodbye composer (one-time grace path).
/// - mitra_is_online == false → row is dimmed and tap is disabled. Mermaid
/// §4 calls for a Bestie Offline Popup variant here, deferred until
/// OfflinePopup gets its returning-user copy.
/// - mitra_is_online == false → row stays dimmed for the visual cue, but
/// tap is enabled and opens [BestieOfflinePopup] with the
/// [BestieOfflineVariant.prePayReturning] variant (Stage 5.3). The popup
/// offers "cari bestie lain" (resets the draft + pushes `/payment/entry`
/// for a fresh blast-payment flow) and `tanya admin`.
class BestieHistoryListScreen extends ConsumerWidget {
const BestieHistoryListScreen({super.key});
@@ -116,12 +123,29 @@ class BestieHistoryListScreen extends ConsumerWidget {
);
return;
}
// Offline mitra — surface the Stage 5.3 pre-payment
// popup. The popup's "cari bestie lain" CTA resets the
// draft and pushes `/payment/entry` for a blast flow.
if (!item.mitraIsOnline) {
// ignore: discarded_futures
BestieOfflinePopup.show(
context,
variant: BestieOfflineVariant.prePayReturning,
mitraName: item.mitraName,
);
return;
}
if (item.mitraId == null) return;
context.push('/payment', extra: <String, dynamic>{
'targetedMitraId': item.mitraId,
'mitraName': item.mitraName,
'topicSensitivity': TopicSensitivity.regular,
});
// Stamp the targeted mitra onto the payment draft; the
// multi-screen payment flow (entry → method → waiting →
// notif-gate → searching) reads it back to fire the
// returning-chat request after payment confirms.
ref.read(paymentDraftNotifierProvider.notifier)
.setTargetedMitra(
mitraId: item.mitraId!,
mitraName: item.mitraName,
);
context.push('/payment/entry');
},
);
},
@@ -156,15 +180,22 @@ class _BestieRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final canPick = item.mitraIsOnline && !isClosing && item.mitraId != null;
// Online + has mitraId → full-colour, taps into targeted-pair payment.
// Closing → full-colour, taps into the goodbye composer (mitraId may be
// absent but `sessionId` is what the chat route needs).
// Offline → dim, but still tappable so [BestieOfflinePopup] can fire the
// Stage 5.3 pre-payment branch.
final isLive = item.mitraIsOnline && item.mitraId != null;
final tappable = isLive || isClosing || !item.mitraIsOnline;
final dim = !isLive && !isClosing;
return Material(
color: HaloTokens.surface,
borderRadius: HaloRadius.lg,
child: InkWell(
onTap: canPick ? onPick : null,
onTap: tappable ? onPick : null,
borderRadius: HaloRadius.lg,
child: Opacity(
opacity: canPick ? 1.0 : 0.55,
opacity: dim ? 0.55 : 1.0,
child: Padding(
padding: const EdgeInsets.all(HaloSpacing.s16),
child: Row(
@@ -239,7 +270,7 @@ class _BestieRow extends StatelessWidget {
'',
style: TextStyle(
fontSize: 16,
color: canPick ? HaloTokens.brand : HaloTokens.inkMuted,
color: dim ? HaloTokens.inkMuted : HaloTokens.brand,
),
),
],

View File

@@ -1,14 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
import '../../payment/state/payment_draft_provider.dart';
/// Phase 4 Stage 8 — Bestie Choice Sheet.
///
/// Triggered from the home `Mulai Curhat` CTA when the user has at least one
/// prior session. Two cards: continue with a known bestie (→ history list)
/// vs. find a new bestie (→ soft-prompt + blast).
class BestieChoiceSheet extends StatelessWidget {
class BestieChoiceSheet extends ConsumerWidget {
const BestieChoiceSheet({super.key});
static Future<void> show(BuildContext context) {
@@ -19,7 +21,7 @@ class BestieChoiceSheet extends StatelessWidget {
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -60,6 +62,8 @@ class BestieChoiceSheet extends StatelessWidget {
subtitle: 'cari bestie baru yang siap dengerin sekarang.',
icon: Icons.auto_awesome_outlined,
onTap: () {
// explicit reset — this branch is blast-only, clear any stale targeted mitra
ref.read(paymentDraftNotifierProvider.notifier).reset();
Navigator.of(context).pop();
context.push('/payment/entry');
},

View File

@@ -1,180 +0,0 @@
import 'package:dio/dio.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../core/api/api_client.dart';
import '../../core/api/api_client_provider.dart';
import '../../core/constants.dart';
part 'payment_notifier.g.dart';
/// Payment-session lifecycle, customer side. The screen owns one of these per
/// (mitra-target, duration) attempt; the notifier wraps the REST calls to
/// `/api/client/payment-sessions`.
sealed class PaymentSessionData {
const PaymentSessionData();
}
class PaymentInitialData extends PaymentSessionData {
const PaymentInitialData();
}
class PaymentCreatingData extends PaymentSessionData {
const PaymentCreatingData();
}
/// Created server-side, sitting in `pending` until the customer taps "Bayar".
class PaymentPendingData extends PaymentSessionData {
final String paymentSessionId;
final int amount;
final int durationMinutes;
final bool isFreeTrial;
final bool isExtension;
final String? targetedMitraId;
const PaymentPendingData({
required this.paymentSessionId,
required this.amount,
required this.durationMinutes,
required this.isFreeTrial,
required this.isExtension,
this.targetedMitraId,
});
}
class PaymentConfirmingData extends PaymentSessionData {
final String paymentSessionId;
const PaymentConfirmingData(this.paymentSessionId);
}
/// Confirmed; the customer can now be routed to the searching screen with
/// this `paymentSessionId` (and optional `targetedMitraId` for "Curhat lagi").
class PaymentConfirmedData extends PaymentSessionData {
final String paymentSessionId;
final int durationMinutes;
final bool isFreeTrial;
final bool isExtension;
final String? targetedMitraId;
const PaymentConfirmedData({
required this.paymentSessionId,
required this.durationMinutes,
required this.isFreeTrial,
required this.isExtension,
this.targetedMitraId,
});
}
class PaymentErrorData extends PaymentSessionData {
final String message;
const PaymentErrorData(this.message);
}
@riverpod
class Payment extends _$Payment {
ApiClient get _api => ref.read(apiClientProvider);
@override
PaymentSessionData build() => const PaymentInitialData();
/// Create a `pending` payment session for the chosen [durationMinutes].
/// Pass [targetedMitraId] for the "Curhat lagi" path; pass [isExtension]
/// for an extension-cost payment (never combined with free trial).
Future<void> createSession({
required int durationMinutes,
String? targetedMitraId,
bool isExtension = false,
}) async {
state = const PaymentCreatingData();
try {
final body = <String, dynamic>{
'duration_minutes': durationMinutes,
if (targetedMitraId != null) 'targeted_mitra_id': targetedMitraId,
if (isExtension) 'is_extension': true,
};
// Trailing slash matters: the backend route is `app.post('/', ...)` mounted
// at prefix `/api/client/payment-sessions`, and Fastify is not configured
// with `ignoreTrailingSlash: true`, so the canonical URL has the slash.
final response = await _api.post('/api/client/payment-sessions/', data: body);
final data = response['data'] as Map<String, dynamic>;
state = PaymentPendingData(
paymentSessionId: data['id'] as String,
amount: data['amount'] as int? ?? 0,
durationMinutes: data['duration_minutes'] as int? ?? durationMinutes,
isFreeTrial: data['is_free_trial'] as bool? ?? false,
isExtension: data['is_extension'] as bool? ?? isExtension,
targetedMitraId: data['targeted_mitra_id'] as String?,
);
} on DioException catch (e) {
state = PaymentErrorData(_humanError(e, fallback: 'Gagal membuat sesi pembayaran.'));
} catch (_) {
state = const PaymentErrorData('Gagal membuat sesi pembayaran.');
}
}
/// Confirm the pending payment. Backend rejects truly empty bodies on
/// `POST .../confirm`, so we always send `{}`.
Future<void> confirm() async {
final current = state;
if (current is! PaymentPendingData) return;
state = PaymentConfirmingData(current.paymentSessionId);
try {
await _api.post(
'/api/client/payment-sessions/${current.paymentSessionId}/confirm',
data: const <String, dynamic>{},
);
state = PaymentConfirmedData(
paymentSessionId: current.paymentSessionId,
durationMinutes: current.durationMinutes,
isFreeTrial: current.isFreeTrial,
isExtension: current.isExtension,
targetedMitraId: current.targetedMitraId,
);
} on DioException catch (e) {
state = PaymentErrorData(_humanError(e, fallback: 'Gagal mengkonfirmasi pembayaran.'));
} catch (_) {
state = const PaymentErrorData('Gagal mengkonfirmasi pembayaran.');
}
}
/// Best-effort cancel of a still-pending session. Safe to call on dispose
/// even if the state isn't `pending` — we just no-op in that case.
Future<void> cancelIfPending() async {
final current = state;
if (current is! PaymentPendingData) return;
final id = current.paymentSessionId;
try {
await _api.post(
'/api/client/payment-sessions/$id/cancel',
data: const <String, dynamic>{},
);
} catch (_) {
// Best-effort — backend sweeper will expire stale `pending` rows
// after `payment_session_timeout_minutes` regardless.
}
}
/// Reset to initial — used when the screen is re-entered for a new attempt.
void reset() {
state = const PaymentInitialData();
}
String _humanError(DioException e, {required String fallback}) {
final code = e.response?.data?['error']?['code'] as String?;
final status = e.response?.statusCode;
if (status == 422 || code == 'VALIDATION_ERROR' || code == 'INVALID_TIER') {
return 'Pilihan durasi tidak valid.';
}
if (status == 403) return 'Sesi tidak diizinkan.';
if (status == 404) return 'Sesi pembayaran tidak ditemukan.';
if (code == 'EXPIRED') return 'Sesi pembayaran sudah kedaluwarsa.';
return fallback;
}
}
/// Mirror of backend `PaymentSessionStatus` for any UI that needs to inspect
/// the raw status field (kept tiny for now — most flows route via state above).
class PaymentStatus {
static const pending = PaymentSessionStatus.pending;
static const confirmed = PaymentSessionStatus.confirmed;
static const consumed = PaymentSessionStatus.consumed;
PaymentStatus._();
}

View File

@@ -1,25 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'payment_notifier.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$paymentHash() => r'd98f2e7e5045ea2a39b7af0d4a9f0601dd06ce74';
/// See also [Payment].
@ProviderFor(Payment)
final paymentProvider =
AutoDisposeNotifierProvider<Payment, PaymentSessionData>.internal(
Payment.new,
name: r'paymentProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$paymentHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$Payment = AutoDisposeNotifier<PaymentSessionData>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -26,7 +26,11 @@ class _PaymentEntryScreenState extends ConsumerState<PaymentEntryScreen> {
super.initState();
Future.microtask(() {
if (!mounted) return;
ref.read(paymentDraftNotifierProvider.notifier).reset();
// Targeting is set BEFORE this screen (by bestie-history-list) and must
// survive the entry-screen reset, so use resetExceptTarget() — full
// reset() would wipe targetedMitraId and silently downgrade the
// returning-targeted flow to a blast.
ref.read(paymentDraftNotifierProvider.notifier).resetExceptTarget();
});
}

View File

@@ -64,6 +64,11 @@ class _PaymentMethodScreenState extends ConsumerState<PaymentMethodScreen> {
'price_idr': draft.priceIDR,
'is_first_session_discount': draft.isFirstSessionDiscount,
'method': _selected.id,
// Returning-targeted "Curhat lagi" flow: backend ties the payment
// session to the picked mitra so the eventual chat request can fire
// against the same bestie. Absent on the general-blast path.
if (draft.targetedMitraId != null)
'targeted_mitra_id': draft.targetedMitraId,
};
// Trailing slash matches the existing payment_notifier path — Fastify
// is not configured with `ignoreTrailingSlash`.

View File

@@ -1,400 +0,0 @@
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)),
),
),
],
),
),
);
}
}

View File

@@ -1,4 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../core/constants.dart';
part 'payment_draft_provider.g.dart';
@@ -31,6 +33,21 @@ class PaymentDraft {
final String? paymentId;
final bool isFirstSessionDiscount;
/// When set, this payment is for a "Curhat lagi" (returning-targeted) flow:
/// downstream of payment confirm, `searching_screen` will fire the targeted
/// chat request against this specific mitra rather than the general blast.
/// Set by `BestieHistoryListScreen` BEFORE pushing `/payment/entry`.
final String? targetedMitraId;
/// Optional display name for the targeted mitra — surfaced on the targeted
/// waiting overlay ("lagi nungguin {name}") and any future returning-flow UI
/// that wants to greet the customer with the right bestie.
final String? targetedMitraName;
/// Topic-sensitivity choice made before entering the payment flow. Carried
/// through to the eventual chat-request API call. Defaults to `regular`.
final TopicSensitivity topicSensitivity;
const PaymentDraft({
this.mode = PaymentMode.chat,
this.durationId,
@@ -38,6 +55,9 @@ class PaymentDraft {
this.priceIDR,
this.paymentId,
this.isFirstSessionDiscount = false,
this.targetedMitraId,
this.targetedMitraName,
this.topicSensitivity = TopicSensitivity.regular,
});
PaymentDraft copyWith({
@@ -47,6 +67,9 @@ class PaymentDraft {
int? priceIDR,
String? paymentId,
bool? isFirstSessionDiscount,
String? targetedMitraId,
String? targetedMitraName,
TopicSensitivity? topicSensitivity,
}) {
return PaymentDraft(
mode: mode ?? this.mode,
@@ -55,6 +78,9 @@ class PaymentDraft {
priceIDR: priceIDR ?? this.priceIDR,
paymentId: paymentId ?? this.paymentId,
isFirstSessionDiscount: isFirstSessionDiscount ?? this.isFirstSessionDiscount,
targetedMitraId: targetedMitraId ?? this.targetedMitraId,
targetedMitraName: targetedMitraName ?? this.targetedMitraName,
topicSensitivity: topicSensitivity ?? this.topicSensitivity,
);
}
}
@@ -103,9 +129,45 @@ class PaymentDraftNotifier extends _$PaymentDraftNotifier {
state = state.copyWith(paymentId: paymentId);
}
/// Wipe the draft when entering the flow fresh (e.g. tapping "Mulai Curhat"
/// from home). Keeping it across back-nav inside the flow is the default.
/// Mark this draft as a targeted "Curhat lagi" flow against a specific
/// mitra. Must be called BEFORE pushing `/payment/entry` from the
/// bestie-history list — the entry screen calls [resetExceptTarget] to
/// clear stale tier/payment state while preserving the targeting set here.
void setTargetedMitra({required String mitraId, String? mitraName}) {
state = state.copyWith(
targetedMitraId: mitraId,
targetedMitraName: mitraName,
);
}
/// Set the topic-sensitivity choice for the upcoming chat request.
void setTopicSensitivity(TopicSensitivity topicSensitivity) {
state = state.copyWith(topicSensitivity: topicSensitivity);
}
/// Full reset — clears EVERYTHING including targeted-mitra intent.
/// Use this when starting a fresh BLAST flow (e.g. "bestie baru" branch or
/// the no-history Home CTA). If you want to preserve a targeted-mitra
/// selection made just before entering the payment flow (set via
/// [setTargetedMitra]), use [resetExceptTarget] instead.
void reset() {
debugPrint('[PaymentDraft] reset() — clearing entire draft including targeted-mitra intent');
state = const PaymentDraft();
}
/// Reset everything EXCEPT the targeted-mitra fields. Used by the payment
/// entry screen so a fresh dive into the multi-screen flow clears any stale
/// tier/payment state while preserving the just-picked targeted mitra. If
/// the draft has no targeted mitra set, this behaves identically to
/// [reset].
void resetExceptTarget() {
debugPrint(
'[PaymentDraft] resetExceptTarget() — preserving targetedMitraId=${state.targetedMitraId}',
);
state = PaymentDraft(
targetedMitraId: state.targetedMitraId,
targetedMitraName: state.targetedMitraName,
topicSensitivity: state.topicSensitivity,
);
}
}

View File

@@ -14,7 +14,6 @@ import 'features/splash/splash_screen.dart';
import 'features/home/home_screen.dart';
import 'features/home/screens/bestie_history_list_screen.dart';
import 'features/profile/profile_screen.dart';
import 'core/constants.dart';
import 'features/chat/screens/searching_screen.dart';
import 'features/chat/screens/bestie_found_screen.dart';
import 'features/chat/screens/no_bestie_screen.dart';
@@ -26,7 +25,6 @@ import 'features/chat_tab/screens/pembayaran_view.dart';
import 'features/chat_tab/screens/selesai_view.dart';
import 'features/chat/screens/targeted_waiting_screen.dart';
import 'features/chat/screens/thank_you_screen.dart';
import 'features/payment/screens/payment_screen.dart';
import 'features/payment/screens/payment_entry_screen.dart';
import 'features/payment/screens/discount_paywall_screen.dart';
import 'features/payment/screens/method_pick_screen.dart';
@@ -166,25 +164,6 @@ GoRouter buildRouter(Ref ref) {
),
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
GoRoute(path: '/profile', builder: (_, __) => const ProfileScreen()),
GoRoute(path: '/payment', builder: (context, state) {
// Legacy Phase 3.7 single-screen payment. Still reachable from
// - Home "Mulai Curhat" CTA → no extras (general blast follows confirm)
// - Chat history "Curhat lagi" CTA → extras carry targetedMitraId/mitraName
// for the returning-chat flow, plus optional topicSensitivity.
// Phase 4 Stage 3 introduces sibling routes under `/payment/*`; the new
// entry point is `/payment/entry`. This route is preserved until Stage 5
// migrates the chat-history "Curhat lagi" flow.
final extra = state.extra;
if (extra is Map<String, dynamic>) {
final topic = extra['topicSensitivity'];
return PaymentScreen(
targetedMitraId: extra['targetedMitraId'] as String?,
mitraName: extra['mitraName'] as String?,
topicSensitivity: topic is TopicSensitivity ? topic : TopicSensitivity.regular,
);
}
return const PaymentScreen();
}),
// Phase 4 Stage 3 — multi-screen payment shell.
GoRoute(path: '/payment/entry', builder: (_, __) => const PaymentEntryScreen()),
GoRoute(path: '/payment/discount-paywall', builder: (_, __) => const DiscountPaywallScreen()),