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:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
|
||||
@@ -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._();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user