Compare commits
2 Commits
b59c66f7df
...
05ab1e10df
| Author | SHA1 | Date | |
|---|---|---|---|
| 05ab1e10df | |||
| f8380163bc |
@@ -56,6 +56,7 @@ export const TransactionType = Object.freeze({
|
|||||||
// Who ended a session
|
// Who ended a session
|
||||||
export const EndedBy = Object.freeze({
|
export const EndedBy = Object.freeze({
|
||||||
SYSTEM: 'system',
|
SYSTEM: 'system',
|
||||||
|
SYSTEM_AUTO_CLOSE: 'system_auto_close',
|
||||||
CUSTOMER: 'customer',
|
CUSTOMER: 'customer',
|
||||||
MITRA: 'mitra',
|
MITRA: 'mitra',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getDb } from '../db/client.js'
|
import { getDb } from '../db/client.js'
|
||||||
import { publish } from '../plugins/valkey.js'
|
import { publish } from '../plugins/valkey.js'
|
||||||
import { clearSessionTimer, clearClosureGraceTimer } from './session-timer.service.js'
|
import { clearSessionTimer, clearClosureGraceTimer, startClosureGraceTimer } from './session-timer.service.js'
|
||||||
import { sendToSessionParticipant } from '../plugins/websocket.js'
|
import { sendToSessionParticipant } from '../plugins/websocket.js'
|
||||||
import { sendPushNotification } from './notification.service.js'
|
import { sendPushNotification } from './notification.service.js'
|
||||||
import { UserType, SessionStatus, EndedBy, WsMessage } from '../constants.js'
|
import { UserType, SessionStatus, EndedBy, WsMessage } from '../constants.js'
|
||||||
@@ -27,6 +27,12 @@ export const submitClosureMessage = async (sessionId, userType, userId, message)
|
|||||||
RETURNING id, session_id, user_type, message, created_at
|
RETURNING id, session_id, user_type, message, created_at
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// Customer submitted their goodbye — cancel the auto-close grace timer.
|
||||||
|
// (Even if mitra hasn't submitted yet, the session is no longer "stuck".)
|
||||||
|
if (userType === UserType.CUSTOMER) {
|
||||||
|
clearClosureGraceTimer(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
// Check if both parties have submitted
|
// Check if both parties have submitted
|
||||||
const closures = await sql`
|
const closures = await sql`
|
||||||
SELECT user_type FROM session_closures WHERE session_id = ${sessionId}
|
SELECT user_type FROM session_closures WHERE session_id = ${sessionId}
|
||||||
@@ -105,6 +111,7 @@ export const initiateEarlyEnd = async (sessionId, userType) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearSessionTimer(sessionId)
|
clearSessionTimer(sessionId)
|
||||||
|
startClosureGraceTimer(sessionId)
|
||||||
|
|
||||||
// Notify both parties to enter closure flow, FCM fallback if WebSocket is down
|
// Notify both parties to enter closure flow, FCM fallback if WebSocket is down
|
||||||
const data = { type: WsMessage.SESSION_CLOSING, session_id: sessionId, ended_by: userType }
|
const data = { type: WsMessage.SESSION_CLOSING, session_id: sessionId, ended_by: userType }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getDb } from '../db/client.js'
|
import { getDb } from '../db/client.js'
|
||||||
import { publish } from '../plugins/valkey.js'
|
import { publish } from '../plugins/valkey.js'
|
||||||
import { sendToSessionParticipant } from '../plugins/websocket.js'
|
import { sendToSessionParticipant } from '../plugins/websocket.js'
|
||||||
import { extendSessionTimer, clearClosureGraceTimer } from './session-timer.service.js'
|
import { extendSessionTimer, clearClosureGraceTimer, startClosureGraceTimer } from './session-timer.service.js'
|
||||||
import { UserType, SessionStatus, ExtensionStatus, TransactionType, WsMessage } from '../constants.js'
|
import { UserType, SessionStatus, ExtensionStatus, TransactionType, WsMessage } from '../constants.js'
|
||||||
|
|
||||||
const sql = getDb()
|
const sql = getDb()
|
||||||
@@ -142,6 +142,7 @@ export const respondToExtension = async (extensionId, sessionId, mitraId, accept
|
|||||||
type: WsMessage.SESSION_CLOSING,
|
type: WsMessage.SESSION_CLOSING,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
})
|
})
|
||||||
|
startClosureGraceTimer(sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
return extension
|
return extension
|
||||||
@@ -174,4 +175,5 @@ const timeoutExtension = async (extensionId, sessionId) => {
|
|||||||
type: WsMessage.SESSION_CLOSING,
|
type: WsMessage.SESSION_CLOSING,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
})
|
})
|
||||||
|
startClosureGraceTimer(sessionId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { getDb } from '../db/client.js'
|
|||||||
import { publish } from '../plugins/valkey.js'
|
import { publish } from '../plugins/valkey.js'
|
||||||
import { sendToSessionParticipant } from '../plugins/websocket.js'
|
import { sendToSessionParticipant } from '../plugins/websocket.js'
|
||||||
import { sendPushNotification } from './notification.service.js'
|
import { sendPushNotification } from './notification.service.js'
|
||||||
import { UserType, SessionStatus, WsMessage } from '../constants.js'
|
import { UserType, SessionStatus, WsMessage, EndedBy } from '../constants.js'
|
||||||
|
|
||||||
const sql = getDb()
|
const sql = getDb()
|
||||||
|
|
||||||
@@ -114,29 +114,46 @@ const onSessionExpired = async (sessionId) => {
|
|||||||
await publish(`session:${sessionId}:status`, expiredData)
|
await publish(`session:${sessionId}:status`, expiredData)
|
||||||
|
|
||||||
// Start grace period — auto-complete if closing messages aren't submitted
|
// Start grace period — auto-complete if closing messages aren't submitted
|
||||||
const graceTimerId = setTimeout(async () => {
|
startClosureGraceTimer(sessionId)
|
||||||
closureGraceTimers.delete(sessionId)
|
}
|
||||||
try {
|
|
||||||
const [stale] = await sql`
|
// Schedule auto-completion if session stays in `closing` past the grace window.
|
||||||
SELECT id FROM chat_sessions
|
// Idempotent: clears any existing grace timer before scheduling a new one.
|
||||||
WHERE id = ${sessionId} AND status = ${SessionStatus.CLOSING}
|
export const startClosureGraceTimer = (sessionId) => {
|
||||||
`
|
clearClosureGraceTimer(sessionId)
|
||||||
if (stale) {
|
const graceTimerId = setTimeout(() => autoCompleteIfStillClosing(sessionId), CLOSURE_GRACE_PERIOD_MS)
|
||||||
await sql`
|
|
||||||
UPDATE chat_sessions
|
|
||||||
SET status = ${SessionStatus.COMPLETED}, ended_at = NOW(), ended_by = 'system'
|
|
||||||
WHERE id = ${sessionId} AND status = ${SessionStatus.CLOSING}
|
|
||||||
`
|
|
||||||
const data = { type: WsMessage.SESSION_COMPLETED, session_id: sessionId }
|
|
||||||
sendToSessionParticipant(sessionId, UserType.CUSTOMER, data)
|
|
||||||
sendToSessionParticipant(sessionId, UserType.MITRA, data)
|
|
||||||
console.log(`Auto-completed abandoned session ${sessionId} after grace period`)
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
}, CLOSURE_GRACE_PERIOD_MS)
|
|
||||||
closureGraceTimers.set(sessionId, graceTimerId)
|
closureGraceTimers.set(sessionId, graceTimerId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const autoCompleteIfStillClosing = async (sessionId) => {
|
||||||
|
closureGraceTimers.delete(sessionId)
|
||||||
|
try {
|
||||||
|
const [stale] = await sql`
|
||||||
|
SELECT id FROM chat_sessions
|
||||||
|
WHERE id = ${sessionId} AND status = ${SessionStatus.CLOSING}
|
||||||
|
`
|
||||||
|
if (!stale) return // customer submitted goodbye, or another path already finalized
|
||||||
|
|
||||||
|
const [updated] = await sql`
|
||||||
|
UPDATE chat_sessions
|
||||||
|
SET status = ${SessionStatus.COMPLETED},
|
||||||
|
ended_at = COALESCE(ended_at, NOW()),
|
||||||
|
ended_by = ${EndedBy.SYSTEM_AUTO_CLOSE}
|
||||||
|
WHERE id = ${sessionId} AND status = ${SessionStatus.CLOSING}
|
||||||
|
RETURNING id
|
||||||
|
`
|
||||||
|
if (!updated) return
|
||||||
|
|
||||||
|
const data = { type: WsMessage.SESSION_COMPLETED, session_id: sessionId }
|
||||||
|
sendToSessionParticipant(sessionId, UserType.CUSTOMER, data)
|
||||||
|
sendToSessionParticipant(sessionId, UserType.MITRA, data)
|
||||||
|
await publish(`session:${sessionId}:status`, data)
|
||||||
|
console.log(`Auto-completed abandoned session ${sessionId} after grace period`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Closure grace auto-complete failed for ${sessionId}:`, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const clearClosureGraceTimer = (sessionId) => {
|
export const clearClosureGraceTimer = (sessionId) => {
|
||||||
const timerId = closureGraceTimers.get(sessionId)
|
const timerId = closureGraceTimers.get(sessionId)
|
||||||
if (timerId) {
|
if (timerId) {
|
||||||
@@ -158,10 +175,14 @@ export const restoreActiveTimers = async () => {
|
|||||||
console.log(`Auto-completed ${staleSessions.length} expired session(s)`)
|
console.log(`Auto-completed ${staleSessions.length} expired session(s)`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-complete sessions stuck in 'closing' status (abandoned during grace period)
|
// Recover pending closure timers — any session still in `closing` after restart
|
||||||
|
// has lost its in-memory grace setTimeout. We can't know how long it has been
|
||||||
|
// closing for, so finalize them all to system_auto_close.
|
||||||
const staleClosing = await sql`
|
const staleClosing = await sql`
|
||||||
UPDATE chat_sessions
|
UPDATE chat_sessions
|
||||||
SET status = ${SessionStatus.COMPLETED}, ended_at = NOW(), ended_by = 'system'
|
SET status = ${SessionStatus.COMPLETED},
|
||||||
|
ended_at = COALESCE(ended_at, NOW()),
|
||||||
|
ended_by = ${EndedBy.SYSTEM_AUTO_CLOSE}
|
||||||
WHERE status = ${SessionStatus.CLOSING}
|
WHERE status = ${SessionStatus.CLOSING}
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -210,12 +210,13 @@ export const getCustomerHistory = async (customerId, { page = 1, limit = 20 } =
|
|||||||
FROM chat_sessions cs
|
FROM chat_sessions cs
|
||||||
LEFT JOIN mitras m ON m.id = cs.mitra_id
|
LEFT JOIN mitras m ON m.id = cs.mitra_id
|
||||||
WHERE cs.customer_id = ${customerId}
|
WHERE cs.customer_id = ${customerId}
|
||||||
AND cs.status = ${SessionStatus.COMPLETED}
|
AND cs.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})
|
||||||
ORDER BY cs.ended_at DESC
|
ORDER BY COALESCE(cs.ended_at, cs.created_at) DESC
|
||||||
LIMIT ${limit} OFFSET ${offset}
|
LIMIT ${limit} OFFSET ${offset}
|
||||||
`
|
`
|
||||||
const [{ count }] = await sql`
|
const [{ count }] = await sql`
|
||||||
SELECT COUNT(*) FROM chat_sessions WHERE customer_id = ${customerId} AND status = ${SessionStatus.COMPLETED}
|
SELECT COUNT(*) FROM chat_sessions WHERE customer_id = ${customerId}
|
||||||
|
AND status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})
|
||||||
`
|
`
|
||||||
return { items, total: Number(count), page, limit }
|
return { items, total: Number(count), page, limit }
|
||||||
}
|
}
|
||||||
@@ -231,12 +232,13 @@ export const getMitraHistory = async (mitraId, { page = 1, limit = 20 } = {}) =>
|
|||||||
FROM chat_sessions cs
|
FROM chat_sessions cs
|
||||||
INNER JOIN customers c ON c.id = cs.customer_id
|
INNER JOIN customers c ON c.id = cs.customer_id
|
||||||
WHERE cs.mitra_id = ${mitraId}
|
WHERE cs.mitra_id = ${mitraId}
|
||||||
AND cs.status = ${SessionStatus.COMPLETED}
|
AND cs.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})
|
||||||
ORDER BY cs.ended_at DESC
|
ORDER BY COALESCE(cs.ended_at, cs.created_at) DESC
|
||||||
LIMIT ${limit} OFFSET ${offset}
|
LIMIT ${limit} OFFSET ${offset}
|
||||||
`
|
`
|
||||||
const [{ count }] = await sql`
|
const [{ count }] = await sql`
|
||||||
SELECT COUNT(*) FROM chat_sessions WHERE mitra_id = ${mitraId} AND status = ${SessionStatus.COMPLETED}
|
SELECT COUNT(*) FROM chat_sessions WHERE mitra_id = ${mitraId}
|
||||||
|
AND status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})
|
||||||
`
|
`
|
||||||
return { items, total: Number(count), page, limit }
|
return { items, total: Number(count), page, limit }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ android {
|
|||||||
applicationId = "com.halobestie.client.client_app"
|
applicationId = "com.halobestie.client.client_app"
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = 24
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
|
|||||||
89
client_app/lib/core/chat/active_session_notifier.dart
Normal file
89
client_app/lib/core/chat/active_session_notifier.dart
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import '../api/api_client_provider.dart';
|
||||||
|
|
||||||
|
part 'active_session_notifier.g.dart';
|
||||||
|
|
||||||
|
/// Snapshot of the current customer's active session (if any) plus its
|
||||||
|
/// unread-message count. Backed by `/api/client/chat/session/active-with-unread`.
|
||||||
|
///
|
||||||
|
/// Lives at app-scope so the home CTA, the global chat WebSocket lifecycle
|
||||||
|
/// (see `main.dart`), and any badge consumers all share one source of truth.
|
||||||
|
class ActiveSessionData {
|
||||||
|
final Map<String, dynamic>? session;
|
||||||
|
final int unreadCount;
|
||||||
|
|
||||||
|
const ActiveSessionData({this.session, this.unreadCount = 0});
|
||||||
|
|
||||||
|
bool get hasSession => session != null;
|
||||||
|
String? get sessionId => session?['id'] as String?;
|
||||||
|
String get mitraName =>
|
||||||
|
(session?['mitra_display_name'] as String?) ?? 'Bestie';
|
||||||
|
|
||||||
|
static const empty = ActiveSessionData();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class ActiveSession extends _$ActiveSession {
|
||||||
|
Timer? _pollTimer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ActiveSessionData> build() async {
|
||||||
|
ref.onDispose(_stopPolling);
|
||||||
|
_startPolling();
|
||||||
|
return await _fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startPolling() {
|
||||||
|
_stopPolling();
|
||||||
|
_pollTimer = Timer.periodic(const Duration(seconds: 15), (_) async {
|
||||||
|
try {
|
||||||
|
final next = await _fetch();
|
||||||
|
state = AsyncData(next);
|
||||||
|
} catch (_) {
|
||||||
|
// Keep last known state on transient failures.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stopPolling() {
|
||||||
|
_pollTimer?.cancel();
|
||||||
|
_pollTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ActiveSessionData> _fetch() async {
|
||||||
|
final apiClient = ref.read(apiClientProvider);
|
||||||
|
final response = await apiClient.get('/api/client/chat/session/active-with-unread');
|
||||||
|
final data = response['data'];
|
||||||
|
if (data is Map<String, dynamic>) {
|
||||||
|
return ActiveSessionData(
|
||||||
|
session: data,
|
||||||
|
unreadCount: data['unread_count'] as int? ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ActiveSessionData.empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force a fresh fetch (e.g. on resume, after pairing succeeds, after a
|
||||||
|
/// session ends). Updates `state` synchronously to AsyncLoading-with-prev
|
||||||
|
/// then to AsyncData.
|
||||||
|
Future<void> refresh() async {
|
||||||
|
try {
|
||||||
|
final next = await _fetch();
|
||||||
|
state = AsyncData(next);
|
||||||
|
} catch (e, st) {
|
||||||
|
state = AsyncError(e, st);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Optimistic local-only update: clear unread without a round-trip
|
||||||
|
/// (used after the chat screen marks messages read).
|
||||||
|
void markRead() {
|
||||||
|
final current = state.valueOrNull;
|
||||||
|
if (current == null || !current.hasSession) return;
|
||||||
|
state = AsyncData(ActiveSessionData(
|
||||||
|
session: current.session,
|
||||||
|
unreadCount: 0,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
26
client_app/lib/core/chat/active_session_notifier.g.dart
Normal file
26
client_app/lib/core/chat/active_session_notifier.g.dart
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'active_session_notifier.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
String _$activeSessionHash() => r'8d7dec22d94b3efc32e0401b9a82a5f712e6cc16';
|
||||||
|
|
||||||
|
/// See also [ActiveSession].
|
||||||
|
@ProviderFor(ActiveSession)
|
||||||
|
final activeSessionProvider =
|
||||||
|
AsyncNotifierProvider<ActiveSession, ActiveSessionData>.internal(
|
||||||
|
ActiveSession.new,
|
||||||
|
name: r'activeSessionProvider',
|
||||||
|
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||||
|
? null
|
||||||
|
: _$activeSessionHash,
|
||||||
|
dependencies: null,
|
||||||
|
allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
typedef _$ActiveSession = AsyncNotifier<ActiveSessionData>;
|
||||||
|
// 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,6 +6,7 @@ import '../api/api_client.dart';
|
|||||||
import '../api/api_client_provider.dart';
|
import '../api/api_client_provider.dart';
|
||||||
import '../auth/auth_bridge.dart';
|
import '../auth/auth_bridge.dart';
|
||||||
import '../constants.dart';
|
import '../constants.dart';
|
||||||
|
import 'active_session_notifier.dart';
|
||||||
|
|
||||||
part 'chat_notifier.g.dart';
|
part 'chat_notifier.g.dart';
|
||||||
|
|
||||||
@@ -102,13 +103,64 @@ class Chat extends _$Chat {
|
|||||||
WebSocketChannel? _channel;
|
WebSocketChannel? _channel;
|
||||||
StreamSubscription? _wsSubscription;
|
StreamSubscription? _wsSubscription;
|
||||||
Timer? _typingTimer;
|
Timer? _typingTimer;
|
||||||
|
String? _connectedSessionId;
|
||||||
|
|
||||||
ApiClient get _apiClient => ref.read(apiClientProvider);
|
ApiClient get _apiClient => ref.read(apiClientProvider);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ChatData build() => const ChatInitialData();
|
ChatData build() => const ChatInitialData();
|
||||||
|
|
||||||
|
/// Idempotent connect: if we're already connected to [sessionId], refresh
|
||||||
|
/// the session status from the server (in case it transitioned to closing /
|
||||||
|
/// expired while we weren't watching closely) but reuse the live WS.
|
||||||
|
/// If we're connected to a different session, disconnect first.
|
||||||
|
Future<void> connectIfNotConnected(String sessionId) async {
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[CONNECT_IF_NOT] target=$sessionId existing=$_connectedSessionId state=${state.runtimeType}');
|
||||||
|
if (_connectedSessionId == sessionId &&
|
||||||
|
(state is ChatConnectedData || state is ChatConnectingData)) {
|
||||||
|
await refreshSessionStatus(sessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_connectedSessionId != null && _connectedSessionId != sessionId) {
|
||||||
|
_cleanup();
|
||||||
|
}
|
||||||
|
await connect(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-pull session status from the server and reconcile local flags. Used
|
||||||
|
/// when re-entering the chat screen for a session we're already connected
|
||||||
|
/// to — covers the case where a `sessionClosing` / `sessionExpired` WS event
|
||||||
|
/// was missed (e.g. socket dropped, app backgrounded, status changed before
|
||||||
|
/// we connected).
|
||||||
|
Future<void> refreshSessionStatus(String sessionId) async {
|
||||||
|
final current = state;
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[REFRESH_STATUS] called sessionId=$sessionId currentState=${current.runtimeType}');
|
||||||
|
if (current is! ChatConnectedData) return;
|
||||||
|
try {
|
||||||
|
final info = await _apiClient.get('/api/shared/chat/$sessionId/info');
|
||||||
|
final data = info['data'] as Map<String, dynamic>?;
|
||||||
|
final status = data?['status'] as String?;
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[REFRESH_STATUS] backend status=$status');
|
||||||
|
if (status == SessionStatus.completed ||
|
||||||
|
status == SessionStatus.cancelled ||
|
||||||
|
status == SessionStatus.expired) {
|
||||||
|
state = current.copyWith(sessionExpired: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state = current.copyWith(
|
||||||
|
sessionClosing: status == SessionStatus.closing,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[REFRESH_STATUS] error=$e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> connect(String sessionId) async {
|
Future<void> connect(String sessionId) async {
|
||||||
|
_connectedSessionId = sessionId;
|
||||||
state = const ChatConnectingData();
|
state = const ChatConnectingData();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -174,6 +226,8 @@ class Chat extends _$Chat {
|
|||||||
state = const ChatInitialData();
|
state = const ChatInitialData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? get connectedSessionId => _connectedSessionId;
|
||||||
|
|
||||||
void sendMessage(String content) {
|
void sendMessage(String content) {
|
||||||
if (state is! ChatConnectedData || _channel == null) return;
|
if (state is! ChatConnectedData || _channel == null) return;
|
||||||
final current = state as ChatConnectedData;
|
final current = state as ChatConnectedData;
|
||||||
@@ -317,6 +371,9 @@ class Chat extends _$Chat {
|
|||||||
|
|
||||||
case WsMessage.sessionCompleted:
|
case WsMessage.sessionCompleted:
|
||||||
_cleanup();
|
_cleanup();
|
||||||
|
// Home CTA must clear immediately — backend just told us the session
|
||||||
|
// is gone, so refresh the shared snapshot.
|
||||||
|
ref.invalidate(activeSessionProvider);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case WsMessage.sessionTopicUpdated:
|
case WsMessage.sessionTopicUpdated:
|
||||||
@@ -336,5 +393,6 @@ class Chat extends _$Chat {
|
|||||||
_channel = null;
|
_channel = null;
|
||||||
_typingTimer?.cancel();
|
_typingTimer?.cancel();
|
||||||
_typingTimer = null;
|
_typingTimer = null;
|
||||||
|
_connectedSessionId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ part of 'chat_notifier.dart';
|
|||||||
// RiverpodGenerator
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$chatHash() => r'b704f27f25fb06bbb266f394daf05ca12f518363';
|
String _$chatHash() => r'30e0aa41d2022e5bb080877e23fcb0a0c0c4b927';
|
||||||
|
|
||||||
/// See also [Chat].
|
/// See also [Chat].
|
||||||
@ProviderFor(Chat)
|
@ProviderFor(Chat)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import '../api/api_client_provider.dart';
|
import '../api/api_client_provider.dart';
|
||||||
|
import 'active_session_notifier.dart';
|
||||||
|
|
||||||
part 'session_closure_notifier.g.dart';
|
part 'session_closure_notifier.g.dart';
|
||||||
|
|
||||||
@@ -68,10 +69,14 @@ class SessionClosure extends _$SessionClosure {
|
|||||||
'message': message,
|
'message': message,
|
||||||
});
|
});
|
||||||
state = const ClosureCompleteData();
|
state = const ClosureCompleteData();
|
||||||
|
// The session is now completed server-side; refresh home snapshot so the
|
||||||
|
// CTA returns to "Mulai Curhat" without waiting for the next poll tick.
|
||||||
|
ref.invalidate(activeSessionProvider);
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
final code = e.response?.data?['error']?['code'];
|
final code = e.response?.data?['error']?['code'];
|
||||||
if (code == 'SESSION_NOT_ACTIVE' || e.response?.statusCode == 409) {
|
if (code == 'SESSION_NOT_ACTIVE' || e.response?.statusCode == 409) {
|
||||||
state = const ClosureCompleteData();
|
state = const ClosureCompleteData();
|
||||||
|
ref.invalidate(activeSessionProvider);
|
||||||
} else {
|
} else {
|
||||||
state = const ClosureErrorData('Gagal mengirim pesan penutup.');
|
state = const ClosureErrorData('Gagal mengirim pesan penutup.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ part of 'session_closure_notifier.dart';
|
|||||||
// RiverpodGenerator
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$sessionClosureHash() => r'22a7994290c3a0cc3c692a68063bdc8ffcb2bf87';
|
String _$sessionClosureHash() => r'f238e4098aefdd942314a13eb3fb77ed19cd2b77';
|
||||||
|
|
||||||
/// See also [SessionClosure].
|
/// See also [SessionClosure].
|
||||||
@ProviderFor(SessionClosure)
|
@ProviderFor(SessionClosure)
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|
||||||
import '../api/api_client_provider.dart';
|
|
||||||
|
|
||||||
part 'unread_notifier.g.dart';
|
|
||||||
|
|
||||||
@Riverpod(keepAlive: true)
|
|
||||||
class UnreadCount extends _$UnreadCount {
|
|
||||||
Timer? _pollTimer;
|
|
||||||
|
|
||||||
@override
|
|
||||||
int build() {
|
|
||||||
_startPolling();
|
|
||||||
ref.onDispose(_stopPolling);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _startPolling() {
|
|
||||||
_stopPolling();
|
|
||||||
_fetchUnreadCount();
|
|
||||||
_pollTimer = Timer.periodic(const Duration(seconds: 15), (_) {
|
|
||||||
_fetchUnreadCount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _stopPolling() {
|
|
||||||
_pollTimer?.cancel();
|
|
||||||
_pollTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _fetchUnreadCount() async {
|
|
||||||
try {
|
|
||||||
final apiClient = ref.read(apiClientProvider);
|
|
||||||
final response = await apiClient.get('/api/client/chat/session/active-with-unread');
|
|
||||||
final data = response['data'];
|
|
||||||
if (data is Map<String, dynamic>) {
|
|
||||||
state = data['unread_count'] as int? ?? 0;
|
|
||||||
} else {
|
|
||||||
state = 0;
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
void markRead() {
|
|
||||||
state = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
void refresh() => _fetchUnreadCount();
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
|
|
||||||
part of 'unread_notifier.dart';
|
|
||||||
|
|
||||||
// **************************************************************************
|
|
||||||
// RiverpodGenerator
|
|
||||||
// **************************************************************************
|
|
||||||
|
|
||||||
String _$unreadCountHash() => r'6a0b31b86ae616177f54346392d9675f916a7bec';
|
|
||||||
|
|
||||||
/// See also [UnreadCount].
|
|
||||||
@ProviderFor(UnreadCount)
|
|
||||||
final unreadCountProvider = NotifierProvider<UnreadCount, int>.internal(
|
|
||||||
UnreadCount.new,
|
|
||||||
name: r'unreadCountProvider',
|
|
||||||
debugGetCreateSourceHash:
|
|
||||||
const bool.fromEnvironment('dart.vm.product') ? null : _$unreadCountHash,
|
|
||||||
dependencies: null,
|
|
||||||
allTransitiveDependencies: null,
|
|
||||||
);
|
|
||||||
|
|
||||||
typedef _$UnreadCount = Notifier<int>;
|
|
||||||
// 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,6 +6,7 @@ import 'package:web_socket_channel/web_socket_channel.dart';
|
|||||||
import '../api/api_client.dart';
|
import '../api/api_client.dart';
|
||||||
import '../api/api_client_provider.dart';
|
import '../api/api_client_provider.dart';
|
||||||
import '../auth/auth_bridge.dart';
|
import '../auth/auth_bridge.dart';
|
||||||
|
import '../chat/active_session_notifier.dart';
|
||||||
import '../constants.dart';
|
import '../constants.dart';
|
||||||
|
|
||||||
part 'pairing_notifier.g.dart';
|
part 'pairing_notifier.g.dart';
|
||||||
@@ -148,6 +149,11 @@ class Pairing extends _$Pairing {
|
|||||||
final sessionId = data['session_id'] as String;
|
final sessionId = data['session_id'] as String;
|
||||||
state = PairingBestieFoundData(sessionId: sessionId, mitraName: mitraName);
|
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));
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
state = PairingActiveData(sessionId: sessionId, mitraName: mitraName);
|
state = PairingActiveData(sessionId: sessionId, mitraName: mitraName);
|
||||||
} else if (type == SessionStatus.expired) {
|
} else if (type == SessionStatus.expired) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ part of 'pairing_notifier.dart';
|
|||||||
// RiverpodGenerator
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$pairingHash() => r'a283e74d7cb4244bac74a950205c91d4b2cf3e9a';
|
String _$pairingHash() => r'e1c3074239cc4efda99885331ff91b3e0f903c8d';
|
||||||
|
|
||||||
/// See also [Pairing].
|
/// See also [Pairing].
|
||||||
@ProviderFor(Pairing)
|
@ProviderFor(Pairing)
|
||||||
|
|||||||
@@ -46,7 +46,10 @@ class _ChatHistoryScreenState extends ConsumerState<ChatHistoryScreen> {
|
|||||||
itemCount: _sessions.length,
|
itemCount: _sessions.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final s = _sessions[index];
|
final s = _sessions[index];
|
||||||
|
final sessionId = s['id'] as String;
|
||||||
final mitraName = s['mitra_display_name'] as String? ?? 'Bestie';
|
final mitraName = s['mitra_display_name'] as String? ?? 'Bestie';
|
||||||
|
final status = s['status'] as String?;
|
||||||
|
final isClosing = status == 'closing';
|
||||||
final endedAt = s['ended_at'] != null
|
final endedAt = s['ended_at'] != null
|
||||||
? DateTime.parse(s['ended_at'] as String).toLocal()
|
? DateTime.parse(s['ended_at'] as String).toLocal()
|
||||||
: null;
|
: null;
|
||||||
@@ -55,17 +58,51 @@ class _ChatHistoryScreenState extends ConsumerState<ChatHistoryScreen> {
|
|||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: const CircleAvatar(child: Icon(Icons.person)),
|
leading: const CircleAvatar(child: Icon(Icons.person)),
|
||||||
title: Text(mitraName),
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Flexible(child: Text(mitraName, overflow: TextOverflow.ellipsis)),
|
||||||
|
if (isClosing) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const _OutstandingClosureBadge(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
subtitle: Text([
|
subtitle: Text([
|
||||||
if (endedAt != null) '${endedAt.day}/${endedAt.month}/${endedAt.year}',
|
if (endedAt != null) '${endedAt.day}/${endedAt.month}/${endedAt.year}',
|
||||||
if (duration != null) '$duration menit',
|
if (duration != null) '$duration menit',
|
||||||
if (closureMsg != null) '"$closureMsg"',
|
if (closureMsg != null) '"$closureMsg"',
|
||||||
].join(' - ')),
|
].join(' - ')),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () => context.push('/chat/history/${s['id']}'),
|
onTap: () => isClosing
|
||||||
|
? context.push('/chat/session/$sessionId', extra: mitraName)
|
||||||
|
: context.push('/chat/history/$sessionId'),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _OutstandingClosureBadge extends StatelessWidget {
|
||||||
|
const _OutstandingClosureBadge();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.amber.shade100,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: Colors.amber.shade700, width: 0.5),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Belum ditutup',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: Colors.amber.shade900,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../../core/chat/active_session_notifier.dart';
|
||||||
import '../../../core/chat/chat_notifier.dart';
|
import '../../../core/chat/chat_notifier.dart';
|
||||||
import '../../../core/chat/session_closure_notifier.dart';
|
import '../../../core/chat/session_closure_notifier.dart';
|
||||||
import '../../../core/constants.dart';
|
import '../../../core/constants.dart';
|
||||||
@@ -12,6 +13,8 @@ const _kUserBubbleColor = Color(0xFFD4929A);
|
|||||||
const _kBgTint = Color(0xFFF5D0D6);
|
const _kBgTint = Color(0xFFF5D0D6);
|
||||||
const _kBannerColor = Color(0xFFC4868F);
|
const _kBannerColor = Color(0xFFC4868F);
|
||||||
const _kAccentPink = Color(0xFFBE7C8A);
|
const _kAccentPink = Color(0xFFBE7C8A);
|
||||||
|
const _kEndedBannerColor = Color(0xFFFFE0B2); // soft amber for early-end notice
|
||||||
|
const _kEndedBannerText = Color(0xFF8B5A00);
|
||||||
|
|
||||||
class ChatScreen extends ConsumerStatefulWidget {
|
class ChatScreen extends ConsumerStatefulWidget {
|
||||||
final String sessionId;
|
final String sessionId;
|
||||||
@@ -25,27 +28,37 @@ class ChatScreen extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _ChatScreenState extends ConsumerState<ChatScreen> {
|
class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||||
final _messageController = TextEditingController();
|
final _messageController = TextEditingController();
|
||||||
|
final _goodbyeController = TextEditingController();
|
||||||
final _scrollController = ScrollController();
|
final _scrollController = ScrollController();
|
||||||
Timer? _typingThrottle;
|
Timer? _typingThrottle;
|
||||||
bool _showBestieBanner = true;
|
bool _showBestieBanner = true;
|
||||||
bool _showUserBanner = true;
|
bool _showUserBanner = true;
|
||||||
|
bool _expiredDialogShown = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
// The chat WebSocket is owned globally by `App` (see main.dart).
|
||||||
|
// We just ask it to be on this session — no-op if it already is.
|
||||||
Future.microtask(() {
|
Future.microtask(() {
|
||||||
ref.read(chatProvider.notifier).connect(widget.sessionId);
|
// Reset any closure state left over from a prior session view (the
|
||||||
|
// closure notifier is keepAlive, so e.g. `ClosureCompleteData` from
|
||||||
|
// the last goodbye submission would otherwise leak into this mount
|
||||||
|
// and suppress the goodbye composer for a fresh `closing` session).
|
||||||
|
ref.read(sessionClosureProvider.notifier).reset();
|
||||||
|
ref.read(chatProvider.notifier).connectIfNotConnected(widget.sessionId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
final notifier = ref.read(chatProvider.notifier);
|
|
||||||
_messageController.dispose();
|
_messageController.dispose();
|
||||||
|
_goodbyeController.dispose();
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
_typingThrottle?.cancel();
|
_typingThrottle?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
Future.microtask(() => notifier.disconnect());
|
// Intentionally do NOT disconnect the WS here. The global lifecycle in
|
||||||
|
// `App` decides when to disconnect (logout / no active session).
|
||||||
}
|
}
|
||||||
|
|
||||||
void _scrollToBottom() {
|
void _scrollToBottom() {
|
||||||
@@ -82,6 +95,38 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _showSessionExpiredDialog() async {
|
||||||
|
if (_expiredDialogShown) return;
|
||||||
|
_expiredDialogShown = true;
|
||||||
|
if (!mounted) return;
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text('Waktu Curhat Berakhir'),
|
||||||
|
content: const Text(
|
||||||
|
'Sesi curhatmu sudah habis waktunya. Kamu bisa menutup obrolan atau memperpanjang waktu untuk lanjut bicara.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(dialogContext).pop();
|
||||||
|
_exitChat();
|
||||||
|
},
|
||||||
|
child: const Text('Tutup'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(dialogContext).pop();
|
||||||
|
PricingBottomSheet.showForExtension(context, sessionId: widget.sessionId);
|
||||||
|
},
|
||||||
|
child: const Text('Perpanjang'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final chatState = ref.watch(chatProvider);
|
final chatState = ref.watch(chatProvider);
|
||||||
@@ -90,24 +135,35 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
|||||||
// Listen for closure complete to navigate home
|
// Listen for closure complete to navigate home
|
||||||
ref.listen(sessionClosureProvider, (prev, next) {
|
ref.listen(sessionClosureProvider, (prev, next) {
|
||||||
if (next is ClosureCompleteData) {
|
if (next is ClosureCompleteData) {
|
||||||
|
// Make doubly sure home picks up the cleared session.
|
||||||
|
ref.invalidate(activeSessionProvider);
|
||||||
context.go('/home');
|
context.go('/home');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for chat state changes to manage closure state
|
// Listen for chat state changes to manage closure state and timer-expired modal
|
||||||
ref.listen(chatProvider, (prev, next) {
|
ref.listen(chatProvider, (prev, next) {
|
||||||
if (next is ChatConnectedData) {
|
if (next is ChatConnectedData) {
|
||||||
|
// Early-end (mitra/customer ended before timer): show goodbye composer.
|
||||||
if (next.sessionClosing && !next.sessionExpired) {
|
if (next.sessionClosing && !next.sessionExpired) {
|
||||||
final closure = ref.read(sessionClosureProvider);
|
final closure = ref.read(sessionClosureProvider);
|
||||||
if (closure is ClosureInitialData) {
|
if (closure is ClosureInitialData) {
|
||||||
ref.read(sessionClosureProvider.notifier).declineExtension();
|
ref.read(sessionClosureProvider.notifier).declineExtension();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Timer-expired: show non-dismissible modal once on false→true flip.
|
||||||
|
final wasExpired = prev is ChatConnectedData && prev.sessionExpired;
|
||||||
|
if (next.sessionExpired && !wasExpired) {
|
||||||
|
_showSessionExpiredDialog();
|
||||||
|
}
|
||||||
if (!next.sessionPaused && !next.sessionExpired && !next.sessionClosing) {
|
if (!next.sessionPaused && !next.sessionExpired && !next.sessionClosing) {
|
||||||
final closure = ref.read(sessionClosureProvider);
|
final closure = ref.read(sessionClosureProvider);
|
||||||
if (closure is! ClosureInitialData) {
|
if (closure is! ClosureInitialData) {
|
||||||
ref.read(sessionClosureProvider.notifier).reset();
|
ref.read(sessionClosureProvider.notifier).reset();
|
||||||
}
|
}
|
||||||
|
// If we're back to a healthy active state, allow the modal to fire
|
||||||
|
// again on a later expiry (e.g. after extension then re-expiry).
|
||||||
|
_expiredDialogShown = false;
|
||||||
}
|
}
|
||||||
_scrollToBottom();
|
_scrollToBottom();
|
||||||
final unread = next.messages
|
final unread = next.messages
|
||||||
@@ -116,6 +172,8 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
|||||||
.toList();
|
.toList();
|
||||||
if (unread.isNotEmpty) {
|
if (unread.isNotEmpty) {
|
||||||
ref.read(chatProvider.notifier).markRead(unread);
|
ref.read(chatProvider.notifier).markRead(unread);
|
||||||
|
// Optimistically clear the home badge.
|
||||||
|
ref.read(activeSessionProvider.notifier).markRead();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -171,7 +229,16 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildChatBody(ChatConnectedData state, SessionClosureData closureState) {
|
Widget _buildChatBody(ChatConnectedData state, SessionClosureData closureState) {
|
||||||
if (closureState is ClosureShowGoodbyeData || closureState is ClosureSubmittingData) {
|
// Show goodbye composer when closure flow is in goodbye/submitting OR when
|
||||||
|
// we mounted directly into a `closing` session (e.g. opened from history).
|
||||||
|
// The chatProvider listener can't catch this case because it only fires on
|
||||||
|
// transitions, not the current state at mount time.
|
||||||
|
final shouldShowGoodbye = closureState is ClosureShowGoodbyeData ||
|
||||||
|
closureState is ClosureSubmittingData ||
|
||||||
|
(state.sessionClosing &&
|
||||||
|
!state.sessionExpired &&
|
||||||
|
closureState is! ClosureCompleteData);
|
||||||
|
if (shouldShowGoodbye) {
|
||||||
return _buildGoodbyeView(closureState);
|
return _buildGoodbyeView(closureState);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,11 +295,8 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
|||||||
child: Text('Bestie sedang mengetik...', style: TextStyle(color: Colors.grey, fontSize: 12)),
|
child: Text('Bestie sedang mengetik...', style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Input bar or session ended bar
|
// Input bar — disabled when timer expired (modal handles next step)
|
||||||
if (state.sessionExpired)
|
if (!state.sessionExpired) _buildInputBar(),
|
||||||
_buildSessionEndedBar()
|
|
||||||
else
|
|
||||||
_buildInputBar(),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -352,76 +416,66 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSessionEndedBar() {
|
|
||||||
return SafeArea(
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: _kBgTint,
|
|
||||||
border: Border(top: BorderSide(color: Colors.grey.shade300)),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.access_time, color: Colors.red, size: 18),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
const Expanded(
|
|
||||||
child: Text(
|
|
||||||
'Waktu Curhat Berakhir',
|
|
||||||
style: TextStyle(color: Colors.red, fontWeight: FontWeight.w500),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () => PricingBottomSheet.showForExtension(context, sessionId: widget.sessionId),
|
|
||||||
child: const Text(
|
|
||||||
'Curhat Lagi',
|
|
||||||
style: TextStyle(color: _kAccentPink, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildGoodbyeView(SessionClosureData closureState) {
|
Widget _buildGoodbyeView(SessionClosureData closureState) {
|
||||||
final controller = TextEditingController();
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.all(32),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 48),
|
// Early-end banner — visually distinct, separate from the closing
|
||||||
|
// composer below. We don't surface "Perpanjang" in this flow.
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _kEndedBannerColor,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.info_outline, color: _kEndedBannerText, size: 20),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Sesi telah ditutup oleh Bestie',
|
||||||
|
style: TextStyle(color: _kEndedBannerText, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
const Icon(Icons.waving_hand, size: 64, color: Colors.amber),
|
const Icon(Icons.waving_hand, size: 64, color: Colors.amber),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text('Pesan Penutup', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
const Text('Pesan Penutup', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
const Text('Tuliskan pesan terakhirmu untuk Bestie', textAlign: TextAlign.center),
|
const Text('Tuliskan pesan terakhirmu untuk Bestie', textAlign: TextAlign.center),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
TextField(
|
TextField(
|
||||||
controller: controller,
|
controller: _goodbyeController,
|
||||||
maxLines: 3,
|
maxLines: 3,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Terima kasih, Bestie...',
|
hintText: 'Terima kasih, Bestie...',
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
),
|
||||||
ElevatedButton(
|
const SizedBox(height: 16),
|
||||||
onPressed: closureState is ClosureSubmittingData
|
ElevatedButton(
|
||||||
? null
|
onPressed: closureState is ClosureSubmittingData
|
||||||
: () {
|
? null
|
||||||
final text = controller.text.trim();
|
: () {
|
||||||
if (text.isNotEmpty) {
|
final text = _goodbyeController.text.trim();
|
||||||
ref.read(sessionClosureProvider.notifier).submitGoodbye(
|
if (text.isNotEmpty) {
|
||||||
widget.sessionId, text,
|
ref.read(sessionClosureProvider.notifier).submitGoodbye(
|
||||||
);
|
widget.sessionId, text,
|
||||||
}
|
);
|
||||||
},
|
}
|
||||||
child: closureState is ClosureSubmittingData
|
},
|
||||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
child: closureState is ClosureSubmittingData
|
||||||
: const Text('Kirim & Selesai'),
|
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
),
|
: const Text('Kirim & Selesai'),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
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';
|
|
||||||
|
|
||||||
class SessionActiveScreen extends ConsumerWidget {
|
|
||||||
final String sessionId;
|
|
||||||
final String mitraName;
|
|
||||||
|
|
||||||
const SessionActiveScreen({
|
|
||||||
super.key,
|
|
||||||
required this.sessionId,
|
|
||||||
required this.mitraName,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('Sesi Aktif'),
|
|
||||||
automaticallyImplyLeading: false,
|
|
||||||
),
|
|
||||||
body: Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(32),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.chat_bubble, size: 80, color: Colors.blue),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
Text(
|
|
||||||
'Terhubung dengan $mitraName',
|
|
||||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
const Text(
|
|
||||||
'Sesi chat akan tersedia di fase berikutnya.',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(fontSize: 14, color: Colors.grey),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 48),
|
|
||||||
ElevatedButton(
|
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
|
||||||
onPressed: () => _endSession(context, ref),
|
|
||||||
child: const Text('Akhiri Sesi', style: TextStyle(color: Colors.white)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _endSession(BuildContext context, WidgetRef ref) async {
|
|
||||||
final confirmed = await showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => AlertDialog(
|
|
||||||
title: const Text('Akhiri Sesi?'),
|
|
||||||
content: const Text('Apakah kamu yakin ingin mengakhiri sesi ini?'),
|
|
||||||
actions: [
|
|
||||||
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Batal')),
|
|
||||||
TextButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Ya, Akhiri')),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (confirmed == true && context.mounted) {
|
|
||||||
try {
|
|
||||||
final apiClient = ref.read(apiClientProvider);
|
|
||||||
await apiClient.post('/api/client/chat/session/$sessionId/end');
|
|
||||||
if (context.mounted) context.go('/home');
|
|
||||||
} catch (_) {
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Gagal mengakhiri sesi. Coba lagi.')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,8 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../core/auth/auth_notifier.dart';
|
import '../../core/auth/auth_notifier.dart';
|
||||||
import '../../core/api/api_client_provider.dart';
|
import '../../core/chat/active_session_notifier.dart';
|
||||||
import '../../core/chat/unread_notifier.dart';
|
|
||||||
import '../../core/pairing/pairing_notifier.dart';
|
import '../../core/pairing/pairing_notifier.dart';
|
||||||
import '../chat/widgets/pricing_bottom_sheet.dart';
|
import '../chat/widgets/pricing_bottom_sheet.dart';
|
||||||
import '../chat/widgets/topic_selection_bottom_sheet.dart';
|
import '../chat/widgets/topic_selection_bottom_sheet.dart';
|
||||||
@@ -16,14 +15,10 @@ class HomeScreen extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObserver {
|
class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObserver {
|
||||||
Map<String, dynamic>? _activeSession;
|
|
||||||
bool _loadingSession = true;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_checkActiveSession();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -35,29 +30,8 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
|
|||||||
@override
|
@override
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
if (state == AppLifecycleState.resumed) {
|
if (state == AppLifecycleState.resumed) {
|
||||||
_checkActiveSession();
|
// Re-fetch in case a session ended/started while backgrounded.
|
||||||
}
|
ref.read(activeSessionProvider.notifier).refresh();
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didChangeDependencies() {
|
|
||||||
super.didChangeDependencies();
|
|
||||||
_checkActiveSession();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _checkActiveSession() async {
|
|
||||||
try {
|
|
||||||
final apiClient = ref.read(apiClientProvider);
|
|
||||||
final response = await apiClient.get('/api/client/chat/session/active');
|
|
||||||
final data = response['data'];
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_activeSession = data is Map<String, dynamic> ? data : null;
|
|
||||||
_loadingSession = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
if (mounted) setState(() => _loadingSession = false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +45,7 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final authState = ref.watch(authProvider);
|
final authState = ref.watch(authProvider);
|
||||||
final authData = authState.valueOrNull;
|
final authData = authState.valueOrNull;
|
||||||
|
final activeSessionAsync = ref.watch(activeSessionProvider);
|
||||||
|
|
||||||
final displayName = switch (authData) {
|
final displayName = switch (authData) {
|
||||||
AuthAuthenticatedData d => d.profile['display_name'] as String? ?? '',
|
AuthAuthenticatedData d => d.profile['display_name'] as String? ?? '',
|
||||||
@@ -112,27 +87,31 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
|
|||||||
children: [
|
children: [
|
||||||
Text('Halo, $displayName!', style: const TextStyle(fontSize: 24)),
|
Text('Halo, $displayName!', style: const TextStyle(fontSize: 24)),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
if (_loadingSession)
|
activeSessionAsync.when(
|
||||||
const CircularProgressIndicator()
|
loading: () => const CircularProgressIndicator(),
|
||||||
else if (_activeSession != null)
|
error: (_, __) => _StartChatButton(onPressed: () => _onStartChatPressed(context)),
|
||||||
_ActiveSessionCard(
|
data: (snapshot) {
|
||||||
session: _activeSession!,
|
// Hide the "Sesi Aktif" CTA when the session is in `closing`
|
||||||
onTap: () {
|
// — the conversation is over, only the goodbye composer
|
||||||
final sessionId = _activeSession!['id'] as String;
|
// remains. Backend auto-completes such sessions after a
|
||||||
final mitraName = _activeSession!['mitra_display_name'] as String? ?? 'Bestie';
|
// grace period; until then the user shouldn't be invited
|
||||||
context.push('/chat/session/$sessionId', extra: mitraName);
|
// back into them from home.
|
||||||
},
|
final status = snapshot.session?['status'] as String?;
|
||||||
)
|
final isCurhatable = snapshot.hasSession && status != 'closing';
|
||||||
else ...[
|
if (isCurhatable) {
|
||||||
const SizedBox(height: 16),
|
return _ActiveSessionCard(
|
||||||
ElevatedButton(
|
mitraName: snapshot.mitraName,
|
||||||
style: ElevatedButton.styleFrom(
|
unreadCount: snapshot.unreadCount,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16),
|
onTap: () {
|
||||||
),
|
final sessionId = snapshot.sessionId;
|
||||||
onPressed: () => _onStartChatPressed(context),
|
if (sessionId == null) return;
|
||||||
child: const Text('Mulai Curhat', style: TextStyle(fontSize: 18)),
|
context.push('/chat/session/$sessionId', extra: snapshot.mitraName);
|
||||||
),
|
},
|
||||||
],
|
);
|
||||||
|
}
|
||||||
|
return _StartChatButton(onPressed: () => _onStartChatPressed(context));
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -141,17 +120,40 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ActiveSessionCard extends ConsumerWidget {
|
class _StartChatButton extends StatelessWidget {
|
||||||
final Map<String, dynamic> session;
|
final VoidCallback onPressed;
|
||||||
final VoidCallback onTap;
|
const _StartChatButton({required this.onPressed});
|
||||||
|
|
||||||
const _ActiveSessionCard({required this.session, required this.onTap});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context) {
|
||||||
final mitraName = session['mitra_display_name'] as String? ?? 'Bestie';
|
return Column(
|
||||||
final unreadCount = ref.watch(unreadCountProvider);
|
children: [
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16),
|
||||||
|
),
|
||||||
|
onPressed: onPressed,
|
||||||
|
child: const Text('Mulai Curhat', style: TextStyle(fontSize: 18)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ActiveSessionCard extends StatelessWidget {
|
||||||
|
final String mitraName;
|
||||||
|
final int unreadCount;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _ActiveSessionCard({
|
||||||
|
required this.mitraName,
|
||||||
|
required this.unreadCount,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'core/api/api_client_provider.dart';
|
import 'core/api/api_client_provider.dart';
|
||||||
import 'core/auth/auth_notifier.dart';
|
import 'core/auth/auth_notifier.dart';
|
||||||
|
import 'core/chat/active_session_notifier.dart';
|
||||||
|
import 'core/chat/chat_notifier.dart';
|
||||||
import 'core/notifications/notification_service.dart';
|
import 'core/notifications/notification_service.dart';
|
||||||
import 'firebase_options.dart';
|
import 'firebase_options.dart';
|
||||||
import 'router.dart';
|
import 'router.dart';
|
||||||
@@ -45,10 +47,33 @@ class _AppState extends ConsumerState<App> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// FCM registration on auth.
|
||||||
ref.listen(authProvider, (prev, next) {
|
ref.listen(authProvider, (prev, next) {
|
||||||
final data = next.valueOrNull;
|
final data = next.valueOrNull;
|
||||||
if (data is AuthAuthenticatedData || data is AuthAnonymousData) {
|
if (data is AuthAuthenticatedData || data is AuthAnonymousData) {
|
||||||
_registerFcmToken();
|
_registerFcmToken();
|
||||||
|
} else {
|
||||||
|
// Logged out (or initial) — ensure the chat WS is closed.
|
||||||
|
ref.read(chatProvider.notifier).disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global chat WebSocket lifecycle: connect whenever the user has an
|
||||||
|
// active session, regardless of which screen is mounted. The chat screen
|
||||||
|
// only joins this connection — it doesn't own it. FCM remains the
|
||||||
|
// background-only fallback.
|
||||||
|
ref.listen(activeSessionProvider, (prev, next) {
|
||||||
|
final snapshot = next.valueOrNull;
|
||||||
|
final notifier = ref.read(chatProvider.notifier);
|
||||||
|
if (snapshot == null || !snapshot.hasSession) {
|
||||||
|
if (notifier.connectedSessionId != null) {
|
||||||
|
notifier.disconnect();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final sessionId = snapshot.sessionId;
|
||||||
|
if (sessionId != null && notifier.connectedSessionId != sessionId) {
|
||||||
|
notifier.connectIfNotConnected(sessionId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ android {
|
|||||||
applicationId = "com.halobestie.mitra.mitra_app"
|
applicationId = "com.halobestie.mitra.mitra_app"
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = 24
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
|
|||||||
@@ -48,7 +48,10 @@ class _MitraChatHistoryScreenState extends ConsumerState<MitraChatHistoryScreen>
|
|||||||
itemCount: _sessions.length,
|
itemCount: _sessions.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final s = _sessions[index];
|
final s = _sessions[index];
|
||||||
|
final sessionId = s['id'] as String;
|
||||||
final customerName = s['customer_display_name'] as String? ?? 'Customer';
|
final customerName = s['customer_display_name'] as String? ?? 'Customer';
|
||||||
|
final status = s['status'] as String?;
|
||||||
|
final isClosing = status == 'closing';
|
||||||
final endedAt = s['ended_at'] != null
|
final endedAt = s['ended_at'] != null
|
||||||
? DateTime.parse(s['ended_at'] as String).toLocal()
|
? DateTime.parse(s['ended_at'] as String).toLocal()
|
||||||
: null;
|
: null;
|
||||||
@@ -66,6 +69,10 @@ class _MitraChatHistoryScreenState extends ConsumerState<MitraChatHistoryScreen>
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
SensitivityBadge(sensitivity: topic, fontSize: 10),
|
SensitivityBadge(sensitivity: topic, fontSize: 10),
|
||||||
],
|
],
|
||||||
|
if (isClosing) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const _OutstandingClosureBadge(),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
subtitle: Text([
|
subtitle: Text([
|
||||||
@@ -74,10 +81,36 @@ class _MitraChatHistoryScreenState extends ConsumerState<MitraChatHistoryScreen>
|
|||||||
if (closureMsg != null) '"$closureMsg"',
|
if (closureMsg != null) '"$closureMsg"',
|
||||||
].join(' - ')),
|
].join(' - ')),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () => context.push('/chat/history/${s['id']}'),
|
onTap: () => isClosing
|
||||||
|
? context.push('/chat/session/$sessionId', extra: customerName)
|
||||||
|
: context.push('/chat/history/$sessionId'),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _OutstandingClosureBadge extends StatelessWidget {
|
||||||
|
const _OutstandingClosureBadge();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.amber.shade100,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: Colors.amber.shade700, width: 0.5),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Belum ditutup',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: Colors.amber.shade900,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class MitraChatScreen extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
|
class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
|
||||||
final _messageController = TextEditingController();
|
final _messageController = TextEditingController();
|
||||||
|
final _goodbyeController = TextEditingController();
|
||||||
final _scrollController = ScrollController();
|
final _scrollController = ScrollController();
|
||||||
Timer? _typingThrottle;
|
Timer? _typingThrottle;
|
||||||
bool _showBestieBanner = true;
|
bool _showBestieBanner = true;
|
||||||
@@ -43,6 +44,7 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
final notifier = ref.read(mitraChatProvider.notifier);
|
final notifier = ref.read(mitraChatProvider.notifier);
|
||||||
_messageController.dispose();
|
_messageController.dispose();
|
||||||
|
_goodbyeController.dispose();
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
_typingThrottle?.cancel();
|
_typingThrottle?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -503,7 +505,6 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildGoodbyeView(ExtensionData extState) {
|
Widget _buildGoodbyeView(ExtensionData extState) {
|
||||||
final controller = TextEditingController();
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.all(32),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -516,7 +517,7 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
|
|||||||
const Text('Tuliskan pesan terakhirmu untuk Customer', textAlign: TextAlign.center),
|
const Text('Tuliskan pesan terakhirmu untuk Customer', textAlign: TextAlign.center),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
TextField(
|
TextField(
|
||||||
controller: controller,
|
controller: _goodbyeController,
|
||||||
maxLines: 3,
|
maxLines: 3,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Terima kasih sudah curhat...',
|
hintText: 'Terima kasih sudah curhat...',
|
||||||
@@ -528,7 +529,7 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
|
|||||||
onPressed: extState is ExtensionSubmittingData
|
onPressed: extState is ExtensionSubmittingData
|
||||||
? null
|
? null
|
||||||
: () {
|
: () {
|
||||||
final text = controller.text.trim();
|
final text = _goodbyeController.text.trim();
|
||||||
if (text.isNotEmpty) {
|
if (text.isNotEmpty) {
|
||||||
ref.read(mitraExtensionProvider.notifier).submitGoodbye(
|
ref.read(mitraExtensionProvider.notifier).submitGoodbye(
|
||||||
widget.sessionId, text,
|
widget.sessionId, text,
|
||||||
|
|||||||
Reference in New Issue
Block a user