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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user