Phase 3.7: paid pairing flow + returning chat + extension flip

- Backend: payment_sessions + pairing_failures tables; payment.service.js
  and pairing-failure.service.js (new); rewritten pairing.service.js
  (payment-gated blast + targeted "Curhat lagi" + cancel + fallback);
  rewritten extension.service.js (data-driven auto-approve with offline
  safeguard, charge-at-approval); pricing.service.js (extension tiers
  without free trial); mitra-status.service.js (countAvailableMitras
  cached path); 60s sweeper for stale payment sessions
- Backend routes: client.payment.routes, client.mitra-availability.routes,
  internal/failed-pairings.routes; client.chat.routes rewritten for
  payment-gated start + /returning + /cancel + /fallback-to-blast;
  internal/config.routes adds 4 new keys with Valkey invalidate publish
- client_app: mitra-availability poll, payment screen + notifier, pairing
  notifier rewrite (PairingTargetedWaiting + PairingFailed states),
  targeted-waiting overlay + bestie-unavailable dialog, "Curhat lagi"
  CTA, failed-pairing terminal, extension via payment-session
- mitra_app: PairingRequestType enum, returning-chat 20s countdown
  auto-dismiss, extension card "otomatis disetujui" copy
- control_center: 4 new config rows in Settings, Failed Pairings page
  (filter + paginate + action menu), sidebar + route registered
- Test infrastructure: Vitest backend (7/7 pass), Playwright CC (4/4
  pass), Maestro mobile scaffold (CLI install pending)
- Bugs found via Playwright + fixed: LoginPage labels not associated
  with inputs (a11y); backend internal CORS missing PATCH/PUT/DELETE
  in allow-methods (silent settings breakage in browsers since Stage 4)
- Docs: phase3.7.md PRD, phase3.7-plan.md, phase3.7-questions.md (Q&A),
  phase3.7-testing.md (E2E checklist), phase3.7-test-run-2026-05-03.md
  (today's run results)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 23:02:49 +08:00
parent f3766813f3
commit d09e50af55
92 changed files with 9579 additions and 437 deletions

View File

@@ -0,0 +1,111 @@
# client_app Maestro flows
End-to-end UI automation for the customer Flutter app using [Maestro](https://maestro.mobile.dev). Single-emulator + curl-as-mitra pattern — the customer app is driven by real Maestro touches; the mitra side is simulated via backend API calls fired from `runScript` steps.
## One-time install
Maestro is a global CLI (not a project dependency). Install on your dev machine once:
```bash
curl -Ls "https://get.maestro.mobile.dev" | bash
```
Verify with `maestro --version`. See the [Maestro install docs](https://maestro.mobile.dev/getting-started/installing-maestro) for Homebrew / chocolatey / Docker alternatives.
You also need:
- `adb` on your PATH (comes with Android Studio's platform-tools)
- `jq` for the helper scripts (`apt install jq` / `brew install jq`)
- One Android emulator OR one connected device — **only one at a time** (per project decision)
## Folder layout
```
.maestro/
├── README.md # this file
├── config.yaml # shared env: app IDs, backend URL, test credentials
├── flows/ # the YAML test scripts
│ ├── 01_smoke.yaml
│ ├── 02_cta_disabled_when_no_mitra.yaml
│ └── 03_payment_to_chat_happy.yaml
└── scripts/ # bash helpers invoked by `runScript` steps
├── mitra_accept_latest.sh
└── force_all_mitras_offline.sh
```
## Configure for your environment
Edit `.maestro/config.yaml` and fill in:
- `BACKEND_URL` — must match the `--dart-define=API_BASE_URL=...` value the installed APK was built with
- `TEST_MITRA_ID` and `TEST_MITRA_JWT` — used by the curl harness to "accept" requests from the customer's blast
The config file is committed because the values are dev-environment defaults. Sensitive credentials (real JWTs, CC operator tokens) should be passed at runtime instead — see "Per-machine overrides" below.
## Run a flow
Single emulator (typical case — Maestro auto-picks the only attached device):
```bash
# from repo root or anywhere
maestro test client_app/.maestro/flows/01_smoke.yaml
# or run all flows in the directory
maestro test client_app/.maestro/flows/
```
If both an emulator and a real device happen to be connected, list them and pick one explicitly:
```bash
adb devices # list attached devices
maestro --device emulator-5554 test client_app/.maestro/flows/01_smoke.yaml
```
## Per-machine overrides
Override any config.yaml value at runtime with `--env`:
```bash
maestro test \
--env BACKEND_URL=http://192.168.99.10:3000 \
--env TEST_MITRA_JWT=eyJhbGc... \
client_app/.maestro/flows/03_payment_to_chat_happy.yaml
```
Or export shell variables — `runScript` steps inherit them:
```bash
export CC_JWT=eyJhbGc...
maestro test client_app/.maestro/flows/02_cta_disabled_when_no_mitra.yaml
```
## Single-emulator + curl pattern
Phase 3.7 flows often need a customer + a mitra acting in concert. Instead of running two emulators (RAM-heavy, flaky), the flows drive the customer side with Maestro and **simulate the mitra via backend curl calls**:
1. Maestro flow drives customer up to the "Mencari Bestie..." state
2. `runScript: ../scripts/mitra_accept_latest.sh` fires `POST /api/mitra/chat-requests/:id/accept` against the backend, using a pre-minted mitra JWT
3. Maestro flow asserts the customer screen transitions to "Bestie Ditemukan" via the WS round-trip
This works for ~90% of multi-actor scenarios — including all the Section D ("Curhat lagi") and Section J ("Mitra goes offline mid-session") tests in [phase3.7-testing.md](../../requirement/phase3.7-testing.md). The 10% that needs both UIs running (e.g., asserting the mitra-side overlay countdown displays correctly) is in [`mitra_app/.maestro/`](../../mitra_app/.maestro/) and runs separately.
## Adding a new flow
Pick a Phase 3.7 testing checklist scenario (see [phase3.7-testing.md](../../requirement/phase3.7-testing.md)), then:
1. Copy an existing flow as a template (e.g., `03_payment_to_chat_happy.yaml`)
2. Update the pre-req comment, the steps, and the assertions
3. If you need a "second actor" action, add a bash helper under `scripts/` and call it via `runScript:`
4. If you need new env vars, add them to `config.yaml` with sensible defaults
## Tips
- **Find the right text to tap on** — `maestro studio` opens a live UI inspector showing every visible label/widget. Run it while the app is on the screen you care about.
- **Slow it down for debugging** — `maestro test --debug-output ./debug flows/foo.yaml` saves screenshots + logs per step.
- **Add flows incrementally** — Maestro's reload-on-save in `maestro studio` makes iteration fast.
- **Don't commit screenshots / debug output** — add `.maestro/output/` and `.maestro/screenshots/` to `.gitignore` if you generate them locally.
## Troubleshooting
- `maestro: device not found` → run `adb devices`; if empty, start an emulator (`emulator -avd <name>`) or plug in a USB device with debugging enabled.
- `Element not visible` errors → use `maestro studio` to inspect actual labels — they may have changed since the flow was written.
- Flow hangs at `assertVisible` waiting for backend → check `BACKEND_URL` matches the APK's build-time value (`grep API_BASE_URL build.gradle`).
- `runScript` exits non-zero → run the script directly to see its error: `bash client_app/.maestro/scripts/mitra_accept_latest.sh`. Most often a missing env var or stale JWT.

View File

@@ -0,0 +1,25 @@
# Shared variables for all client_app Maestro flows.
#
# Override at runtime with `maestro test --env KEY=value` or by setting shell env vars.
# See README.md for full setup + per-machine overrides.
env:
# App identifiers — Android / iOS bundle IDs picked up automatically by `appId:` in flows.
APP_ID_ANDROID: com.halobestie.client.client_app
APP_ID_IOS: com.halobestie.client.clientApp
# Backend the app talks to — must match what the installed APK was built with
# (the `--dart-define=API_BASE_URL=...` value at build time).
BACKEND_URL: http://192.168.88.247:3000
BACKEND_INTERNAL_URL: http://192.168.88.247:3001
# Test customer credentials — must exist in the customers table on the target backend.
# These are read by helper scripts (see .maestro/scripts/) when seeding state.
CUSTOMER_PHONE: "+628100000001"
CUSTOMER_OTP: "123456" # OTP stub mode emits a known code per phone
# If you need to drive a "second actor" (e.g., the mitra accepting a blast), the test
# flows curl the backend directly using these credentials. See README §"Single-emulator
# + curl pattern" for details.
TEST_MITRA_ID: "REPLACE-WITH-A-REAL-MITRA-UUID"
TEST_MITRA_JWT: "REPLACE-WITH-A-VALID-MITRA-JWT"

View File

@@ -0,0 +1,14 @@
# Smoke test: launch the app and assert the home screen renders.
# Use this flow first to verify Maestro can talk to your device/emulator at all.
#
# Run:
# maestro test client_app/.maestro/flows/01_smoke.yaml
#
# Pre-req: client_app debug APK installed on the connected device, signed in as a customer.
appId: ${APP_ID_ANDROID} # Maestro uses APP_ID_IOS automatically when running against an iOS device
---
- launchApp:
clearState: false # keep existing auth — set to true to test cold-start onboarding
- assertVisible:
text: "Mulai Curhat"
timeout: 10000 # 10s — give Riverpod time to hydrate the home screen

View File

@@ -0,0 +1,21 @@
# Verifies the home CTA is disabled and shows the "no bestie available" subtitle
# when no mitra is online.
#
# Pre-req: NO mitras are online on the target backend. Use the helper script to
# force everyone offline before running:
# bash client_app/.maestro/scripts/force_all_mitras_offline.sh
#
# Run:
# maestro test client_app/.maestro/flows/02_cta_disabled_when_no_mitra.yaml
appId: ${APP_ID_ANDROID}
---
- launchApp:
clearState: false
- assertVisible: "Mulai Curhat"
- assertVisible: "Belum ada bestie tersedia"
# CTA is disabled — tapping it should be a no-op.
- tapOn: "Mulai Curhat"
# We should still be on the home screen, NOT on the payment screen.
- assertNotVisible: "Bayar"
- assertNotVisible: "Mulai"
- assertVisible: "Mulai Curhat"

View File

@@ -0,0 +1,42 @@
# Happy-path golden flow: customer taps CTA → payment screen → confirm →
# searching → mitra accepts (via curl harness) → chat screen.
#
# This is the canonical demonstration of the "single-emulator + curl-as-mitra"
# pattern. The customer side is real Maestro; the mitra side is a backend API call
# fired from a runScript step.
#
# Pre-req:
# 1. At least one mitra is ONLINE on the target backend
# 2. TEST_MITRA_ID and TEST_MITRA_JWT in .maestro/config.yaml point at that mitra
# 3. The mitra has spare capacity (active_session_count < max_customers_per_mitra)
#
# Run:
# maestro test client_app/.maestro/flows/03_payment_to_chat_happy.yaml
appId: ${APP_ID_ANDROID}
---
- launchApp:
clearState: false
- assertVisible: "Mulai Curhat"
# Step 1: customer taps CTA → payment screen
- tapOn: "Mulai Curhat"
- assertVisible:
text: "Bayar|Mulai" # "Bayar" for paid tier, "Mulai" for free trial
timeout: 5000
# Step 2: customer confirms payment → searching screen
- tapOn:
text: "Bayar|Mulai"
- assertVisible:
text: "Mencari Bestie"
timeout: 5000
# Step 3: simulate mitra accepting via backend API (the "curl-as-mitra" trick).
# This avoids needing a second emulator — the backend treats mitra interactions as
# REST calls regardless of whether they originate from the mitra app or a script.
- runScript: ../scripts/mitra_accept_latest.sh
# Step 4: customer screen transitions to "found" then chat
- assertVisible:
text: "Bestie Ditemukan"
timeout: 10000 # blast→accept→WS round-trip takes a few seconds

View File

@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Force every mitra offline on the target backend. Used as a pre-step for tests
# that verify the "no bestie available" disabled-CTA state.
#
# Reads BACKEND_INTERNAL_URL and a CC_JWT from the shell env (NOT from
# .maestro/config.yaml — CC credentials should never be committed).
#
# Usage:
# CC_JWT=<token> bash client_app/.maestro/scripts/force_all_mitras_offline.sh
set -euo pipefail
: "${BACKEND_INTERNAL_URL:=http://192.168.88.247:3001}"
: "${CC_JWT:?CC_JWT must be set (a valid control_center user JWT)}"
# Get the list of currently online mitras from the CC dashboard endpoint.
mitras=$(curl -fsSL "$BACKEND_INTERNAL_URL/internal/mitra-online-status" \
-H "Authorization: Bearer $CC_JWT" \
| jq -r '.data[] | select(.is_online == true) | .mitra_id')
if [ -z "$mitras" ]; then
echo "All mitras already offline."
exit 0
fi
for mitra_id in $mitras; do
echo "Forcing $mitra_id offline..."
curl -fsSL -X POST "$BACKEND_INTERNAL_URL/internal/mitra-online-status/$mitra_id/offline" \
-H "Authorization: Bearer $CC_JWT" \
-H "Content-Type: application/json" \
-d '{}' || echo " (failed — endpoint may not exist; check route name)"
done
echo "Done."

View File

@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Find the most recent pending chat_session for the test mitra and accept it via
# the backend API. Used by Maestro flows that drive the customer side and need a
# mitra to "accept" without running a second app.
#
# Reads from .maestro/config.yaml env (BACKEND_URL, TEST_MITRA_ID, TEST_MITRA_JWT).
# Maestro injects these as shell env vars before running this script.
set -euo pipefail
: "${BACKEND_URL:?BACKEND_URL must be set in .maestro/config.yaml}"
: "${TEST_MITRA_ID:?TEST_MITRA_ID must be set in .maestro/config.yaml}"
: "${TEST_MITRA_JWT:?TEST_MITRA_JWT must be set in .maestro/config.yaml}"
# Poll for a pending request (blast may take 1-2 seconds to arrive)
for i in 1 2 3 4 5; do
pending=$(curl -fsSL "$BACKEND_URL/api/mitra/chat-requests/pending" \
-H "Authorization: Bearer $TEST_MITRA_JWT" 2>/dev/null || echo '{"data":[]}')
session_id=$(echo "$pending" | jq -r '.data[0].session_id // empty')
if [ -n "$session_id" ]; then
break
fi
sleep 1
done
if [ -z "${session_id:-}" ]; then
echo "ERROR: no pending chat request found for mitra $TEST_MITRA_ID after 5s" >&2
exit 1
fi
echo "Accepting session $session_id as mitra $TEST_MITRA_ID..."
curl -fsSL -X POST "$BACKEND_URL/api/mitra/chat-requests/$session_id/accept" \
-H "Authorization: Bearer $TEST_MITRA_JWT" \
-H "Content-Type: application/json" \
-d '{}'
echo "OK"

View File

@@ -6,7 +6,7 @@ part of 'auth_notifier.dart';
// RiverpodGenerator
// **************************************************************************
String _$authHash() => r'601e614f3297fb679f5baa893932a43ae981eb9d';
String _$authHash() => r'f0382b1380749cebe8182ad221023926768ebb92';
/// See also [Auth].
@ProviderFor(Auth)

View File

@@ -0,0 +1,78 @@
import 'dart:async';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../api/api_client_provider.dart';
part 'mitra_availability_notifier.g.dart';
/// Customer-home availability poll.
///
/// Polls `GET /api/client/mitra-availability` every 5 seconds while the home
/// screen is in the foreground. Polling is gated by the home screen calling
/// [setActive] from `WidgetsBindingObserver.didChangeAppLifecycleState`:
/// - resumed → setActive(true)
/// - paused/inactive → setActive(false)
///
/// On any HTTP error we emit `false` (never display stale state).
///
/// The endpoint also returns a `count`, but the customer UI must only read the
/// binary `available` field — the count is for CC/debug only.
@Riverpod(keepAlive: true)
class MitraAvailability extends _$MitraAvailability {
Timer? _pollTimer;
bool _active = false;
@override
Future<bool> build() async {
ref.onDispose(_stopPolling);
// Default to disabled until the first poll returns. Never optimistically
// show the CTA as enabled.
return false;
}
/// Called by the home screen via `WidgetsBindingObserver` to gate polling
/// to the foregrounded state. Polling is paused on `paused` / `inactive`
/// and resumed on `resumed` (and an immediate poll fires on resume).
void setActive(bool active) {
if (_active == active) return;
_active = active;
if (_active) {
_startPolling();
// Fire-and-forget an immediate poll on resume so the CTA reflects
// current availability without waiting up to 5s.
// ignore: unawaited_futures
_pollOnce();
} else {
_stopPolling();
}
}
/// Manual one-shot refresh — used for pull-to-refresh on the home screen.
Future<void> refresh() => _pollOnce();
void _startPolling() {
_stopPolling();
_pollTimer = Timer.periodic(const Duration(seconds: 5), (_) => _pollOnce());
}
void _stopPolling() {
_pollTimer?.cancel();
_pollTimer = null;
}
Future<void> _pollOnce() async {
bool available;
try {
final api = ref.read(apiClientProvider);
final response = await api.get('/api/client/mitra-availability');
final data = response['data'] as Map<String, dynamic>?;
available = data?['available'] as bool? ?? false;
} catch (_) {
// Poll failure → default to disabled. Never keep the last-known state.
available = false;
}
// Skip the assignment when the value didn't change — Riverpod allocates a
// new AsyncData each call, which would re-notify all listeners every 5s.
if (state.valueOrNull == available) return;
state = AsyncData(available);
}
}

View File

@@ -0,0 +1,39 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'mitra_availability_notifier.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$mitraAvailabilityHash() => r'7429862ccffbae1fc7bbe7368359e1624fe28ec9';
/// Phase 3.7 §1: customer-home availability poll.
///
/// Polls `GET /api/client/mitra-availability` every 5 seconds while the home
/// screen is in the foreground. Polling is gated by the home screen calling
/// [setActive] from `WidgetsBindingObserver.didChangeAppLifecycleState`:
/// - resumed → setActive(true)
/// - paused/inactive → setActive(false)
///
/// On any HTTP error we emit `false` (PRD §1.3: never display stale state).
///
/// The endpoint also returns a `count`, but per PRD §1.3 the customer UI must
/// only read the binary `available` field — the count is for CC/debug only.
///
/// Copied from [MitraAvailability].
@ProviderFor(MitraAvailability)
final mitraAvailabilityProvider =
AsyncNotifierProvider<MitraAvailability, bool>.internal(
MitraAvailability.new,
name: r'mitraAvailabilityProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$mitraAvailabilityHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$MitraAvailability = AsyncNotifier<bool>;
// 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

View File

@@ -6,7 +6,7 @@ part of 'chat_notifier.dart';
// RiverpodGenerator
// **************************************************************************
String _$chatHash() => r'30e0aa41d2022e5bb080877e23fcb0a0c0c4b927';
String _$chatHash() => r'35388d2977db9cee4307697524620b22691c54f5';
/// See also [Chat].
@ProviderFor(Chat)

View File

@@ -1,5 +1,4 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../api/api_client_provider.dart';
import 'active_session_notifier.dart';
@@ -41,14 +40,42 @@ class SessionClosure extends _$SessionClosure {
@override
SessionClosureData build() => const ClosureInitialData();
/// Extension request is a 3-step flow with the extension cost held in its
/// own `payment_session` (never combined with a free trial). Server-side,
/// the extension service refuses requests without an
/// `extension_payment_session_id` on a confirmed, is_extension payment session.
///
/// 1. POST `/api/client/payment-sessions` with `is_extension: true`
/// 2. POST `/api/client/payment-sessions/:id/confirm`
/// 3. POST `/api/client/chat/session/:sessionId/extend` with the
/// extension_payment_session_id from step 2.
///
/// Charge timing is server-side: only on actual approve / auto-approve.
/// If the mitra explicitly rejects within 10s the payment is failed back, no charge.
Future<void> requestExtension(String sessionId, {required int durationMinutes, required int price}) async {
state = const ExtendingWaitingMitraData();
try {
await ref.read(apiClientProvider).post('/api/client/chat/session/$sessionId/extend', data: {
final api = ref.read(apiClientProvider);
final createResp = await api.post('/api/client/payment-sessions/', data: {
'duration_minutes': durationMinutes,
'is_extension': true,
});
final paymentSessionId = (createResp['data'] as Map<String, dynamic>)['id'] as String;
// Backend rejects truly empty bodies on confirm, so always send `{}`.
await api.post(
'/api/client/payment-sessions/$paymentSessionId/confirm',
data: const <String, dynamic>{},
);
// Trigger the extension request. The actual approve/reject round-trip is
// owned by the chat WS — ChatNotifier surfaces it.
await api.post('/api/client/chat/session/$sessionId/extend', data: {
'duration_minutes': durationMinutes,
'price': price,
'extension_payment_session_id': paymentSessionId,
});
// Response will come via WebSocket (ChatBloc/ChatNotifier handles it)
} catch (e) {
state = const ClosureErrorData('Gagal meminta perpanjangan.');
}

View File

@@ -9,6 +9,17 @@ String formatCountdown(int totalSeconds) {
return '${minutes}m ${seconds}d';
}
/// Format an integer rupiah amount with dot thousand-separators: 1234567 → "Rp 1.234.567".
String formatRupiah(int amount) {
final str = amount.toString();
final buffer = StringBuffer();
for (var i = 0; i < str.length; i++) {
if (i > 0 && (str.length - i) % 3 == 0) buffer.write('.');
buffer.write(str[i]);
}
return 'Rp $buffer';
}
/// User types
class UserType {
static const customer = 'customer';
@@ -105,5 +116,44 @@ class WsMessage {
// Early end
static const earlyEnd = 'early_end';
// Returning-chat (intermediate failures — payment stays confirmed)
static const returningChatTimeout = 'returning_chat_timeout';
static const returningChatRejected = 'returning_chat_rejected';
// Terminal pairing failure on a confirmed payment session
static const pairingFailed = 'pairing_failed';
WsMessage._();
}
/// Pairing-failure cause tags. Mirror of backend
/// `PairingFailureCause` (see backend/src/constants.js). Use for both routing
/// (terminal vs. intermediate) and surfacing copy on the failed-pairing screen.
enum PairingFailureCause {
noMitraAvailable('no_mitra_available'),
allMitrasRejected('all_mitras_rejected'),
targetedMitraOffline('targeted_mitra_offline'),
targetedMitraRejected('targeted_mitra_rejected'),
targetedMitraTimeout('targeted_mitra_timeout'),
paymentSessionExpired('payment_session_expired'),
customerCancelled('customer_cancelled'),
unknown('unknown');
final String value;
const PairingFailureCause(this.value);
static PairingFailureCause fromString(String? v) =>
values.firstWhere((e) => e.value == v, orElse: () => PairingFailureCause.unknown);
}
/// Payment session lifecycle. Mirror of backend
/// `PaymentSessionStatus`.
class PaymentSessionStatus {
static const pending = 'pending';
static const confirmed = 'confirmed';
static const consumed = 'consumed';
static const failedPairing = 'failed_pairing';
static const abandoned = 'abandoned';
static const expired = 'expired';
PaymentSessionStatus._();
}

View File

@@ -20,9 +20,55 @@ class PairingInitialData extends PairingData {
const PairingInitialData();
}
/// General-blast in flight. The chat_session row exists; backend has already
/// notified all available mitras and is waiting for the first to accept.
class PairingSearchingData extends PairingData {
/// chat_session id (NOT payment_session id).
final String sessionId;
const PairingSearchingData(this.sessionId);
/// payment_session id — we keep it on the state so cancelSearch can call
/// the payment-session-scoped cancel endpoint without re-prompting.
final String paymentSessionId;
const PairingSearchingData({
required this.sessionId,
required this.paymentSessionId,
});
}
/// "Curhat lagi" 20s wait. The targeted-mitra request has been created and
/// we're waiting for either accept (→ paired), reject/timeout (→ bestie-unavailable
/// popup), or customer cancel (→ home).
///
/// `secondsRemaining` is decremented locally for the overlay countdown. The
/// server is the source of truth for the actual auto-reject; the local timer
/// is purely cosmetic.
class PairingTargetedWaitingData extends PairingData {
final String paymentSessionId;
final String mitraId;
final String mitraName;
final int secondsRemaining;
// Carried so the fallback-to-blast path preserves the customer's original choice
// — otherwise sensitive sessions silently get re-routed as regular.
final TopicSensitivity topicSensitivity;
const PairingTargetedWaitingData({
required this.paymentSessionId,
required this.mitraId,
required this.mitraName,
required this.secondsRemaining,
required this.topicSensitivity,
});
PairingTargetedWaitingData copyWith({int? secondsRemaining}) {
return PairingTargetedWaitingData(
paymentSessionId: paymentSessionId,
mitraId: mitraId,
mitraName: mitraName,
secondsRemaining: secondsRemaining ?? this.secondsRemaining,
topicSensitivity: topicSensitivity,
);
}
}
class PairingBestieFoundData extends PairingData {
@@ -37,8 +83,35 @@ class PairingActiveData extends PairingData {
const PairingActiveData({required this.sessionId, required this.mitraName});
}
class PairingNoBestieData extends PairingData {
const PairingNoBestieData();
/// Intermediate fail signalled by RETURNING_CHAT_TIMEOUT or RETURNING_CHAT_REJECTED,
/// or by a 409 `targeted_mitra_offline` at request time. Payment session is still
/// `confirmed` server-side — the customer can choose between fallback-to-blast
/// (general blast on the same payment) or going back home (which will leave the
/// payment to expire, no double-charge).
///
/// The UI surfaces this via the bestie-unavailable dialog.
class PairingTargetedUnavailableData extends PairingData {
final String paymentSessionId;
final String mitraName;
final PairingFailureCause cause;
// Carried so the fallback-to-blast call preserves the customer's original choice.
final TopicSensitivity topicSensitivity;
const PairingTargetedUnavailableData({
required this.paymentSessionId,
required this.mitraName,
required this.cause,
required this.topicSensitivity,
});
}
/// Terminal pairing failure — payment session is in `failed_pairing`. Routes
/// to the failed-pairing screen (no_bestie_screen).
class PairingFailedData extends PairingData {
final PairingFailureCause cause;
final String? paymentSessionId;
const PairingFailedData({required this.cause, this.paymentSessionId});
}
class PairingCancelledData extends PairingData {
@@ -52,7 +125,7 @@ class PairingErrorData extends PairingData {
@Riverpod(keepAlive: true)
class Pairing extends _$Pairing {
Timer? _timeoutTimer;
Timer? _localCountdownTimer;
WebSocketChannel? _channel;
StreamSubscription? _wsSubscription;
@@ -61,58 +134,176 @@ class Pairing extends _$Pairing {
@override
PairingData build() => const PairingInitialData();
Future<void> requestPairing({required TopicSensitivity topicSensitivity}) async {
await _doPairingRequest({'topic_sensitivity': topicSensitivity.value});
}
Future<void> requestPairingWithTier({
int? durationMinutes,
int? price,
bool isFreeTrial = false,
/// General-blast against a confirmed payment session.
/// Returns once the chat_session row is created server-side; subsequent
/// transitions (paired / pairing_failed) arrive via WS.
Future<void> startSearch({
required String paymentSessionId,
required TopicSensitivity topicSensitivity,
}) async {
final body = <String, dynamic>{'topic_sensitivity': topicSensitivity.value};
if (isFreeTrial) {
body['is_free_trial'] = true;
} else {
body['duration_minutes'] = durationMinutes;
body['price'] = price;
}
await _doPairingRequest(body);
}
Future<void> _doPairingRequest(Map<String, dynamic> body) async {
if (state is! PairingInitialData) {
state = const PairingInitialData();
}
state = const PairingInitialData();
try {
await _connectWebSocket();
final response = await _apiClient.post('/api/client/chat/request', data: body);
final response = await _apiClient.post(
'/api/client/chat/request',
data: {
'payment_session_id': paymentSessionId,
'topic_sensitivity': topicSensitivity.value,
},
);
final data = response['data'] as Map<String, dynamic>;
final sessionId = data['id'] as String;
state = PairingSearchingData(sessionId);
_timeoutTimer = Timer(const Duration(seconds: 60), () {
_cleanup();
state = const PairingNoBestieData();
});
state = PairingSearchingData(
sessionId: sessionId,
paymentSessionId: paymentSessionId,
);
} on DioException catch (e) {
_cleanup();
final code = e.response?.data?['error']?['code'];
if (code == 'NO_MITRA_AVAILABLE') {
state = const PairingNoBestieData();
// Backend already failed the payment in this case — terminal.
state = PairingFailedData(
cause: PairingFailureCause.noMitraAvailable,
paymentSessionId: paymentSessionId,
);
} else if (code == 'ALREADY_ACTIVE') {
state = const PairingErrorData('Kamu sudah memiliki sesi aktif.');
} else if (code == 'FREE_TRIAL_INELIGIBLE') {
state = const PairingErrorData('Kamu tidak memenuhi syarat untuk free trial.');
} else {
state = const PairingErrorData('Gagal memulai. Coba lagi.');
}
}
}
/// Targeted "Curhat lagi" against a specific mitra. The backend creates a
/// single-recipient notification + 20s server-side timer. Locally we run a
/// cosmetic countdown for the overlay.
///
/// On 409 `targeted_mitra_offline`: backend recorded an audit-only failure
/// row (payment stays confirmed) — we transition to TargetedUnavailable so
/// the UI can offer the fallback dialog.
Future<void> startTargetedSearch({
required String paymentSessionId,
required String mitraId,
required String mitraName,
required TopicSensitivity topicSensitivity,
}) async {
state = const PairingInitialData();
try {
await _connectWebSocket();
final response = await _apiClient.post(
'/api/client/chat/chat-requests/returning',
data: {
'payment_session_id': paymentSessionId,
'mitra_id': mitraId,
'topic_sensitivity': topicSensitivity.value,
},
);
// Backend returns the configured returning_chat_confirmation_timeout_seconds so
// the overlay countdown matches the server-side timer exactly.
final sessionData = response['data'] as Map<String, dynamic>?;
final seconds = (sessionData?['confirmation_timeout_seconds'] as num?)?.toInt() ?? 20;
state = PairingTargetedWaitingData(
paymentSessionId: paymentSessionId,
mitraId: mitraId,
mitraName: mitraName,
secondsRemaining: seconds,
topicSensitivity: topicSensitivity,
);
_startLocalCountdown();
} on DioException catch (e) {
_cleanup();
final code = e.response?.data?['error']?['code'];
final reason = e.response?.data?['error']?['reason'];
if (code == 'TARGETED_MITRA_OFFLINE' || reason == 'targeted_mitra_offline') {
// Intermediate — payment session is still confirmed; show the
// bestie-unavailable popup with a "Chat dengan bestie lain" option.
state = PairingTargetedUnavailableData(
paymentSessionId: paymentSessionId,
mitraName: mitraName,
cause: PairingFailureCause.targetedMitraOffline,
topicSensitivity: topicSensitivity,
);
} else if (code == 'ALREADY_ACTIVE') {
state = const PairingErrorData('Kamu sudah memiliki sesi aktif.');
} else {
state = const PairingErrorData('Gagal memulai. Coba lagi.');
}
}
}
/// Customer-initiated cancel during a search/wait. Terminal — payment
/// session moves to `failed_pairing` server-side. We route the UI to home
/// (NOT to the failed-pairing screen) since the customer chose this.
Future<void> cancelSearch() async {
String? paymentSessionId;
final current = state;
if (current is PairingSearchingData) {
paymentSessionId = current.paymentSessionId;
} else if (current is PairingTargetedWaitingData) {
paymentSessionId = current.paymentSessionId;
}
if (paymentSessionId == null) {
_cleanup();
state = const PairingCancelledData();
return;
}
try {
await _apiClient.post(
'/api/client/chat/chat-requests/cancel',
data: {'payment_session_id': paymentSessionId},
);
} catch (_) {
// Best-effort. Backend will still fail the payment if/when it
// sweeps stale rows.
}
_cleanup();
state = const PairingCancelledData();
}
/// "Chat dengan bestie lain" tapped from the bestie-unavailable dialog.
/// Reuses the same payment session — backend transitions back into the
/// general-blast path.
Future<void> fallbackToBlast({
required String paymentSessionId,
required TopicSensitivity topicSensitivity,
}) async {
state = const PairingInitialData();
try {
await _connectWebSocket();
final response = await _apiClient.post(
'/api/client/chat/chat-requests/$paymentSessionId/fallback-to-blast',
data: {'topic_sensitivity': topicSensitivity.value},
);
final data = response['data'] as Map<String, dynamic>;
final sessionId = data['id'] as String;
state = PairingSearchingData(
sessionId: sessionId,
paymentSessionId: paymentSessionId,
);
} on DioException catch (e) {
_cleanup();
final code = e.response?.data?['error']?['code'];
if (code == 'NO_MITRA_AVAILABLE') {
state = PairingFailedData(
cause: PairingFailureCause.noMitraAvailable,
paymentSessionId: paymentSessionId,
);
} else {
state = const PairingErrorData('Gagal memulai. Coba lagi.');
}
}
}
/// Reset back to initial — used when the failed-pairing screen "Kembali ke
/// beranda" CTA is tapped, or when the bestie-unavailable dialog is
/// dismissed via "Kembali".
void reset() {
_cleanup();
state = const PairingInitialData();
}
// ---- Internal ---------------------------------------------------------
Future<void> _connectWebSocket() async {
_closeWebSocket();
final token = ref.read(authBridgeProvider).accessToken;
@@ -128,7 +319,7 @@ class Pairing extends _$Pairing {
(raw) {
final data = jsonDecode(raw as String) as Map<String, dynamic>;
if (data['type'] == WsMessage.authOk) return;
_onStatusUpdate(data);
_onWsEvent(data);
},
onError: (_) {},
onDone: () {},
@@ -140,42 +331,89 @@ class Pairing extends _$Pairing {
}));
}
Future<void> _onStatusUpdate(Map<String, dynamic> data) async {
Future<void> _onWsEvent(Map<String, dynamic> data) async {
final type = data['type'] as String?;
final current = state;
if (type == WsMessage.paired) {
_cleanup();
final mitraName = data['mitra_display_name'] as String? ?? 'Bestie';
final sessionId = data['session_id'] as String;
state = PairingBestieFoundData(sessionId: sessionId, mitraName: mitraName);
// A session now exists for this customer — refresh the shared snapshot
// so the home CTA reflects it immediately when the user returns.
// ignore: unawaited_futures
ref.read(activeSessionProvider.notifier).refresh();
await Future.delayed(const Duration(seconds: 2));
state = PairingActiveData(sessionId: sessionId, mitraName: mitraName);
} else if (type == SessionStatus.expired) {
return;
}
if (type == WsMessage.pairingFailed) {
// Terminal — payment_session is in failed_pairing server-side.
final causeTag = data['cause_tag'] as String?;
final paymentSessionId = data['payment_session_id'] as String?;
_cleanup();
state = const PairingNoBestieData();
state = PairingFailedData(
cause: PairingFailureCause.fromString(causeTag),
paymentSessionId: paymentSessionId,
);
return;
}
if (type == WsMessage.returningChatTimeout || type == WsMessage.returningChatRejected) {
// Intermediate — payment still confirmed. Show the bestie-unavailable
// dialog (UI surfaces via state listener).
_stopLocalCountdown();
final paymentSessionId = data['payment_session_id'] as String?;
// Pull mitra name + topic from the prior targeted-waiting state (we know it from
// the request payload). If we somehow lost it, fall back to safe defaults.
String mitraName = 'Bestie';
TopicSensitivity carriedTopic = TopicSensitivity.regular;
if (current is PairingTargetedWaitingData) {
mitraName = current.mitraName;
carriedTopic = current.topicSensitivity;
}
state = PairingTargetedUnavailableData(
paymentSessionId: paymentSessionId ?? (current is PairingTargetedWaitingData ? current.paymentSessionId : ''),
mitraName: mitraName,
topicSensitivity: carriedTopic,
cause: type == WsMessage.returningChatTimeout
? PairingFailureCause.targetedMitraTimeout
: PairingFailureCause.targetedMitraRejected,
);
return;
}
if (type == SessionStatus.expired) {
// Legacy event from the older pairing path — treat as terminal "no mitra".
_cleanup();
state = const PairingFailedData(cause: PairingFailureCause.noMitraAvailable);
}
}
Future<void> cancelPairing() async {
if (state is PairingSearchingData) {
final sessionId = (state as PairingSearchingData).sessionId;
try {
await _apiClient.post('/api/client/chat/request/$sessionId/cancel');
} catch (_) {}
_cleanup();
state = const PairingCancelledData();
}
void _startLocalCountdown() {
_stopLocalCountdown();
_localCountdownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
final current = state;
if (current is! PairingTargetedWaitingData) {
_stopLocalCountdown();
return;
}
final next = current.secondsRemaining - 1;
if (next <= 0) {
_stopLocalCountdown();
// We don't transition here — the server is the source of truth for
// the actual auto-reject. The WS event will land within ~1s and
// transition us to TargetedUnavailable.
state = current.copyWith(secondsRemaining: 0);
} else {
state = current.copyWith(secondsRemaining: next);
}
});
}
void reset() {
_cleanup();
state = const PairingInitialData();
void _stopLocalCountdown() {
_localCountdownTimer?.cancel();
_localCountdownTimer = null;
}
void _closeWebSocket() {
@@ -186,8 +424,7 @@ class Pairing extends _$Pairing {
}
void _cleanup() {
_timeoutTimer?.cancel();
_timeoutTimer = null;
_stopLocalCountdown();
_closeWebSocket();
}
}

View File

@@ -6,7 +6,7 @@ part of 'pairing_notifier.dart';
// RiverpodGenerator
// **************************************************************************
String _$pairingHash() => r'e1c3074239cc4efda99885331ff91b3e0f903c8d';
String _$pairingHash() => r'0fe1e5646fe70b90f5489919ec8dd557c998daad';
/// See also [Pairing].
@ProviderFor(Pairing)

View File

@@ -2,7 +2,17 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/api/api_client_provider.dart';
import '../../../core/constants.dart';
/// Chat history with per-row "Curhat lagi" CTA.
///
/// Tapping "Curhat lagi" routes to the payment screen with the targeted
/// mitra id + display name as extras. The payment screen then:
/// 1. POSTs `/api/client/payment-sessions` with `targeted_mitra_id`
/// 2. On confirm, fires `pairingNotifier.startTargetedSearch(...)` instead
/// of the general `startSearch(...)`.
///
/// The CTA is per-row (not per-unique-mitra).
class ChatHistoryScreen extends ConsumerStatefulWidget {
const ChatHistoryScreen({super.key});
@@ -34,6 +44,19 @@ class _ChatHistoryScreenState extends ConsumerState<ChatHistoryScreen> {
}
}
void _onCurhatLagiPressed(Map<String, dynamic> session) {
// The mitra id field on the history payload is `mitra_id` per existing
// backend convention. If absent (older rows), don't render the CTA.
final mitraId = session['mitra_id'] as String?;
if (mitraId == null) return;
final mitraName = session['mitra_display_name'] as String? ?? 'Bestie';
context.push('/payment', extra: <String, dynamic>{
'targetedMitraId': mitraId,
'mitraName': mitraName,
'topicSensitivity': TopicSensitivity.regular,
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -42,11 +65,13 @@ class _ChatHistoryScreenState extends ConsumerState<ChatHistoryScreen> {
? const Center(child: CircularProgressIndicator())
: _sessions.isEmpty
? const Center(child: Text('Belum ada riwayat chat'))
: ListView.builder(
: ListView.separated(
itemCount: _sessions.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final s = _sessions[index];
final sessionId = s['id'] as String;
final mitraId = s['mitra_id'] as String?;
final mitraName = s['mitra_display_name'] as String? ?? 'Bestie';
final status = s['status'] as String?;
final isClosing = status == 'closing';
@@ -72,7 +97,18 @@ class _ChatHistoryScreenState extends ConsumerState<ChatHistoryScreen> {
if (duration != null) '$duration menit',
if (closureMsg != null) '"$closureMsg"',
].join(' - ')),
trailing: const Icon(Icons.chevron_right),
// Curhat-lagi CTA renders inline; transcript view is
// still reachable by tapping the row body (or, for
// closing sessions, the active chat — same as before).
trailing: !isClosing && mitraId != null
? OutlinedButton(
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
),
onPressed: () => _onCurhatLagiPressed(s),
child: const Text('Curhat lagi'),
)
: const Icon(Icons.chevron_right),
onTap: () => isClosing
? context.push('/chat/session/$sessionId', extra: mitraName)
: context.push('/chat/history/$sessionId'),

View File

@@ -1,36 +1,64 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/pairing/pairing_notifier.dart';
class NoBestieScreen extends StatelessWidget {
/// Terminal failed-pairing screen.
///
/// Reached when the pairing notifier transitions to [PairingFailedData]
/// (terminal — payment session is `failed_pairing` server-side, audit row
/// recorded). Copy is intentionally identical regardless of `cause_tag` for
/// now (the design pass will revise this later).
///
/// Single CTA "Kembali ke beranda" resets the pairing notifier and routes
/// home. PopScope falls back to home for deep-link entry per project memory
/// rule "Deep-link pop fallback".
class NoBestieScreen extends ConsumerWidget {
const NoBestieScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.sentiment_dissatisfied, size: 80, color: Colors.orange),
const SizedBox(height: 24),
const Text(
'Bestie belum tersedia',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
Widget build(BuildContext context, WidgetRef ref) {
return PopScope(
canPop: true,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) return;
ref.read(pairingProvider.notifier).reset();
},
child: Scaffold(
body: SafeArea(
child: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.sentiment_dissatisfied, size: 80, color: Colors.orange),
const SizedBox(height: 24),
const Text(
'Belum berhasil terhubung',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
const Text(
'Maaf, kami tidak bisa menemukan bestie untuk sesimu. '
'Tim kami akan menghubungimu segera.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, color: Colors.grey),
),
const SizedBox(height: 48),
ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14),
),
onPressed: () {
ref.read(pairingProvider.notifier).reset();
context.go('/home');
},
child: const Text('Kembali ke beranda'),
),
],
),
const SizedBox(height: 8),
const Text(
'Maaf, semua Bestie sedang sibuk. Coba lagi nanti ya.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, color: Colors.grey),
),
const SizedBox(height: 48),
ElevatedButton(
onPressed: () => context.go('/home'),
child: const Text('Kembali'),
),
],
),
),
),
),

View File

@@ -2,51 +2,170 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/pairing/pairing_notifier.dart';
import '../widgets/bestie_unavailable_dialog.dart';
import '../widgets/targeted_waiting_overlay.dart';
class SearchingScreen extends ConsumerWidget {
/// Searching screen, also responsible for routing all downstream pairing
/// transitions:
///
/// - PairingTargetedWaitingData → render the targeted waiting overlay above
/// the searching shell (the customer sees the 20s countdown + cancel CTA).
/// - PairingTargetedUnavailableData → show the bestie-unavailable dialog
/// (intermediate; payment stays confirmed; offers fallback-to-blast).
/// - PairingFailedData → terminal; route to no-bestie screen.
/// - PairingBestieFoundData → existing transition to bestie-found screen.
/// - PairingCancelledData → customer cancelled; back home.
///
/// Per project memory ("Riverpod ref.listen in build is unsafe"), we use
/// ref.listenManual in initState for one-shot side effects rather than
/// build-scoped listeners.
class SearchingScreen extends ConsumerStatefulWidget {
const SearchingScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.listen(pairingProvider, (prev, next) {
if (next is PairingBestieFoundData) {
context.go('/chat/found', extra: {
'sessionId': next.sessionId,
'mitraName': next.mitraName,
});
} else if (next is PairingNoBestieData) {
context.go('/chat/no-bestie');
} else if (next is PairingCancelledData) {
context.go('/home');
}
ConsumerState<SearchingScreen> createState() => _SearchingScreenState();
}
class _SearchingScreenState extends ConsumerState<SearchingScreen> {
/// Guard against re-firing the bestie-unavailable dialog if the notifier
/// briefly emits multiple intermediate states (e.g. WS event arrives just
/// after a 409 already opened the dialog).
bool _unavailableDialogShown = false;
@override
void initState() {
super.initState();
ref.listenManual<PairingData>(pairingProvider, _onPairingState);
// The pairing state can already be PairingTargetedUnavailableData by
// the time we mount (the payment screen awaits startTargetedSearch
// before navigating; a 409 lands while we're still on the previous
// screen). Inspect once after first frame to handle that case.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_onPairingState(null, ref.read(pairingProvider));
});
}
void _onPairingState(PairingData? prev, PairingData next) {
if (!mounted) return;
if (next is PairingBestieFoundData) {
context.go('/chat/found', extra: {
'sessionId': next.sessionId,
'mitraName': next.mitraName,
});
return;
}
if (next is PairingActiveData) {
// Direct route into the active chat — happens after the brief "found"
// animation if the user is already on this screen.
context.go('/chat/session/${next.sessionId}', extra: next.mitraName);
return;
}
if (next is PairingFailedData) {
// Terminal — payment_session is failed_pairing.
context.go('/chat/no-bestie');
return;
}
if (next is PairingCancelledData) {
context.go('/home');
return;
}
if (next is PairingTargetedUnavailableData && !_unavailableDialogShown) {
_unavailableDialogShown = true;
// ignore: discarded_futures
BestieUnavailableDialog.show(
context,
paymentSessionId: next.paymentSessionId,
mitraName: next.mitraName,
topicSensitivity: next.topicSensitivity,
).then((_) {
if (mounted) _unavailableDialogShown = false;
});
return;
}
if (next is PairingErrorData) {
// Inline error UX is preferred over SnackBars (project memory:
// "Avoid SnackBars for provider errors"). The build below renders
// a banner when the state is PairingErrorData.
}
}
@override
Widget build(BuildContext context) {
final pairingState = ref.watch(pairingProvider);
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 32),
const Text(
'Mencari Bestie...',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
'Tunggu sebentar ya, kami sedang mencarikan Bestie untukmu',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, color: Colors.grey),
),
const SizedBox(height: 48),
OutlinedButton(
onPressed: () => ref.read(pairingProvider.notifier).cancelPairing(),
child: const Text('Batalkan'),
),
],
),
body: SafeArea(
child: Stack(
children: [
_SearchingBody(state: pairingState),
if (pairingState is PairingTargetedWaitingData)
TargetedWaitingOverlay(waiting: pairingState),
],
),
),
);
}
}
class _SearchingBody extends ConsumerWidget {
final PairingData state;
const _SearchingBody({required this.state});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isTargetedWaiting = state is PairingTargetedWaitingData;
final errorMessage = state is PairingErrorData ? (state as PairingErrorData).message : null;
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 32),
Text(
isTargetedWaiting ? 'Menghubungi bestie...' : 'Mencari Bestie...',
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
'Tunggu sebentar ya, kami sedang mencarikan Bestie untukmu',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, color: Colors.grey),
),
if (errorMessage != null) ...[
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.shade200),
),
child: Text(
errorMessage,
style: TextStyle(color: Colors.red.shade900),
textAlign: TextAlign.center,
),
),
],
const SizedBox(height: 48),
// The targeted-waiting overlay owns its own cancel button — only
// show the general cancel CTA when we're in a non-overlay state.
if (!isTargetedWaiting)
OutlinedButton(
onPressed: () => ref.read(pairingProvider.notifier).cancelSearch(),
child: const Text('Batalkan'),
),
],
),
),
);

View File

@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/availability/mitra_availability_notifier.dart';
import '../../../core/constants.dart';
import '../../../core/pairing/pairing_notifier.dart';
/// Shown when a "Curhat lagi" attempt against a specific bestie can't proceed
/// — either a 409 `targeted_mitra_offline` response on the targeted POST, or
/// one of the intermediate WS events (`returning_chat_timeout`,
/// `returning_chat_rejected`).
///
/// CTAs:
/// - "Chat dengan bestie lain" — only rendered when
/// [mitraAvailabilityProvider] reports `available == true` at the time of
/// build. Tapping calls [Pairing.fallbackToBlast] (reuses the same payment
/// session — no double-charge) and closes the dialog. The caller is expected
/// to be the searching screen, which will transition into PairingSearchingData
/// and stay put.
/// - "Kembali" — pops dialog and routes home. Backend has already audit-logged
/// the targeted failure; payment session stays `confirmed` until the sweeper
/// expires it.
class BestieUnavailableDialog extends ConsumerWidget {
final String paymentSessionId;
final String mitraName;
final TopicSensitivity topicSensitivity;
const BestieUnavailableDialog({
super.key,
required this.paymentSessionId,
required this.mitraName,
required this.topicSensitivity,
});
/// Convenience: show this dialog and return when it closes. Per project
/// memory ("Riverpod ref.listen in build is unsafe"), callers should
/// invoke this from `ref.listenManual` callbacks in `initState`, not from
/// `build`.
static Future<void> show(
BuildContext context, {
required String paymentSessionId,
required String mitraName,
required TopicSensitivity topicSensitivity,
}) {
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => BestieUnavailableDialog(
paymentSessionId: paymentSessionId,
mitraName: mitraName,
topicSensitivity: topicSensitivity,
),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
// Snapshot at dialog-open time — we don't keep listening, we just check
// whether other bestie are around right now.
final availabilityAsync = ref.watch(mitraAvailabilityProvider);
final hasOtherAvailable = availabilityAsync.valueOrNull ?? false;
return AlertDialog(
title: const Text('Bestie sedang tidak online'),
content: Text(
'$mitraName sedang tidak bisa menerima chat saat ini. '
'Kamu bisa coba chat dengan bestie lain atau kembali ke beranda.',
),
actions: [
TextButton(
onPressed: () {
// Reset pairing state and route home. Payment session stays
// confirmed until sweeper expires it — no extra API call needed.
ref.read(pairingProvider.notifier).reset();
Navigator.of(context).pop();
context.go('/home');
},
child: const Text('Kembali'),
),
if (hasOtherAvailable)
ElevatedButton(
onPressed: () {
// Close the dialog first, then kick off the fallback. The
// searching screen will pick up the new PairingSearchingData
// state and render normally (no targeted overlay).
Navigator.of(context).pop();
// ignore: discarded_futures
ref.read(pairingProvider.notifier).fallbackToBlast(
paymentSessionId: paymentSessionId,
topicSensitivity: topicSensitivity,
);
},
child: const Text('Chat dengan bestie lain'),
),
],
);
}
}

View File

@@ -3,27 +3,21 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/chat/chat_opening_provider.dart';
import '../../../core/chat/session_closure_notifier.dart';
import '../../../core/constants.dart';
import '../../../core/pairing/pairing_notifier.dart';
/// Extension-only pricing sheet.
///
/// Used solely for in-session extension requests; the initial pairing flow
/// goes through `/payment` instead. Free-trial is never offered for extensions.
///
/// Submit triggers [SessionClosure.requestExtension], which internally
/// runs the payment-session create+confirm and then the extend POST.
class PricingBottomSheet extends ConsumerWidget {
/// If set, the bottom sheet is in "extension" mode — selecting a tier extends the session.
final String? extensionSessionId;
/// Required — the in-progress chat session id this extension targets.
final String extensionSessionId;
/// Required when starting a new pairing. Null when in extension mode.
final TopicSensitivity? topicSensitivity;
const PricingBottomSheet({super.key, required this.extensionSessionId});
const PricingBottomSheet({super.key, this.extensionSessionId, this.topicSensitivity});
/// Show for new pairing (from home screen)
static Future<void> show(BuildContext context, {required TopicSensitivity topicSensitivity}) {
return showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) => PricingBottomSheet(topicSensitivity: topicSensitivity),
);
}
/// Show for session extension (from chat screen)
/// Show for session extension (from chat screen).
static Future<void> showForExtension(BuildContext context, {required String sessionId}) {
return showModalBottomSheet(
context: context,
@@ -32,19 +26,8 @@ class PricingBottomSheet extends ConsumerWidget {
);
}
String _formatPrice(int price) {
final str = price.toString();
final buffer = StringBuffer();
for (var i = 0; i < str.length; i++) {
if (i > 0 && (str.length - i) % 3 == 0) buffer.write('.');
buffer.write(str[i]);
}
return 'Rp $buffer';
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final isExtension = extensionSessionId != null;
final pricingAsync = ref.watch(chatPricingProvider);
return pricingAsync.when(
@@ -52,7 +35,7 @@ class PricingBottomSheet extends ConsumerWidget {
height: 200,
child: Center(child: CircularProgressIndicator()),
),
error: (error, _) => SizedBox(
error: (error, _) => const SizedBox(
height: 200,
child: Center(child: Text('Gagal memuat harga. Coba lagi.')),
),
@@ -67,54 +50,30 @@ class PricingBottomSheet extends ConsumerWidget {
child: ListView(
controller: scrollController,
children: [
Text(
isExtension ? 'Perpanjang Durasi' : 'Pilih Durasi Curhat',
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
const Text(
'Perpanjang Durasi',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
if (!isExtension && pricing.freeTrialEligible) ...[
Card(
color: Colors.green.shade50,
child: ListTile(
leading: const Icon(Icons.card_giftcard, color: Colors.green),
title: Text('Free Trial (${pricing.freeTrialDurationMinutes} Menit)'),
subtitle: const Text('Gratis untuk pertama kali!'),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: () {
Navigator.of(context).pop();
_startPairing(ref, isFreeTrial: true);
},
),
),
const Divider(height: 24),
],
// No free-trial path for extensions.
...pricing.tiers.map((tier) => Card(
child: ListTile(
title: Text(tier.label),
trailing: Text(
_formatPrice(tier.price),
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
onTap: () {
Navigator.of(context).pop();
if (isExtension) {
_requestExtension(
ref,
sessionId: extensionSessionId!,
durationMinutes: tier.durationMinutes,
price: tier.price,
);
} else {
_startPairing(
ref,
durationMinutes: tier.durationMinutes,
price: tier.price,
);
}
},
),
)),
child: ListTile(
title: Text(tier.label),
trailing: Text(
formatRupiah(tier.price),
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
onTap: () {
Navigator.of(context).pop();
ref.read(sessionClosureProvider.notifier).requestExtension(
extensionSessionId,
durationMinutes: tier.durationMinutes,
price: tier.price,
);
},
),
)),
],
),
);
@@ -122,21 +81,4 @@ class PricingBottomSheet extends ConsumerWidget {
),
);
}
void _startPairing(WidgetRef ref, {bool isFreeTrial = false, int? durationMinutes, int? price}) {
ref.read(pairingProvider.notifier).requestPairingWithTier(
durationMinutes: durationMinutes,
price: price,
isFreeTrial: isFreeTrial,
topicSensitivity: topicSensitivity ?? TopicSensitivity.regular,
);
}
void _requestExtension(WidgetRef ref, {required String sessionId, required int durationMinutes, required int price}) {
ref.read(sessionClosureProvider.notifier).requestExtension(
sessionId,
durationMinutes: durationMinutes,
price: price,
);
}
}

View File

@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/pairing/pairing_notifier.dart';
/// Full-screen modal overlay shown above the searching screen during the 20s
/// targeted-mitra wait window. The overlay reads its state directly from the
/// [PairingTargetedWaitingData] passed in by the parent — the local countdown
/// ticks are owned by the pairing notifier so the overlay just renders.
///
/// "Batalkan" calls `pairingNotifier.cancelSearch()` which posts to
/// `/api/client/chat/chat-requests/cancel` and transitions state to
/// `PairingCancelledData`. The parent screen listens for that and pops home.
class TargetedWaitingOverlay extends ConsumerWidget {
final PairingTargetedWaitingData waiting;
const TargetedWaitingOverlay({super.key, required this.waiting});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Container(
// Slight scrim so the underlying searching UI is still visible but the
// overlay clearly owns the foreground.
color: Colors.black.withValues(alpha: 0.55),
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Card(
elevation: 6,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
width: 56,
height: 56,
child: CircularProgressIndicator(strokeWidth: 3),
),
const SizedBox(height: 20),
Text(
'Menunggu konfirmasi ${waiting.mitraName}',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
'${waiting.secondsRemaining}d',
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w300),
),
const SizedBox(height: 8),
const Text(
'Bestie punya 20 detik untuk merespon. Kalau tidak ada respon, kami bantu cari bestie lain.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: Colors.grey),
),
const SizedBox(height: 16),
TextButton(
onPressed: () => ref.read(pairingProvider.notifier).cancelSearch(),
child: const Text('Batalkan'),
),
],
),
),
),
),
),
);
}
}

View File

@@ -2,11 +2,17 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/auth/auth_notifier.dart';
import '../../core/availability/mitra_availability_notifier.dart';
import '../../core/chat/active_session_notifier.dart';
import '../../core/pairing/pairing_notifier.dart';
import '../chat/widgets/pricing_bottom_sheet.dart';
import '../chat/widgets/topic_selection_bottom_sheet.dart';
/// Home screen.
///
/// 1. The "Mulai Curhat" CTA is gated on real-time mitra availability
/// (polling owned by the [mitraAvailabilityProvider]). Polling is paused
/// on background and resumed on foreground via [WidgetsBindingObserver].
/// 2. Tapping the enabled CTA pushes `/payment` so the customer must confirm
/// a payment session before any blast fires.
class HomeScreen extends ConsumerStatefulWidget {
const HomeScreen({super.key});
@@ -19,26 +25,38 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
// Kick the availability poll on once the first frame settles. Doing it
// here (rather than in build) avoids re-firing on every rebuild.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ref.read(mitraAvailabilityProvider.notifier).setActive(true);
});
}
@override
void dispose() {
// Stop polling when leaving home.
ref.read(mitraAvailabilityProvider.notifier).setActive(false);
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
final notifier = ref.read(mitraAvailabilityProvider.notifier);
if (state == AppLifecycleState.resumed) {
// Re-fetch in case a session ended/started while backgrounded.
ref.read(activeSessionProvider.notifier).refresh();
notifier.setActive(true);
} else if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive) {
notifier.setActive(false);
}
}
Future<void> _onStartChatPressed(BuildContext context) async {
final topic = await TopicSelectionBottomSheet.show(context);
if (topic == null || !context.mounted) return;
await PricingBottomSheet.show(context, topicSensitivity: topic);
context.push('/payment', extra: {'topicSensitivity': topic});
}
@override
@@ -46,6 +64,7 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
final authState = ref.watch(authProvider);
final authData = authState.valueOrNull;
final activeSessionAsync = ref.watch(activeSessionProvider);
final availabilityAsync = ref.watch(mitraAvailabilityProvider);
final displayName = switch (authData) {
AuthAuthenticatedData d => d.profile['display_name'] as String? ?? '',
@@ -53,76 +72,84 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
_ => '',
};
ref.listen(pairingProvider, (prev, next) {
if (next is PairingSearchingData) {
context.go('/chat/searching');
} else if (next is PairingNoBestieData) {
context.go('/chat/no-bestie');
} else if (next is PairingErrorData) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(next.message)),
);
}
});
// Poll-failure / loading both default to "no bestie available" (greyed-out).
// Never optimistically enable.
final mitraAvailable = availabilityAsync.valueOrNull ?? false;
return Scaffold(
appBar: AppBar(
title: const Text('Halo Bestie'),
actions: [
IconButton(
icon: const Icon(Icons.history),
onPressed: () => context.push('/chat/history'),
),
IconButton(
icon: const Icon(Icons.logout),
onPressed: () => ref.read(authProvider.notifier).logout(),
appBar: AppBar(
title: const Text('Halo Bestie'),
actions: [
IconButton(
icon: const Icon(Icons.history),
onPressed: () => context.push('/chat/history'),
),
IconButton(
icon: const Icon(Icons.logout),
onPressed: () => ref.read(authProvider.notifier).logout(),
),
],
),
body: RefreshIndicator(
onRefresh: () async {
// Pull-to-refresh kicks both the active-session and availability polls.
await Future.wait([
ref.read(activeSessionProvider.notifier).refresh(),
ref.read(mitraAvailabilityProvider.notifier).refresh(),
]);
},
child: ListView(
// Force-scroll so RefreshIndicator can fire even on a short body.
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(32),
children: [
const SizedBox(height: 32),
Center(child: Text('Halo, $displayName!', style: const TextStyle(fontSize: 24))),
const SizedBox(height: 32),
Center(
child: activeSessionAsync.when(
loading: () => const CircularProgressIndicator(),
error: (_, __) => _StartChatButton(
enabled: mitraAvailable,
onPressed: () => _onStartChatPressed(context),
),
data: (snapshot) {
// Hide the "Sesi Aktif" CTA when the session is in `closing`
// — the conversation is over, only the goodbye composer
// remains. Backend auto-completes such sessions after a
// grace period; until then the user shouldn't be invited
// back into them from home.
final status = snapshot.session?['status'] as String?;
final isCurhatable = snapshot.hasSession && status != 'closing';
if (isCurhatable) {
return _ActiveSessionCard(
mitraName: snapshot.mitraName,
unreadCount: snapshot.unreadCount,
onTap: () {
final sessionId = snapshot.sessionId;
if (sessionId == null) return;
context.push('/chat/session/$sessionId', extra: snapshot.mitraName);
},
);
}
return _StartChatButton(
enabled: mitraAvailable,
onPressed: () => _onStartChatPressed(context),
);
},
),
),
],
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Halo, $displayName!', style: const TextStyle(fontSize: 24)),
const SizedBox(height: 32),
activeSessionAsync.when(
loading: () => const CircularProgressIndicator(),
error: (_, __) => _StartChatButton(onPressed: () => _onStartChatPressed(context)),
data: (snapshot) {
// Hide the "Sesi Aktif" CTA when the session is in `closing`
// — the conversation is over, only the goodbye composer
// remains. Backend auto-completes such sessions after a
// grace period; until then the user shouldn't be invited
// back into them from home.
final status = snapshot.session?['status'] as String?;
final isCurhatable = snapshot.hasSession && status != 'closing';
if (isCurhatable) {
return _ActiveSessionCard(
mitraName: snapshot.mitraName,
unreadCount: snapshot.unreadCount,
onTap: () {
final sessionId = snapshot.sessionId;
if (sessionId == null) return;
context.push('/chat/session/$sessionId', extra: snapshot.mitraName);
},
);
}
return _StartChatButton(onPressed: () => _onStartChatPressed(context));
},
),
],
),
),
),
),
);
}
}
class _StartChatButton extends StatelessWidget {
final bool enabled;
final VoidCallback onPressed;
const _StartChatButton({required this.onPressed});
const _StartChatButton({required this.enabled, required this.onPressed});
@override
Widget build(BuildContext context) {
@@ -133,9 +160,15 @@ class _StartChatButton extends StatelessWidget {
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16),
),
onPressed: onPressed,
onPressed: enabled ? onPressed : null,
child: const Text('Mulai Curhat', style: TextStyle(fontSize: 18)),
),
const SizedBox(height: 12),
if (!enabled)
Text(
'Belum ada bestie tersedia',
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
),
],
);
}

View File

@@ -0,0 +1,180 @@
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._();
}

View File

@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'payment_notifier.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$paymentHash() => r'63019ba794311cd36761bd6ad6f90b0abde5c747';
/// 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

View File

@@ -0,0 +1,390 @@
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 dispose() {
// Best-effort cancel on back/dispose if we still have a `pending` row.
// The notifier checks state before calling the API, so this is safe to
// call unconditionally.
// ignore: discarded_futures
ref.read(paymentProvider.notifier).cancelIfPending();
super.dispose();
}
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();
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)),
),
),
],
),
),
);
}
}

View File

@@ -11,12 +11,14 @@ import 'features/auth/screens/set_display_name_screen.dart';
import 'features/onboarding/onboarding_screen.dart';
import 'features/splash/splash_screen.dart';
import 'features/home/home_screen.dart';
import 'core/constants.dart';
import 'features/chat/screens/searching_screen.dart';
import 'features/chat/screens/bestie_found_screen.dart';
import 'features/chat/screens/no_bestie_screen.dart';
import 'features/chat/screens/chat_screen.dart';
import 'features/chat/screens/chat_history_screen.dart';
import 'features/chat/screens/chat_transcript_screen.dart';
import 'features/payment/screens/payment_screen.dart';
class RouterNotifier extends ChangeNotifier {
final Ref _ref;
@@ -96,6 +98,22 @@ GoRouter buildRouter(Ref ref) {
GoRoute(path: '/auth/set-name', builder: (_, __) => const SetDisplayNameScreen()),
GoRoute(path: '/auth/force-register', builder: (_, __) => const ForceRegisterScreen()),
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
GoRoute(path: '/payment', builder: (context, state) {
// Payment screen reachable from
// - Home "Mulai Curhat" CTA → no extras (general blast follows confirm)
// - Chat history "Curhat lagi" CTA → extras carry targetedMitraId/mitraName
// for the returning-chat flow, plus optional topicSensitivity.
final extra = state.extra;
if (extra is Map<String, dynamic>) {
final topic = extra['topicSensitivity'];
return PaymentScreen(
targetedMitraId: extra['targetedMitraId'] as String?,
mitraName: extra['mitraName'] as String?,
topicSensitivity: topic is TopicSensitivity ? topic : TopicSensitivity.regular,
);
}
return const PaymentScreen();
}),
GoRoute(path: '/chat/searching', builder: (_, __) => const SearchingScreen()),
GoRoute(path: '/chat/found', builder: (context, state) {
final extra = state.extra as Map<String, dynamic>;