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>
174 lines
5.9 KiB
Dart
174 lines
5.9 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|
import '../../../core/constants.dart';
|
|
|
|
part 'payment_draft_provider.g.dart';
|
|
|
|
/// Session mode the customer is paying for. Mirrors backend `SessionMode`
|
|
/// (chat | call). Phase 4 introduces `call` as a future option — pricing config
|
|
/// supplies the call tier list, but no functional voice feature is built yet.
|
|
enum PaymentMode {
|
|
chat('chat'),
|
|
call('call');
|
|
|
|
final String value;
|
|
const PaymentMode(this.value);
|
|
|
|
static PaymentMode fromString(String? v) =>
|
|
values.firstWhere((e) => e.value == v, orElse: () => PaymentMode.chat);
|
|
}
|
|
|
|
/// Draft state shared across the multi-screen payment flow:
|
|
/// discount-paywall / method-pick / duration-pick / method / waiting / expired.
|
|
///
|
|
/// The state is deliberately minimal — the *source of truth* for amount and
|
|
/// duration is always the backend (server-validated tier or first-session
|
|
/// discount). The draft only carries the customer's in-flight intent across
|
|
/// the screen graph.
|
|
class PaymentDraft {
|
|
final PaymentMode mode;
|
|
final String? durationId;
|
|
final int? durationMinutes;
|
|
final int? priceIDR;
|
|
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,
|
|
this.durationMinutes,
|
|
this.priceIDR,
|
|
this.paymentId,
|
|
this.isFirstSessionDiscount = false,
|
|
this.targetedMitraId,
|
|
this.targetedMitraName,
|
|
this.topicSensitivity = TopicSensitivity.regular,
|
|
});
|
|
|
|
PaymentDraft copyWith({
|
|
PaymentMode? mode,
|
|
String? durationId,
|
|
int? durationMinutes,
|
|
int? priceIDR,
|
|
String? paymentId,
|
|
bool? isFirstSessionDiscount,
|
|
String? targetedMitraId,
|
|
String? targetedMitraName,
|
|
TopicSensitivity? topicSensitivity,
|
|
}) {
|
|
return PaymentDraft(
|
|
mode: mode ?? this.mode,
|
|
durationId: durationId ?? this.durationId,
|
|
durationMinutes: durationMinutes ?? this.durationMinutes,
|
|
priceIDR: priceIDR ?? this.priceIDR,
|
|
paymentId: paymentId ?? this.paymentId,
|
|
isFirstSessionDiscount: isFirstSessionDiscount ?? this.isFirstSessionDiscount,
|
|
targetedMitraId: targetedMitraId ?? this.targetedMitraId,
|
|
targetedMitraName: targetedMitraName ?? this.targetedMitraName,
|
|
topicSensitivity: topicSensitivity ?? this.topicSensitivity,
|
|
);
|
|
}
|
|
}
|
|
|
|
@Riverpod(keepAlive: true)
|
|
class PaymentDraftNotifier extends _$PaymentDraftNotifier {
|
|
@override
|
|
PaymentDraft build() => const PaymentDraft();
|
|
|
|
void setMode(PaymentMode mode) {
|
|
// Switching mode resets the previously-picked tier — chat and call
|
|
// tier lists are independent.
|
|
state = state.copyWith(
|
|
mode: mode,
|
|
durationId: null,
|
|
durationMinutes: null,
|
|
priceIDR: null,
|
|
);
|
|
}
|
|
|
|
void setTier({
|
|
required String durationId,
|
|
required int durationMinutes,
|
|
required int priceIDR,
|
|
}) {
|
|
state = state.copyWith(
|
|
durationId: durationId,
|
|
durationMinutes: durationMinutes,
|
|
priceIDR: priceIDR,
|
|
);
|
|
}
|
|
|
|
void setDiscountPlan({
|
|
required int durationMinutes,
|
|
required int priceIDR,
|
|
}) {
|
|
state = state.copyWith(
|
|
mode: PaymentMode.chat,
|
|
durationMinutes: durationMinutes,
|
|
priceIDR: priceIDR,
|
|
isFirstSessionDiscount: true,
|
|
);
|
|
}
|
|
|
|
void setPaymentId(String paymentId) {
|
|
state = state.copyWith(paymentId: paymentId);
|
|
}
|
|
|
|
/// 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,
|
|
);
|
|
}
|
|
}
|