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:
111
client_app/.maestro/README.md
Normal file
111
client_app/.maestro/README.md
Normal 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.
|
||||
25
client_app/.maestro/config.yaml
Normal file
25
client_app/.maestro/config.yaml
Normal 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"
|
||||
14
client_app/.maestro/flows/01_smoke.yaml
Normal file
14
client_app/.maestro/flows/01_smoke.yaml
Normal 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
|
||||
21
client_app/.maestro/flows/02_cta_disabled_when_no_mitra.yaml
Normal file
21
client_app/.maestro/flows/02_cta_disabled_when_no_mitra.yaml
Normal 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"
|
||||
42
client_app/.maestro/flows/03_payment_to_chat_happy.yaml
Normal file
42
client_app/.maestro/flows/03_payment_to_chat_happy.yaml
Normal 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
|
||||
34
client_app/.maestro/scripts/force_all_mitras_offline.sh
Executable file
34
client_app/.maestro/scripts/force_all_mitras_offline.sh
Executable 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."
|
||||
36
client_app/.maestro/scripts/mitra_accept_latest.sh
Executable file
36
client_app/.maestro/scripts/mitra_accept_latest.sh
Executable 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"
|
||||
@@ -6,7 +6,7 @@ part of 'auth_notifier.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$authHash() => r'601e614f3297fb679f5baa893932a43ae981eb9d';
|
||||
String _$authHash() => r'f0382b1380749cebe8182ad221023926768ebb92';
|
||||
|
||||
/// See also [Auth].
|
||||
@ProviderFor(Auth)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -6,7 +6,7 @@ part of 'chat_notifier.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$chatHash() => r'30e0aa41d2022e5bb080877e23fcb0a0c0c4b927';
|
||||
String _$chatHash() => r'35388d2977db9cee4307697524620b22691c54f5';
|
||||
|
||||
/// See also [Chat].
|
||||
@ProviderFor(Chat)
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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._();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ part of 'pairing_notifier.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$pairingHash() => r'e1c3074239cc4efda99885331ff91b3e0f903c8d';
|
||||
String _$pairingHash() => r'0fe1e5646fe70b90f5489919ec8dd557c998daad';
|
||||
|
||||
/// See also [Pairing].
|
||||
@ProviderFor(Pairing)
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
180
client_app/lib/features/payment/payment_notifier.dart
Normal file
180
client_app/lib/features/payment/payment_notifier.dart
Normal 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._();
|
||||
}
|
||||
25
client_app/lib/features/payment/payment_notifier.g.dart
Normal file
25
client_app/lib/features/payment/payment_notifier.g.dart
Normal 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
|
||||
390
client_app/lib/features/payment/screens/payment_screen.dart
Normal file
390
client_app/lib/features/payment/screens/payment_screen.dart
Normal 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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user