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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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