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

@@ -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,
),
),
],