Six new screens under /payment/* + a paymentDraftProvider holding
mode/durationId/durationMinutes/priceIDR/paymentId/isFirstSessionDiscount
across the flow. PaymentEntryScreen handles the routing decision
(eligible+enabled -> /payment/discount-paywall, else /payment/method-pick)
and clears the draft on fresh entry.
Screens:
- discount_paywall_screen: S6 first-session discount with struck-through
gimmick price + actual price + 'mulai · Rp{actual}' CTA -> /payment/method
- method_pick_screen: chat vs call cards
- duration_pick_screen: tier list with chat|call mode toggle that resets
the selection on swap
- payment_method_screen: QRIS-first list, posts to existing
/api/client/payment-sessions with mode/duration/price/discount/method
- waiting_payment_screen: qr_flutter QR (encodes paymentId in mock mode),
20-min countdown header, 3s polling for status, pauses on background
via WidgetsBindingObserver
- payment_expired_screen: retry CTA -> /payment/method with draft retained
Status mapping: real payment_sessions.status uses 'confirmed'/'consumed'
for paid (not 'paid' as in plan) and 'expired'/'abandoned' as terminal.
home_screen 'Mulai Curhat' CTA now pushes /payment/entry.
Dev-only /internal/_test/force-expire-payment endpoint to drive Maestro
flow 04_payment_expired.yaml without waiting 20 minutes. Gated behind
NODE_ENV !== 'production'.
chat_opening_provider PricingData extended to carry Phase 4 chat/call
groups + firstSessionDiscount, back-compat with the Phase 3 shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
112 lines
3.1 KiB
Dart
112 lines
3.1 KiB
Dart
import 'package:riverpod_annotation/riverpod_annotation.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;
|
|
|
|
const PaymentDraft({
|
|
this.mode = PaymentMode.chat,
|
|
this.durationId,
|
|
this.durationMinutes,
|
|
this.priceIDR,
|
|
this.paymentId,
|
|
this.isFirstSessionDiscount = false,
|
|
});
|
|
|
|
PaymentDraft copyWith({
|
|
PaymentMode? mode,
|
|
String? durationId,
|
|
int? durationMinutes,
|
|
int? priceIDR,
|
|
String? paymentId,
|
|
bool? isFirstSessionDiscount,
|
|
}) {
|
|
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,
|
|
);
|
|
}
|
|
}
|
|
|
|
@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);
|
|
}
|
|
|
|
/// 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.
|
|
void reset() {
|
|
state = const PaymentDraft();
|
|
}
|
|
}
|