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,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,
);
}
}