Compare commits

...

14 Commits

Author SHA1 Message Date
bb0346a843 Fix overlay: Directionality widget + startListening state guard
- Wrap overlay Stack with Directionality (required above MaterialApp)
- Guard startListening() for IncomingData/StaleData states to prevent
  overlay dismissal when status reloads on app resume

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 23:09:36 +08:00
4c6130aa04 Phase 3.2 WS2: Mitra request activity log + control center page
- DB migration: add active_session_count column + mitra_notified index
- Constants: add MISSED to NotificationResponse
- Pairing service: record active_session_count on notification creation,
  use MISSED (not IGNORED) when another mitra accepts first
- New mitra-activity.service.js: getMitraActivityLog (paginated),
  getMitraActivitySummary (per-mitra aggregates with acceptance rate)
- New mitra-activity.routes.js: GET /internal/mitra-activity/log,
  GET /internal/mitra-activity/summary
- Control center: new MitraActivityPage with summary table + detail log,
  filters (mitra, date range), color-coded response types, pagination
- Register route in App.jsx, add "Aktivitas Mitra" nav link in Layout

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 22:20:52 +08:00
b9c4841eb1 Phase 3.2 WS1: Chat request overlay, queue, stale reasons
- Backend: add reason field to chat_request_closed WS messages
  (cancelled_by_customer, accepted_by_other, expired)
- Backend: include duration_minutes, is_free_trial in chat_request WS
- ChatRequestNotifier: add ChatRequestStaleData, StaleReason enum,
  request queue (List<Map>), ignore(), acknowledgeStale(), _advanceQueue()
- New ChatRequestOverlay widget: slides up from bottom, dimmed background,
  swipe to dismiss, shows active/stale request content
- Integrate overlay in main.dart wrapping MaterialApp.router
- Cleanup: convert HomeScreen to ConsumerWidget, remove showModalBottomSheet,
  remove IncomingRequestSheet, remove lifecycle observer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 22:16:30 +08:00
4158fb9432 Phase 3.2 docs + Phase 3.1 testing fixes
- Add phase3.2.md requirement: overlay UX, mitra activity log
- Add phase3.2-plan.md implementation plan
- Fix stale request validation: add GET /:sessionId/status endpoint
- Fix notification tap flow: setIncomingFromNotification + onChatRequestTapped
- IncomingRequestSheet shows stale message instead of auto-dismiss
- Home screen validates on resume, shows immediately on fresh WS

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 22:09:25 +08:00
e3da863f3c Validate stale chat requests, show info instead of auto-dismiss
- Add validateIncomingRequest() — checks session status with backend
- Home screen validates before showing sheet (on resume + listener)
- IncomingRequestSheet shows "cancelled/accepted by other" message
  instead of silently dismissing when request becomes stale

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:40:52 +08:00
212e1e8ac6 Fix auth: auto-create customer, display name flow, OTP auto-verify
- Backend: getOrCreateCustomer with phone fallback for re-login
- Backend: PATCH /api/client/auth/profile for display name update
- Client app: AuthNeedsDisplayNameData state + SetDisplayNameScreen
- Client app: ApiClient.patch method
- Both apps: handle verificationCompleted for auto-verify (test numbers)
- Both apps: skip credential sign-in if already auto-verified
- Remove debug prints from mitra auth + OTP screens
- Fix ChatRequestNotifier.startListening skips when accepting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:22:28 +08:00
2e80434e9b Phase 3.1: Local notification for WS chat requests, router fix, cleanup
- Show local notification (sound + vibrate) when chat_request arrives
  via WebSocket while mitra app is backgrounded
- Add NotificationService.showLocalNotification() for programmatic use
- Fix router redirect: don't redirect auth routes to splash during loading
- Handle binary/string WebSocket frames in ChatRequestNotifier
- Remove debug logging from backend and Flutter
- Control center: mitra ping config UI
- Both apps: dynamic ping, FCM deep-linking, unread badges

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:57:36 +08:00
1b249e34b0 Fix router redirect breaking OTP flow on both apps
AsyncLoading during OTP request was redirecting from /login to /splash,
bouncing users back to login. Now auth routes stay put during loading —
only redirect to splash from non-auth routes (initial app startup).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:38:48 +08:00
229f889551 Phase 3.1 WS2: FCM fallback Flutter + CC, unread badges, dynamic ping
- Control center: add mitra ping config UI (require ping toggle + interval)
- Mitra app StatusNotifier: honor require_ping and ping_interval_seconds
  from API; skip heartbeat when ping not required
- Both apps: update notification services for FCM deep-linking
  - mitra_app: handle chat_request (open_accept), session_closing
  - client_app: handle session_closing, paired
- Unread badge providers:
  - mitra_app: UnreadSessions provider (polls active-with-unread, badge
    on active sessions button)
  - client_app: UnreadCount provider (polls active-with-unread, badge
    on _ActiveSessionCard)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:29:06 +08:00
ed765d230c Phase 3.1 WS2: Backend FCM fallback, ping config, unread API
- Add require_mitra_ping + mitra_ping_interval_seconds config keys (migration)
- Add getMitraPingConfig/setMitraPingConfig to config service
- Add GET/PATCH /internal/config/mitra-ping routes for control center
- Update mitra status service: honor ping config in auto-offline sweep,
  include ping config in GET /api/mitra/status response
- Enhance pairing FCM payload with action: 'open_accept' for deep-link
- Add FCM fallback to closure.service (initiateEarlyEnd, completeSession)
- Add FCM fallback to session-timer.service (onSessionExpired)
- Add unread count queries (getActiveSessionByCustomerWithUnread,
  getActiveSessionsByMitraWithUnread)
- Add GET /api/client/chat/session/active-with-unread route
- Add GET /api/mitra/chat-requests/sessions/active-with-unread route

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:22:41 +08:00
fa8c963d92 Phase 3.1: Remove flutter_bloc + equatable, delete old bloc files
- Remove flutter_bloc and equatable dependencies from both apps
- Delete all 10 old bloc files (5 per app)
- Fix 6 remaining screens that used context.read<ApiClient>() from
  flutter_bloc → converted to ConsumerStatefulWidget/ConsumerWidget
  with ref.read(apiClientProvider)
- Both apps now use Riverpod exclusively for state management

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:12:28 +08:00
35d470b851 Phase 3.1: Complete mitra_app Riverpod migration (all blocs, fix auth bug)
- Migrate AuthBloc → MitraAuthNotifier (fixes stuck-loading bug: now returns
  MitraAuthInitialData when currentUser is null)
- Migrate StatusBloc → OnlineStatusNotifier (heartbeat timer + lifecycle)
- Migrate ExtensionBloc → MitraExtensionNotifier (accept/reject + goodbye)
- Migrate ChatRequestBloc → ChatRequestNotifier (WebSocket incoming requests)
- Migrate MitraChatBloc → MitraChatNotifier (WebSocket chat + messages)
- Update router to use Riverpod auth state for redirects
- Remove all flutter_bloc usage from mitra_app screens and main.dart
- MultiBlocProvider fully removed from mitra_app

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:08:45 +08:00
bc66bbf50a Phase 3.1: Complete client_app Riverpod migration (all blocs)
- Migrate SessionClosureBloc → SessionClosureNotifier (@riverpod)
- Migrate PairingBloc → PairingNotifier (@riverpod, WebSocket + timer)
- Migrate ChatBloc → ChatNotifier (@riverpod, WebSocket + message state)
- Remove all flutter_bloc usage from client_app screens and main.dart
- MultiBlocProvider fully removed from client_app
- All screens now use ConsumerWidget/ConsumerStatefulWidget + ref

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:01:48 +08:00
d15b2f05fc Phase 3.1 WIP: Riverpod migration (client_app Auth + ChatOpening)
- Add phase3.1 requirement and implementation plan docs
- Add Riverpod dependencies to both client_app and mitra_app
- Wrap both app roots with ProviderScope
- Migrate client_app AuthBloc → AuthNotifier (@riverpod annotation)
- Migrate client_app ChatOpeningBloc → chatPricingProvider (FutureProvider)
- Update router to use Riverpod-based auth state for redirects
- Update all auth screens (display name, register, OTP, force register)
- Update home screen and pricing bottom sheet
- Add android:usesCleartextTraffic for dev HTTP access on both apps
- mitra_app prepared with ProviderScope + ApiClient provider (blocs next)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:51:17 +08:00
96 changed files with 5933 additions and 2870 deletions

View File

@@ -6,6 +6,7 @@ import { rolesRoutes } from './routes/internal/roles.routes.js'
import { internalAuthRoutes } from './routes/internal/auth.routes.js' import { internalAuthRoutes } from './routes/internal/auth.routes.js'
import { internalConfigRoutes } from './routes/internal/config.routes.js' import { internalConfigRoutes } from './routes/internal/config.routes.js'
import { sessionManagementRoutes } from './routes/internal/session.routes.js' import { sessionManagementRoutes } from './routes/internal/session.routes.js'
import { mitraActivityRoutes } from './routes/internal/mitra-activity.routes.js'
import { errorHandler } from './plugins/error-handler.js' import { errorHandler } from './plugins/error-handler.js'
export const buildInternalApp = async () => { export const buildInternalApp = async () => {
@@ -20,6 +21,7 @@ export const buildInternalApp = async () => {
app.register(rolesRoutes, { prefix: '/internal/roles' }) app.register(rolesRoutes, { prefix: '/internal/roles' })
app.register(internalConfigRoutes, { prefix: '/internal/config' }) app.register(internalConfigRoutes, { prefix: '/internal/config' })
app.register(sessionManagementRoutes, { prefix: '/internal/sessions' }) app.register(sessionManagementRoutes, { prefix: '/internal/sessions' })
app.register(mitraActivityRoutes, { prefix: '/internal/mitra-activity' })
return app return app
} }

View File

@@ -33,6 +33,7 @@ export const MessageType = Object.freeze({
export const NotificationResponse = Object.freeze({ export const NotificationResponse = Object.freeze({
ACCEPTED: 'accepted', ACCEPTED: 'accepted',
DECLINED: 'declined', DECLINED: 'declined',
MISSED: 'missed',
IGNORED: 'ignored', IGNORED: 'ignored',
}) })

View File

@@ -274,6 +274,32 @@ const migrate = async () => {
ON CONFLICT (key) DO NOTHING ON CONFLICT (key) DO NOTHING
` `
// --- Phase 3.1: Mitra Ping Config ---
await sql`
INSERT INTO app_config (key, value)
VALUES ('require_mitra_ping', '{"value": true}')
ON CONFLICT (key) DO NOTHING
`
await sql`
INSERT INTO app_config (key, value)
VALUES ('mitra_ping_interval_seconds', '{"value": 15}')
ON CONFLICT (key) DO NOTHING
`
// --- Phase 3.2: Mitra Request Activity Log ---
await sql`
ALTER TABLE chat_request_notifications
ADD COLUMN IF NOT EXISTS active_session_count INT NOT NULL DEFAULT 0
`
await sql`
CREATE INDEX IF NOT EXISTS idx_chat_request_notifications_mitra_notified
ON chat_request_notifications (mitra_id, notified_at)
`
console.log('Migration complete.') console.log('Migration complete.')
await sql.end() await sql.end()
} }

View File

@@ -6,6 +6,7 @@ import {
getFreeTrialConfig, setFreeTrialConfig, getFreeTrialConfig, setFreeTrialConfig,
getExtensionTimeoutConfig, setExtensionTimeoutConfig, getExtensionTimeoutConfig, setExtensionTimeoutConfig,
getEarlyEndConfig, setEarlyEndConfig, getEarlyEndConfig, setEarlyEndConfig,
getMitraPingConfig, setMitraPingConfig,
} from '../../services/config.service.js' } from '../../services/config.service.js'
const attachCcUser = async (request, reply) => { const attachCcUser = async (request, reply) => {
@@ -102,6 +103,28 @@ export const internalConfigRoutes = async (app) => {
return reply.send({ success: true, data: config }) return reply.send({ success: true, data: config })
}) })
// --- Phase 3.1: Mitra Ping Config ---
app.get('/mitra-ping', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
}, async (request, reply) => {
const config = await getMitraPingConfig()
return reply.send({ success: true, data: config })
})
app.patch('/mitra-ping', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
}, async (request, reply) => {
const { require_ping, ping_interval_seconds } = request.body ?? {}
if (require_ping !== undefined && typeof require_ping !== 'boolean') {
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'require_ping must be a boolean' } })
}
if (ping_interval_seconds !== undefined && (typeof ping_interval_seconds !== 'number' || ping_interval_seconds < 5)) {
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'ping_interval_seconds must be a number >= 5' } })
}
const config = await setMitraPingConfig({ require_ping, ping_interval_seconds })
return reply.send({ success: true, data: config })
})
// --- Price Tiers --- // --- Price Tiers ---
app.get('/price-tiers', { app.get('/price-tiers', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')], preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],

View File

@@ -0,0 +1,33 @@
import { authenticate, requirePermission } from '../../plugins/auth.js'
import { getCcUserByFirebaseUid } from '../../services/cc-user.service.js'
import { getMitraActivityLog, getMitraActivitySummary } from '../../services/mitra-activity.service.js'
const attachCcUser = async (request, reply) => {
const user = await getCcUserByFirebaseUid(request.firebaseUser.uid)
if (!user) return reply.code(403).send({
success: false,
error: { code: 'FORBIDDEN', message: 'Not a control center user' },
})
request.ccUser = user
}
export const mitraActivityRoutes = async (app) => {
app.get('/log', {
preHandler: [authenticate, attachCcUser, requirePermission('mitra', 'read')],
}, async (request, reply) => {
const { mitra_id, date_from, date_to, page = 1, limit = 20 } = request.query
const result = await getMitraActivityLog({
mitra_id, date_from, date_to,
page: Number(page), limit: Number(limit),
})
return reply.send({ success: true, data: result })
})
app.get('/summary', {
preHandler: [authenticate, attachCcUser, requirePermission('mitra', 'read')],
}, async (request, reply) => {
const { mitra_id, date_from, date_to } = request.query
const result = await getMitraActivitySummary({ mitra_id, date_from, date_to })
return reply.send({ success: true, data: result })
})
}

View File

@@ -1,8 +1,18 @@
import { authenticate } from '../../plugins/auth.js' import { authenticate } from '../../plugins/auth.js'
import { getCustomerByFirebaseUid } from '../../services/customer.service.js' import { getOrCreateCustomer, getCustomerByFirebaseUid, updateCustomerDisplayName } from '../../services/customer.service.js'
export const clientAuthRoutes = async (app) => { export const clientAuthRoutes = async (app) => {
app.post('/verify', { preHandler: authenticate }, async (request, reply) => { app.post('/verify', { preHandler: authenticate }, async (request, reply) => {
const { uid, phone_number, name } = request.firebaseUser
const customer = await getOrCreateCustomer({
firebase_uid: uid,
phone: phone_number || null,
display_name: name || null,
})
return reply.send({ success: true, data: customer })
})
app.patch('/profile', { preHandler: authenticate }, async (request, reply) => {
const customer = await getCustomerByFirebaseUid(request.firebaseUser.uid) const customer = await getCustomerByFirebaseUid(request.firebaseUser.uid)
if (!customer) { if (!customer) {
return reply.code(404).send({ return reply.code(404).send({
@@ -10,6 +20,14 @@ export const clientAuthRoutes = async (app) => {
error: { code: 'NOT_FOUND', message: 'Customer account not found' }, error: { code: 'NOT_FOUND', message: 'Customer account not found' },
}) })
} }
return reply.send({ success: true, data: customer }) const { display_name } = request.body || {}
if (!display_name || typeof display_name !== 'string' || display_name.trim().length === 0) {
return reply.code(422).send({
success: false,
error: { code: 'VALIDATION_ERROR', message: 'display_name is required' },
})
}
const updated = await updateCustomerDisplayName(customer.id, display_name.trim())
return reply.send({ success: true, data: updated })
}) })
} }

View File

@@ -1,7 +1,7 @@
import { authenticate } from '../../plugins/auth.js' import { authenticate } from '../../plugins/auth.js'
import { getCustomerByFirebaseUid } from '../../services/customer.service.js' import { getCustomerByFirebaseUid } from '../../services/customer.service.js'
import { createPairingRequest, cancelPairingRequest } from '../../services/pairing.service.js' import { createPairingRequest, cancelPairingRequest } from '../../services/pairing.service.js'
import { getActiveSessionByCustomer, endSession, getCustomerHistory } from '../../services/session.service.js' import { getActiveSessionByCustomer, getActiveSessionByCustomerWithUnread, endSession, getCustomerHistory } from '../../services/session.service.js'
import { getPricingForCustomer, isValidTier, isCustomerEligibleForFreeTrial, getFreeTrial } from '../../services/pricing.service.js' import { getPricingForCustomer, isValidTier, isCustomerEligibleForFreeTrial, getFreeTrial } from '../../services/pricing.service.js'
import { requestExtension } from '../../services/extension.service.js' import { requestExtension } from '../../services/extension.service.js'
import { EndedBy } from '../../constants.js' import { EndedBy } from '../../constants.js'
@@ -73,6 +73,11 @@ export const clientChatRoutes = async (app) => {
return reply.send({ success: true, data: session ?? null }) return reply.send({ success: true, data: session ?? null })
}) })
app.get('/session/active-with-unread', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const session = await getActiveSessionByCustomerWithUnread(request.customer.id)
return reply.send({ success: true, data: session ?? null })
})
app.post('/session/:sessionId/end', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { app.post('/session/:sessionId/end', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const session = await endSession(request.params.sessionId, EndedBy.CUSTOMER, request.customer.id) const session = await endSession(request.params.sessionId, EndedBy.CUSTOMER, request.customer.id)
return reply.send({ success: true, data: session }) return reply.send({ success: true, data: session })

View File

@@ -1,7 +1,7 @@
import { authenticate } from '../../plugins/auth.js' import { authenticate } from '../../plugins/auth.js'
import { getMitraByFirebaseUid } from '../../services/mitra.service.js' import { getMitraByFirebaseUid } from '../../services/mitra.service.js'
import { acceptPairingRequest, declinePairingRequest } from '../../services/pairing.service.js' import { acceptPairingRequest, declinePairingRequest, getSessionStatus } from '../../services/pairing.service.js'
import { getActiveSessionsByMitra, endSession, getMitraHistory } from '../../services/session.service.js' import { getActiveSessionsByMitra, getActiveSessionsByMitraWithUnread, endSession, getMitraHistory } from '../../services/session.service.js'
import { respondToExtension } from '../../services/extension.service.js' import { respondToExtension } from '../../services/extension.service.js'
import { EndedBy } from '../../constants.js' import { EndedBy } from '../../constants.js'
@@ -23,6 +23,15 @@ const resolveMitra = async (request, reply) => {
} }
export const mitraChatRoutes = async (app) => { export const mitraChatRoutes = async (app) => {
// Check if a session is still pending acceptance (for notification validation)
app.get('/:sessionId/status', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
const session = await getSessionStatus(request.params.sessionId)
if (!session) {
return reply.code(404).send({ success: false, error: { code: 'NOT_FOUND', message: 'Session not found' } })
}
return reply.send({ success: true, data: { status: session.status } })
})
app.post('/:sessionId/accept', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => { app.post('/:sessionId/accept', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
const session = await acceptPairingRequest(request.params.sessionId, request.mitra.id) const session = await acceptPairingRequest(request.params.sessionId, request.mitra.id)
return reply.send({ success: true, data: session }) return reply.send({ success: true, data: session })
@@ -38,6 +47,11 @@ export const mitraChatRoutes = async (app) => {
return reply.send({ success: true, data: sessions }) return reply.send({ success: true, data: sessions })
}) })
app.get('/sessions/active-with-unread', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
const sessions = await getActiveSessionsByMitraWithUnread(request.mitra.id)
return reply.send({ success: true, data: sessions })
})
app.post('/sessions/:sessionId/end', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => { app.post('/sessions/:sessionId/end', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
const session = await endSession(request.params.sessionId, EndedBy.MITRA, request.mitra.id) const session = await endSession(request.params.sessionId, EndedBy.MITRA, request.mitra.id)
return reply.send({ success: true, data: session }) return reply.send({ success: true, data: session })

View File

@@ -26,7 +26,7 @@ const start = async () => {
// Auto-offline mitras with stale heartbeat (every 30s) // Auto-offline mitras with stale heartbeat (every 30s)
setInterval(async () => { setInterval(async () => {
try { try {
const count = await autoOfflineStaleMitras(45) const count = await autoOfflineStaleMitras()
if (count > 0) console.log(`Auto-offlined ${count} stale mitra(s)`) if (count > 0) console.log(`Auto-offlined ${count} stale mitra(s)`)
} catch (err) { } catch (err) {
console.error('Auto-offline check failed:', err) console.error('Auto-offline check failed:', err)

View File

@@ -2,6 +2,7 @@ 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 } from './session-timer.service.js'
import { sendToSessionParticipant } from '../plugins/websocket.js' import { sendToSessionParticipant } from '../plugins/websocket.js'
import { sendPushNotification } from './notification.service.js'
import { UserType, SessionStatus, EndedBy, WsMessage } from '../constants.js' import { UserType, SessionStatus, EndedBy, WsMessage } from '../constants.js'
const sql = getDb() const sql = getDb()
@@ -53,10 +54,25 @@ export const completeSession = async (sessionId) => {
` `
if (!session) return null if (!session) return null
// Notify both parties // Notify both parties, FCM fallback if WebSocket is down
const data = { type: WsMessage.SESSION_COMPLETED, session_id: sessionId } const data = { type: WsMessage.SESSION_COMPLETED, session_id: sessionId }
sendToSessionParticipant(sessionId, UserType.CUSTOMER, data) const customerSent = sendToSessionParticipant(sessionId, UserType.CUSTOMER, data)
sendToSessionParticipant(sessionId, UserType.MITRA, data) const mitraSent = sendToSessionParticipant(sessionId, UserType.MITRA, data)
if (!customerSent) {
await sendPushNotification(UserType.CUSTOMER, session.customer_id, {
title: 'Sesi Selesai',
body: 'Sesi curhat kamu telah selesai.',
data: { type: WsMessage.SESSION_COMPLETED, session_id: sessionId },
})
}
if (!mitraSent) {
await sendPushNotification(UserType.MITRA, session.mitra_id, {
title: 'Sesi Selesai',
body: 'Sesi curhat telah selesai.',
data: { type: WsMessage.SESSION_COMPLETED, session_id: sessionId },
})
}
await publish(`session:${sessionId}:status`, { type: WsMessage.SESSION_ENDED, session_id: sessionId }) await publish(`session:${sessionId}:status`, { type: WsMessage.SESSION_ENDED, session_id: sessionId })
@@ -90,10 +106,25 @@ export const initiateEarlyEnd = async (sessionId, userType) => {
clearSessionTimer(sessionId) clearSessionTimer(sessionId)
// Notify both parties to enter closure flow // 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 }
sendToSessionParticipant(sessionId, UserType.CUSTOMER, data) const customerSent = sendToSessionParticipant(sessionId, UserType.CUSTOMER, data)
sendToSessionParticipant(sessionId, UserType.MITRA, data) const mitraSent = sendToSessionParticipant(sessionId, UserType.MITRA, data)
if (!customerSent) {
await sendPushNotification(UserType.CUSTOMER, session.customer_id, {
title: 'Sesi Berakhir',
body: 'Sesi curhat kamu telah berakhir. Ketuk untuk menulis pesan penutup.',
data: { type: WsMessage.SESSION_CLOSING, session_id: sessionId },
})
}
if (!mitraSent) {
await sendPushNotification(UserType.MITRA, session.mitra_id, {
title: 'Sesi Berakhir',
body: 'Sesi curhat telah berakhir. Ketuk untuk menulis pesan penutup.',
data: { type: WsMessage.SESSION_CLOSING, session_id: sessionId },
})
}
return session return session
} }

View File

@@ -82,6 +82,35 @@ export const getEarlyEndConfig = async () => {
} }
} }
// --- Phase 3.1: Mitra Ping Config ---
export const getMitraPingConfig = async () => {
const [requireRow] = await sql`SELECT value FROM app_config WHERE key = 'require_mitra_ping'`
const [intervalRow] = await sql`SELECT value FROM app_config WHERE key = 'mitra_ping_interval_seconds'`
return {
require_ping: requireRow?.value?.value ?? true,
ping_interval_seconds: intervalRow?.value?.value ?? 15,
}
}
export const setMitraPingConfig = async ({ require_ping, ping_interval_seconds }) => {
if (require_ping !== undefined) {
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES ('require_mitra_ping', ${sql.json({ value: require_ping })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
}
if (ping_interval_seconds !== undefined) {
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES ('mitra_ping_interval_seconds', ${sql.json({ value: ping_interval_seconds })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
}
return getMitraPingConfig()
}
export const setEarlyEndConfig = async ({ mitra_enabled, customer_enabled }) => { export const setEarlyEndConfig = async ({ mitra_enabled, customer_enabled }) => {
if (mitra_enabled !== undefined) { if (mitra_enabled !== undefined) {
await sql` await sql`

View File

@@ -48,3 +48,40 @@ export const getCustomerByFirebaseUid = async (firebase_uid) => {
` `
return customer return customer
} }
export const getOrCreateCustomer = async ({ firebase_uid, phone, display_name }) => {
// Return existing customer if already linked to this Firebase UID
const existing = await getCustomerByFirebaseUid(firebase_uid)
if (existing) return existing
// Check if a customer with this phone already exists (re-login with new Firebase UID)
if (phone) {
const [byPhone] = await sql`
SELECT id, display_name, is_anonymous, phone, created_at
FROM customers WHERE phone = ${phone}
`
if (byPhone) {
// Link the new Firebase UID to the existing phone-based customer
await sql`UPDATE customers SET firebase_uid = ${firebase_uid} WHERE id = ${byPhone.id}`
return { ...byPhone, firebase_uid }
}
}
// Auto-create a registered (non-anonymous) customer for phone/social login
// display_name is null — user must set it on first login
const [customer] = await sql`
INSERT INTO customers (firebase_uid, phone, display_name, is_anonymous)
VALUES (${firebase_uid}, ${phone || null}, ${display_name || null}, false)
RETURNING id, display_name, is_anonymous, phone, created_at
`
return customer
}
export const updateCustomerDisplayName = async (customerId, displayName) => {
const [customer] = await sql`
UPDATE customers SET display_name = ${displayName}
WHERE id = ${customerId}
RETURNING id, display_name, is_anonymous, phone, created_at
`
return customer
}

View File

@@ -0,0 +1,75 @@
import { getDb } from '../db/client.js'
const sql = getDb()
export const getMitraActivityLog = async ({ mitra_id, date_from, date_to, page = 1, limit = 20 } = {}) => {
const offset = (page - 1) * limit
const conditions = []
if (mitra_id) conditions.push(sql`crn.mitra_id = ${mitra_id}`)
if (date_from) conditions.push(sql`crn.notified_at >= ${date_from}`)
if (date_to) conditions.push(sql`crn.notified_at <= ${date_to}`)
const where = conditions.length > 0
? sql`WHERE ${conditions.reduce((a, b) => sql`${a} AND ${b}`)}`
: sql``
const items = await sql`
SELECT crn.id, crn.session_id, crn.mitra_id, crn.response,
crn.notified_at, crn.responded_at, crn.active_session_count,
m.display_name AS mitra_display_name,
CASE WHEN crn.responded_at IS NOT NULL
THEN EXTRACT(EPOCH FROM (crn.responded_at - crn.notified_at))::int
ELSE NULL
END AS response_time_seconds
FROM chat_request_notifications crn
INNER JOIN mitras m ON m.id = crn.mitra_id
${where}
ORDER BY crn.notified_at DESC
LIMIT ${limit} OFFSET ${offset}
`
const [{ count }] = await sql`
SELECT COUNT(*) FROM chat_request_notifications crn ${where}
`
return { items, total: Number(count), page, limit }
}
export const getMitraActivitySummary = async ({ mitra_id, date_from, date_to } = {}) => {
const conditions = []
if (mitra_id) conditions.push(sql`crn.mitra_id = ${mitra_id}`)
if (date_from) conditions.push(sql`crn.notified_at >= ${date_from}`)
if (date_to) conditions.push(sql`crn.notified_at <= ${date_to}`)
const where = conditions.length > 0
? sql`WHERE ${conditions.reduce((a, b) => sql`${a} AND ${b}`)}`
: sql``
const summaries = await sql`
SELECT crn.mitra_id,
m.display_name AS mitra_display_name,
COUNT(*)::int AS total_requests,
COUNT(*) FILTER (WHERE crn.response = 'accepted')::int AS accepted_count,
COUNT(*) FILTER (WHERE crn.response = 'declined')::int AS rejected_count,
COUNT(*) FILTER (WHERE crn.response = 'missed')::int AS missed_count,
COUNT(*) FILTER (WHERE crn.response = 'ignored')::int AS ignored_count,
ROUND(
100.0 * COUNT(*) FILTER (WHERE crn.response = 'accepted') / NULLIF(COUNT(*), 0), 1
) AS acceptance_rate,
AVG(
CASE WHEN crn.responded_at IS NOT NULL
THEN EXTRACT(EPOCH FROM (crn.responded_at - crn.notified_at))
ELSE NULL
END
)::numeric(10,1) AS avg_response_time_seconds
FROM chat_request_notifications crn
INNER JOIN mitras m ON m.id = crn.mitra_id
${where}
GROUP BY crn.mitra_id, m.display_name
ORDER BY acceptance_rate DESC NULLS LAST
`
return summaries
}

View File

@@ -1,5 +1,6 @@
import { getDb } from '../db/client.js' import { getDb } from '../db/client.js'
import { SessionStatus } from '../constants.js' import { SessionStatus } from '../constants.js'
import { getMitraPingConfig } from './config.service.js'
const sql = getDb() const sql = getDb()
@@ -58,7 +59,12 @@ export const getStatus = async (mitraId) => {
FROM mitra_online_status FROM mitra_online_status
WHERE mitra_id = ${mitraId} WHERE mitra_id = ${mitraId}
` `
return status const pingConfig = await getMitraPingConfig()
return {
...status,
require_ping: pingConfig.require_ping,
ping_interval_seconds: pingConfig.ping_interval_seconds,
}
} }
export const getOnlineMitras = async () => { export const getOnlineMitras = async () => {
@@ -89,7 +95,13 @@ export const getOnlineLogs = async (mitraId, { page = 1, limit = 50 } = {}) => {
return { items, total: Number(count), page, limit } return { items, total: Number(count), page, limit }
} }
export const autoOfflineStaleMitras = async (staleSeconds = 45) => { export const autoOfflineStaleMitras = async () => {
const pingConfig = await getMitraPingConfig()
// If ping is not required, skip the auto-offline sweep entirely
if (!pingConfig.require_ping) return 0
const staleSeconds = pingConfig.ping_interval_seconds * 3
const stale = await sql` const stale = await sql`
UPDATE mitra_online_status UPDATE mitra_online_status
SET is_online = false, last_offline_at = NOW(), updated_at = NOW() SET is_online = false, last_offline_at = NOW(), updated_at = NOW()

View File

@@ -19,8 +19,8 @@ const notifyMitra = async (mitraId, data) => {
if (data.type === WsMessage.CHAT_REQUEST) { if (data.type === WsMessage.CHAT_REQUEST) {
await sendPushNotification(UserType.MITRA, mitraId, { await sendPushNotification(UserType.MITRA, mitraId, {
title: 'Permintaan Chat Baru', title: 'Permintaan Chat Baru',
body: 'Ada pelanggan yang ingin curhat!', body: 'Ada pelanggan yang ingin curhat! Ketuk untuk menerima.',
data: { type: WsMessage.CHAT_REQUEST, session_id: data.session_id }, data: { type: WsMessage.CHAT_REQUEST, session_id: data.session_id, action: 'open_accept' },
}) })
} }
} }
@@ -29,6 +29,7 @@ const notifyMitra = async (mitraId, data) => {
// Send notification to customer via WebSocket, fall back to FCM if offline // Send notification to customer via WebSocket, fall back to FCM if offline
const notifyCustomer = async (customerId, data) => { const notifyCustomer = async (customerId, data) => {
const sent = sendToUser(UserType.CUSTOMER, customerId, data) const sent = sendToUser(UserType.CUSTOMER, customerId, data)
console.log(`[notifyCustomer] customerId=${customerId} type=${data.type} ws_sent=${sent}`)
if (!sent) { if (!sent) {
if (data.type === WsMessage.PAIRED) { if (data.type === WsMessage.PAIRED) {
await sendPushNotification(UserType.CUSTOMER, customerId, { await sendPushNotification(UserType.CUSTOMER, customerId, {
@@ -91,15 +92,22 @@ export const createPairingRequest = async (customerId, { duration_minutes, price
// Create notifications for all available mitras // Create notifications for all available mitras
for (const mitra of availableMitras) { for (const mitra of availableMitras) {
const [{ count: activeCount }] = await sql`
SELECT COUNT(*)::int AS count FROM chat_sessions
WHERE mitra_id = ${mitra.id}
AND status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT})
`
await sql` await sql`
INSERT INTO chat_request_notifications (session_id, mitra_id) INSERT INTO chat_request_notifications (session_id, mitra_id, active_session_count)
VALUES (${session.id}, ${mitra.id}) VALUES (${session.id}, ${mitra.id}, ${activeCount})
` `
// Notify mitra via WebSocket (FCM fallback if offline) // Notify mitra via WebSocket (FCM fallback if offline)
await notifyMitra(mitra.id, { await notifyMitra(mitra.id, {
type: WsMessage.CHAT_REQUEST, type: WsMessage.CHAT_REQUEST,
session_id: session.id, session_id: session.id,
created_at: session.created_at, created_at: session.created_at,
duration_minutes: session.duration_minutes,
is_free_trial: session.is_free_trial,
}) })
} }
@@ -136,10 +144,10 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
WHERE session_id = ${sessionId} AND mitra_id = ${mitraId} WHERE session_id = ${sessionId} AND mitra_id = ${mitraId}
` `
// Mark other mitras' notifications as ignored // Mark other mitras' notifications as missed (another mitra accepted)
await sql` await sql`
UPDATE chat_request_notifications UPDATE chat_request_notifications
SET response = ${NotificationResponse.IGNORED}, responded_at = NOW() SET response = ${NotificationResponse.MISSED}, responded_at = NOW()
WHERE session_id = ${sessionId} AND mitra_id != ${mitraId} AND response IS NULL WHERE session_id = ${sessionId} AND mitra_id != ${mitraId} AND response IS NULL
` `
@@ -201,6 +209,7 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
await notifyMitra(n.mitra_id, { await notifyMitra(n.mitra_id, {
type: WsMessage.CHAT_REQUEST_CLOSED, type: WsMessage.CHAT_REQUEST_CLOSED,
session_id: sessionId, session_id: sessionId,
reason: 'accepted_by_other',
}) })
} }
@@ -244,7 +253,7 @@ export const cancelPairingRequest = async (sessionId, customerId) => {
WHERE session_id = ${sessionId} AND response IS NULL WHERE session_id = ${sessionId} AND response IS NULL
` `
// Notify mitras to dismiss // Notify mitras to dismiss (customer cancelled)
const notifications = await sql` const notifications = await sql`
SELECT mitra_id FROM chat_request_notifications WHERE session_id = ${sessionId} SELECT mitra_id FROM chat_request_notifications WHERE session_id = ${sessionId}
` `
@@ -252,6 +261,7 @@ export const cancelPairingRequest = async (sessionId, customerId) => {
await notifyMitra(n.mitra_id, { await notifyMitra(n.mitra_id, {
type: WsMessage.CHAT_REQUEST_CLOSED, type: WsMessage.CHAT_REQUEST_CLOSED,
session_id: sessionId, session_id: sessionId,
reason: 'cancelled_by_customer',
}) })
} }
@@ -282,7 +292,7 @@ export const expirePairingRequest = async (sessionId) => {
session_id: sessionId, session_id: sessionId,
}) })
// Notify mitras to dismiss // Notify mitras to dismiss (request expired)
const notifications = await sql` const notifications = await sql`
SELECT mitra_id FROM chat_request_notifications WHERE session_id = ${sessionId} SELECT mitra_id FROM chat_request_notifications WHERE session_id = ${sessionId}
` `
@@ -290,6 +300,7 @@ export const expirePairingRequest = async (sessionId) => {
await notifyMitra(n.mitra_id, { await notifyMitra(n.mitra_id, {
type: WsMessage.CHAT_REQUEST_CLOSED, type: WsMessage.CHAT_REQUEST_CLOSED,
session_id: sessionId, session_id: sessionId,
reason: 'expired',
}) })
} }

View File

@@ -1,6 +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 { sendPushNotification } from './notification.service.js'
import { UserType, SessionStatus, WsMessage } from '../constants.js' import { UserType, SessionStatus, WsMessage } from '../constants.js'
const sql = getDb() const sql = getDb()
@@ -85,15 +86,29 @@ const onSessionExpired = async (sessionId) => {
` `
if (!session) return if (!session) return
// Notify customer — sees extend/close dialog // Notify customer — sees extend/close dialog; FCM fallback if WebSocket is down
const expiredData = { type: WsMessage.SESSION_EXPIRED, session_id: sessionId } const expiredData = { type: WsMessage.SESSION_EXPIRED, session_id: sessionId }
sendToSessionParticipant(sessionId, UserType.CUSTOMER, expiredData) const customerSent = sendToSessionParticipant(sessionId, UserType.CUSTOMER, expiredData)
if (!customerSent) {
await sendPushNotification(UserType.CUSTOMER, session.customer_id, {
title: 'Waktu Sesi Habis',
body: 'Sesi curhat kamu telah habis. Ketuk untuk memperpanjang atau mengakhiri.',
data: { type: WsMessage.SESSION_CLOSING, session_id: sessionId },
})
}
// Notify mitra — sees expired + closing (waits for customer's decision or goodbye) // Notify mitra — sees expired + closing (waits for customer's decision or goodbye)
sendToSessionParticipant(sessionId, UserType.MITRA, expiredData) const mitraSent = sendToSessionParticipant(sessionId, UserType.MITRA, expiredData)
sendToSessionParticipant(sessionId, UserType.MITRA, { sendToSessionParticipant(sessionId, UserType.MITRA, {
type: WsMessage.SESSION_CLOSING, session_id: sessionId, type: WsMessage.SESSION_CLOSING, session_id: sessionId,
}) })
if (!mitraSent) {
await sendPushNotification(UserType.MITRA, session.mitra_id, {
title: 'Sesi Berakhir',
body: 'Sesi curhat telah berakhir. Ketuk untuk menulis pesan penutup.',
data: { type: WsMessage.SESSION_CLOSING, session_id: sessionId },
})
}
// Also publish via Valkey for any listeners // Also publish via Valkey for any listeners
await publish(`session:${sessionId}:status`, expiredData) await publish(`session:${sessionId}:status`, expiredData)

View File

@@ -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 { UserType, SessionStatus, WsMessage } from '../constants.js' import { UserType, SessionStatus, MessageStatus, WsMessage } from '../constants.js'
const sql = getDb() const sql = getDb()
@@ -155,6 +155,42 @@ export const getSessionById = async (sessionId) => {
return session return session
} }
// --- Phase 3.1: Unread counts ---
export const getActiveSessionByCustomerWithUnread = async (customerId) => {
const [session] = await sql`
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.created_at, cs.paired_at,
cs.duration_minutes, cs.price, cs.is_free_trial, cs.expires_at, cs.extended_minutes,
m.display_name AS mitra_display_name,
(SELECT COUNT(*) FROM chat_messages cm
WHERE cm.session_id = cs.id AND cm.sender_type = ${UserType.MITRA}
AND cm.status IN (${MessageStatus.SENT}, ${MessageStatus.DELIVERED}))::int AS unread_count
FROM chat_sessions cs
LEFT JOIN mitras m ON m.id = cs.mitra_id
WHERE cs.customer_id = ${customerId}
AND cs.status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT}, ${SessionStatus.EXTENDING}, ${SessionStatus.CLOSING})
ORDER BY cs.created_at DESC LIMIT 1
`
return session
}
export const getActiveSessionsByMitraWithUnread = async (mitraId) => {
const sessions = await sql`
SELECT cs.id, cs.customer_id, cs.status, cs.created_at, cs.paired_at,
cs.duration_minutes, cs.expires_at, cs.extended_minutes,
c.display_name AS customer_display_name,
(SELECT COUNT(*) FROM chat_messages cm
WHERE cm.session_id = cs.id AND cm.sender_type = ${UserType.CUSTOMER}
AND cm.status IN (${MessageStatus.SENT}, ${MessageStatus.DELIVERED}))::int AS unread_count
FROM chat_sessions cs
INNER JOIN customers c ON c.id = cs.customer_id
WHERE cs.mitra_id = ${mitraId}
AND cs.status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT}, ${SessionStatus.EXTENDING}, ${SessionStatus.CLOSING})
ORDER BY cs.created_at DESC
`
return sessions
}
export const getCustomerHistory = async (customerId, { page = 1, limit = 20 } = {}) => { export const getCustomerHistory = async (customerId, { page = 1, limit = 20 } = {}) => {
const offset = (page - 1) * limit const offset = (page - 1) * limit
const items = await sql` const items = await sql`

View File

@@ -2,7 +2,8 @@
<application <application
android:label="client_app" android:label="client_app"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

View File

@@ -32,4 +32,9 @@ class ApiClient {
final response = await _dio.get(path, queryParameters: queryParameters); final response = await _dio.get(path, queryParameters: queryParameters);
return response.data as Map<String, dynamic>; return response.data as Map<String, dynamic>;
} }
Future<Map<String, dynamic>> patch(String path, {Map<String, dynamic>? data}) async {
final response = await _dio.patch(path, data: data);
return response.data as Map<String, dynamic>;
}
} }

View File

@@ -0,0 +1,8 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'api_client.dart';
part 'api_client_provider.g.dart';
@Riverpod(keepAlive: true)
ApiClient apiClient(Ref ref) => ApiClient();

View File

@@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'api_client_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$apiClientHash() => r'90c807f03b90249684265cc91739139c2c89eeb9';
/// See also [apiClient].
@ProviderFor(apiClient)
final apiClientProvider = Provider<ApiClient>.internal(
apiClient,
name: r'apiClientProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$apiClientHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef ApiClientRef = ProviderRef<ApiClient>;
// 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

@@ -1,236 +0,0 @@
import 'package:equatable/equatable.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../api/api_client.dart';
// Events
abstract class AuthEvent extends Equatable {
@override
List<Object?> get props => [];
}
class AppStarted extends AuthEvent {}
class AnonymousLoginRequested extends AuthEvent {
final String displayName;
AnonymousLoginRequested(this.displayName);
@override List<Object?> get props => [displayName];
}
class GoogleLoginRequested extends AuthEvent {}
class AppleLoginRequested extends AuthEvent {}
class PhoneOtpRequested extends AuthEvent {
final String phone;
PhoneOtpRequested(this.phone);
@override List<Object?> get props => [phone];
}
class OtpVerified extends AuthEvent {
final String verificationId;
final String smsCode;
OtpVerified(this.verificationId, this.smsCode);
@override List<Object?> get props => [verificationId, smsCode];
}
class LinkAccountRequested extends AuthEvent {}
class LogoutRequested extends AuthEvent {}
// States
abstract class AuthState extends Equatable {
@override
List<Object?> get props => [];
}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthAuthenticated extends AuthState {
final Map<String, dynamic> profile;
AuthAuthenticated(this.profile);
@override List<Object?> get props => [profile];
}
class AuthAnonymous extends AuthState {
final String customerId;
final String displayName;
AuthAnonymous({required this.customerId, required this.displayName});
@override List<Object?> get props => [customerId, displayName];
}
class AuthOtpSent extends AuthState {
final String verificationId;
AuthOtpSent(this.verificationId);
@override List<Object?> get props => [verificationId];
}
class AuthError extends AuthState {
final String message;
AuthError(this.message);
@override List<Object?> get props => [message];
}
class AuthForceRegister extends AuthState {
final String customerId;
final String displayName;
AuthForceRegister({required this.customerId, required this.displayName});
@override List<Object?> get props => [customerId, displayName];
}
// Bloc
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final ApiClient apiClient;
final _auth = FirebaseAuth.instance;
String? _pendingVerificationId;
AuthBloc({required this.apiClient}) : super(AuthLoading()) {
on<AppStarted>(_onAppStarted);
on<AnonymousLoginRequested>(_onAnonymousLogin);
on<GoogleLoginRequested>(_onGoogleLogin);
on<AppleLoginRequested>(_onAppleLogin);
on<PhoneOtpRequested>(_onPhoneOtpRequested);
on<OtpVerified>(_onOtpVerified);
on<LinkAccountRequested>(_onLinkAccount);
on<LogoutRequested>(_onLogout);
}
Future<void> _onAppStarted(AppStarted event, Emitter<AuthState> emit) async {
final prefs = await SharedPreferences.getInstance();
final customerId = prefs.getString('anonymous_customer_id');
final displayName = prefs.getString('anonymous_display_name');
final currentUser = _auth.currentUser;
if (currentUser != null && currentUser.isAnonymous && customerId != null && displayName != null) {
// Anonymous Firebase user — restore anonymous state
try {
final config = await apiClient.get('/api/shared/config/anonymity');
final anonymityEnabled = config['data']['anonymity_enabled'] as bool;
if (!anonymityEnabled) {
emit(AuthForceRegister(customerId: customerId, displayName: displayName));
} else {
emit(AuthAnonymous(customerId: customerId, displayName: displayName));
}
} catch (_) {
emit(AuthAnonymous(customerId: customerId, displayName: displayName));
}
} else if (currentUser != null && !currentUser.isAnonymous) {
// Fully registered Firebase user
await _verifyAndEmit(emit);
} else {
emit(AuthInitial());
}
}
Future<void> _onAnonymousLogin(AnonymousLoginRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
// Sign in anonymously with Firebase to get a real JWT
await _auth.signInAnonymously();
// Create/get customer record on backend linked to this Firebase UID
final response = await apiClient.post(
'/api/shared/customer/anonymous',
data: {'display_name': event.displayName},
);
final customer = response['data'] as Map<String, dynamic>;
final prefs = await SharedPreferences.getInstance();
await prefs.setString('anonymous_customer_id', customer['id'] as String);
await prefs.setString('anonymous_display_name', customer['display_name'] as String);
emit(AuthAnonymous(customerId: customer['id'] as String, displayName: customer['display_name'] as String));
} catch (e) {
emit(AuthError('Failed to continue as guest. Please try again.'));
}
}
Future<void> _onGoogleLogin(GoogleLoginRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
final googleUser = await GoogleSignIn().signIn();
if (googleUser == null) { emit(AuthInitial()); return; }
final googleAuth = await googleUser.authentication;
final credential = GoogleAuthProvider.credential(
accessToken: googleAuth.accessToken,
idToken: googleAuth.idToken,
);
await _auth.signInWithCredential(credential);
await _verifyAndEmit(emit);
} catch (e) {
emit(AuthError('Google sign-in failed. Please try again.'));
}
}
Future<void> _onAppleLogin(AppleLoginRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
final appleCredential = await SignInWithApple.getAppleIDCredential(
scopes: [AppleIDAuthorizationScopes.email],
);
final oauthCredential = OAuthProvider('apple.com').credential(
idToken: appleCredential.identityToken,
accessToken: appleCredential.authorizationCode,
);
await _auth.signInWithCredential(oauthCredential);
await _verifyAndEmit(emit);
} catch (e) {
emit(AuthError('Apple sign-in failed. Please try again.'));
}
}
Future<void> _onPhoneOtpRequested(PhoneOtpRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
await _auth.verifyPhoneNumber(
phoneNumber: event.phone,
verificationCompleted: (_) {},
verificationFailed: (e) => emit(AuthError('Failed to send OTP. Please try again.')),
codeSent: (verificationId, _) {
_pendingVerificationId = verificationId;
emit(AuthOtpSent(verificationId));
},
codeAutoRetrievalTimeout: (_) {},
);
}
Future<void> _onOtpVerified(OtpVerified event, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
final credential = PhoneAuthProvider.credential(
verificationId: event.verificationId,
smsCode: event.smsCode,
);
await _auth.signInWithCredential(credential);
await _verifyAndEmit(emit);
} catch (e) {
emit(AuthError('Invalid OTP. Please try again.'));
}
}
Future<void> _onLinkAccount(LinkAccountRequested event, Emitter<AuthState> emit) async {
// Called after anonymous user completes social/OTP login to link accounts
final prefs = await SharedPreferences.getInstance();
final customerId = prefs.getString('anonymous_customer_id');
if (customerId == null || _auth.currentUser == null) return;
emit(AuthLoading());
try {
await apiClient.post('/api/shared/customer/link', data: {
'customer_id': customerId,
'firebase_uid': _auth.currentUser!.uid,
});
await prefs.remove('anonymous_customer_id');
await prefs.remove('anonymous_display_name');
await _verifyAndEmit(emit);
} catch (e) {
emit(AuthError('Failed to link account. Please try again.'));
}
}
Future<void> _onLogout(LogoutRequested event, Emitter<AuthState> emit) async {
await _auth.signOut();
final prefs = await SharedPreferences.getInstance();
await prefs.remove('anonymous_customer_id');
await prefs.remove('anonymous_display_name');
emit(AuthInitial());
}
Future<void> _verifyAndEmit(Emitter<AuthState> emit) async {
try {
final response = await apiClient.post('/api/client/auth/verify');
emit(AuthAuthenticated(response['data'] as Map<String, dynamic>));
} catch (e) {
emit(AuthError('Failed to verify account. Please try again.'));
}
}
}

View File

@@ -0,0 +1,226 @@
import 'dart:async';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
import '../api/api_client.dart';
import '../api/api_client_provider.dart';
part 'auth_notifier.g.dart';
// States
sealed class AuthData {
const AuthData();
}
class AuthInitialData extends AuthData {
const AuthInitialData();
}
class AuthAuthenticatedData extends AuthData {
final Map<String, dynamic> profile;
const AuthAuthenticatedData(this.profile);
}
class AuthAnonymousData extends AuthData {
final String customerId;
final String displayName;
const AuthAnonymousData({required this.customerId, required this.displayName});
}
class AuthOtpSentData extends AuthData {
final String verificationId;
const AuthOtpSentData(this.verificationId);
}
class AuthForceRegisterData extends AuthData {
final String customerId;
final String displayName;
const AuthForceRegisterData({required this.customerId, required this.displayName});
}
class AuthNeedsDisplayNameData extends AuthData {
final Map<String, dynamic> profile;
const AuthNeedsDisplayNameData(this.profile);
}
@Riverpod(keepAlive: true)
class Auth extends _$Auth {
FirebaseAuth get _auth => FirebaseAuth.instance;
ApiClient get _apiClient => ref.read(apiClientProvider);
@override
FutureOr<AuthData> build() async {
final prefs = await SharedPreferences.getInstance();
final customerId = prefs.getString('anonymous_customer_id');
final displayName = prefs.getString('anonymous_display_name');
final currentUser = _auth.currentUser;
if (currentUser != null && currentUser.isAnonymous && customerId != null && displayName != null) {
try {
final config = await _apiClient.get('/api/shared/config/anonymity');
final anonymityEnabled = config['data']['anonymity_enabled'] as bool;
if (!anonymityEnabled) {
return AuthForceRegisterData(customerId: customerId, displayName: displayName);
}
return AuthAnonymousData(customerId: customerId, displayName: displayName);
} catch (_) {
return AuthAnonymousData(customerId: customerId, displayName: displayName);
}
} else if (currentUser != null && !currentUser.isAnonymous) {
return await _verifyAndReturn();
}
return const AuthInitialData();
}
Future<void> loginAnonymous(String displayName) async {
state = const AsyncLoading();
try {
await _auth.signInAnonymously();
final response = await _apiClient.post(
'/api/shared/customer/anonymous',
data: {'display_name': displayName},
);
final customer = response['data'] as Map<String, dynamic>;
final prefs = await SharedPreferences.getInstance();
await prefs.setString('anonymous_customer_id', customer['id'] as String);
await prefs.setString('anonymous_display_name', customer['display_name'] as String);
state = AsyncData(AuthAnonymousData(
customerId: customer['id'] as String,
displayName: customer['display_name'] as String,
));
} catch (e) {
state = AsyncError('Failed to continue as guest. Please try again.', StackTrace.current);
}
}
Future<void> loginGoogle() async {
state = const AsyncLoading();
try {
final googleUser = await GoogleSignIn().signIn();
if (googleUser == null) {
state = const AsyncData(AuthInitialData());
return;
}
final googleAuth = await googleUser.authentication;
final credential = GoogleAuthProvider.credential(
accessToken: googleAuth.accessToken,
idToken: googleAuth.idToken,
);
await _auth.signInWithCredential(credential);
state = AsyncData(await _verifyAndReturn());
} catch (e) {
state = AsyncError('Google sign-in failed. Please try again.', StackTrace.current);
}
}
Future<void> loginApple() async {
state = const AsyncLoading();
try {
final appleCredential = await SignInWithApple.getAppleIDCredential(
scopes: [AppleIDAuthorizationScopes.email],
);
final oauthCredential = OAuthProvider('apple.com').credential(
idToken: appleCredential.identityToken,
accessToken: appleCredential.authorizationCode,
);
await _auth.signInWithCredential(oauthCredential);
state = AsyncData(await _verifyAndReturn());
} catch (e) {
state = AsyncError('Apple sign-in failed. Please try again.', StackTrace.current);
}
}
Future<void> requestOtp(String phone) async {
state = const AsyncLoading();
final completer = Completer<void>();
await _auth.verifyPhoneNumber(
phoneNumber: phone,
verificationCompleted: (credential) async {
try {
await _auth.signInWithCredential(credential);
state = AsyncData(await _verifyAndReturn());
} catch (_) {}
if (!completer.isCompleted) completer.complete();
},
verificationFailed: (e) {
state = AsyncError('Failed to send OTP. Please try again.', StackTrace.current);
if (!completer.isCompleted) completer.complete();
},
codeSent: (verificationId, _) {
state = AsyncData(AuthOtpSentData(verificationId));
if (!completer.isCompleted) completer.complete();
},
codeAutoRetrievalTimeout: (_) {
if (!completer.isCompleted) completer.complete();
},
);
await completer.future;
}
Future<void> verifyOtp(String verificationId, String smsCode) async {
state = const AsyncLoading();
try {
// If already signed in via auto-verification, skip credential sign-in
if (_auth.currentUser == null || _auth.currentUser!.isAnonymous) {
final credential = PhoneAuthProvider.credential(
verificationId: verificationId,
smsCode: smsCode,
);
await _auth.signInWithCredential(credential);
}
state = AsyncData(await _verifyAndReturn());
} catch (e) {
state = AsyncError('Invalid OTP. Please try again.', StackTrace.current);
}
}
Future<void> linkAccount() async {
final prefs = await SharedPreferences.getInstance();
final customerId = prefs.getString('anonymous_customer_id');
if (customerId == null || _auth.currentUser == null) return;
state = const AsyncLoading();
try {
await _apiClient.post('/api/shared/customer/link', data: {
'customer_id': customerId,
'firebase_uid': _auth.currentUser!.uid,
});
await prefs.remove('anonymous_customer_id');
await prefs.remove('anonymous_display_name');
state = AsyncData(await _verifyAndReturn());
} catch (e) {
state = AsyncError('Failed to link account. Please try again.', StackTrace.current);
}
}
Future<void> logout() async {
await _auth.signOut();
final prefs = await SharedPreferences.getInstance();
await prefs.remove('anonymous_customer_id');
await prefs.remove('anonymous_display_name');
state = const AsyncData(AuthInitialData());
}
Future<void> setDisplayName(String displayName) async {
state = const AsyncLoading();
try {
final response = await _apiClient.patch('/api/client/auth/profile', data: {
'display_name': displayName,
});
state = AsyncData(AuthAuthenticatedData(response['data'] as Map<String, dynamic>));
} catch (e) {
state = AsyncError('Gagal menyimpan nama. Coba lagi.', StackTrace.current);
}
}
Future<AuthData> _verifyAndReturn() async {
final response = await _apiClient.post('/api/client/auth/verify');
final profile = response['data'] as Map<String, dynamic>;
if (profile['display_name'] == null || (profile['display_name'] as String).isEmpty) {
return AuthNeedsDisplayNameData(profile);
}
return AuthAuthenticatedData(profile);
}
}

View File

@@ -0,0 +1,24 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'auth_notifier.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$authHash() => r'8cb877e94ccf4366b574ffe8c8b4b63321340b6d';
/// See also [Auth].
@ProviderFor(Auth)
final authProvider = AsyncNotifierProvider<Auth, AuthData>.internal(
Auth.new,
name: r'authProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$authHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$Auth = AsyncNotifier<AuthData>;
// 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

@@ -1,69 +1,28 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:equatable/equatable.dart';
import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:web_socket_channel/web_socket_channel.dart'; 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 '../constants.dart'; import '../constants.dart';
// Events part 'chat_notifier.g.dart';
abstract class ChatEvent extends Equatable {
@override
List<Object?> get props => [];
}
class ConnectChat extends ChatEvent {
final String sessionId;
ConnectChat(this.sessionId);
@override
List<Object?> get props => [sessionId];
}
class DisconnectChat extends ChatEvent {}
class SendMessage extends ChatEvent {
final String content;
SendMessage(this.content);
@override
List<Object?> get props => [content];
}
class SendTyping extends ChatEvent {}
class _MessageReceived extends ChatEvent {
final Map<String, dynamic> data;
_MessageReceived(this.data);
@override
List<Object?> get props => [data];
}
class _ConnectionError extends ChatEvent {}
class MarkMessagesDelivered extends ChatEvent {
final List<String> messageIds;
MarkMessagesDelivered(this.messageIds);
@override
List<Object?> get props => [messageIds];
}
class MarkMessagesRead extends ChatEvent {
final List<String> messageIds;
MarkMessagesRead(this.messageIds);
@override
List<Object?> get props => [messageIds];
}
// States // States
abstract class ChatState extends Equatable { sealed class ChatData {
@override const ChatData();
List<Object?> get props => [];
} }
class ChatInitial extends ChatState {} class ChatInitialData extends ChatData {
class ChatConnecting extends ChatState {} const ChatInitialData();
}
class ChatConnected extends ChatState { class ChatConnectingData extends ChatData {
const ChatConnectingData();
}
class ChatConnectedData extends ChatData {
final List<ChatMessage> messages; final List<ChatMessage> messages;
final bool isOtherTyping; final bool isOtherTyping;
final int? remainingSeconds; final int? remainingSeconds;
@@ -72,7 +31,7 @@ class ChatConnected extends ChatState {
final bool sessionClosing; final bool sessionClosing;
final Map<String, dynamic>? extensionResponse; final Map<String, dynamic>? extensionResponse;
ChatConnected({ const ChatConnectedData({
required this.messages, required this.messages,
this.isOtherTyping = false, this.isOtherTyping = false,
this.remainingSeconds, this.remainingSeconds,
@@ -82,7 +41,7 @@ class ChatConnected extends ChatState {
this.extensionResponse, this.extensionResponse,
}); });
ChatConnected copyWith({ ChatConnectedData copyWith({
List<ChatMessage>? messages, List<ChatMessage>? messages,
bool? isOtherTyping, bool? isOtherTyping,
int? remainingSeconds, int? remainingSeconds,
@@ -91,7 +50,7 @@ class ChatConnected extends ChatState {
bool? sessionClosing, bool? sessionClosing,
Map<String, dynamic>? extensionResponse, Map<String, dynamic>? extensionResponse,
}) { }) {
return ChatConnected( return ChatConnectedData(
messages: messages ?? this.messages, messages: messages ?? this.messages,
isOtherTyping: isOtherTyping ?? this.isOtherTyping, isOtherTyping: isOtherTyping ?? this.isOtherTyping,
remainingSeconds: remainingSeconds ?? this.remainingSeconds, remainingSeconds: remainingSeconds ?? this.remainingSeconds,
@@ -101,16 +60,11 @@ class ChatConnected extends ChatState {
extensionResponse: extensionResponse ?? this.extensionResponse, extensionResponse: extensionResponse ?? this.extensionResponse,
); );
} }
@override
List<Object?> get props => [messages, isOtherTyping, remainingSeconds, sessionExpired, sessionPaused, sessionClosing, extensionResponse];
} }
class ChatError extends ChatState { class ChatErrorData extends ChatData {
final String message; final String message;
ChatError(this.message); const ChatErrorData(this.message);
@override
List<Object?> get props => [message];
} }
// Message model // Message model
@@ -119,10 +73,10 @@ class ChatMessage {
final String senderType; final String senderType;
final String content; final String content;
final String type; final String type;
final String status; // sending, sent, delivered, read final String status;
final DateTime createdAt; final DateTime createdAt;
ChatMessage({ const ChatMessage({
required this.id, required this.id,
required this.senderType, required this.senderType,
required this.content, required this.content,
@@ -143,45 +97,34 @@ class ChatMessage {
} }
} }
// Bloc @Riverpod(keepAlive: true)
class ChatBloc extends Bloc<ChatEvent, ChatState> { class Chat extends _$Chat {
final ApiClient apiClient;
WebSocketChannel? _channel; WebSocketChannel? _channel;
StreamSubscription? _wsSubscription; StreamSubscription? _wsSubscription;
Timer? _typingTimer; Timer? _typingTimer;
ChatBloc({required this.apiClient}) : super(ChatInitial()) { ApiClient get _apiClient => ref.read(apiClientProvider);
on<ConnectChat>(_onConnect);
on<DisconnectChat>(_onDisconnect);
on<SendMessage>(_onSendMessage);
on<SendTyping>(_onSendTyping);
on<_MessageReceived>(_onMessageReceived);
on<_ConnectionError>(_onConnectionError);
on<MarkMessagesDelivered>(_onMarkDelivered);
on<MarkMessagesRead>(_onMarkRead);
}
Future<void> _onConnect(ConnectChat event, Emitter<ChatState> emit) async { @override
emit(ChatConnecting()); ChatData build() => const ChatInitialData();
Future<void> connect(String sessionId) async {
state = const ChatConnectingData();
try { try {
// Check session status before connecting final sessionInfo = await _apiClient.get('/api/shared/chat/$sessionId/info');
final sessionInfo = await apiClient.get('/api/shared/chat/${event.sessionId}/info');
final sessionData = sessionInfo['data'] as Map<String, dynamic>?; final sessionData = sessionInfo['data'] as Map<String, dynamic>?;
final sessionStatus = sessionData?['status'] as String?; final sessionStatus = sessionData?['status'] as String?;
if (sessionStatus == SessionStatus.completed || if (sessionStatus == SessionStatus.completed ||
sessionStatus == SessionStatus.cancelled || sessionStatus == SessionStatus.cancelled ||
sessionStatus == SessionStatus.expired) { sessionStatus == SessionStatus.expired) {
emit(ChatError('Sesi sudah berakhir.')); state = const ChatErrorData('Sesi sudah berakhir.');
return; return;
} }
final isClosing = sessionStatus == SessionStatus.closing; final isClosing = sessionStatus == SessionStatus.closing;
// Load existing messages from API final response = await _apiClient.get('/api/shared/chat/$sessionId/messages');
final response = await apiClient.get(
'/api/shared/chat/${event.sessionId}/messages',
);
final messagesData = response['data'] as List<dynamic>; final messagesData = response['data'] as List<dynamic>;
final messages = messagesData.map((m) => ChatMessage( final messages = messagesData.map((m) => ChatMessage(
id: m['id'] as String, id: m['id'] as String,
@@ -192,7 +135,6 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
createdAt: DateTime.parse(m['created_at'] as String), createdAt: DateTime.parse(m['created_at'] as String),
)).toList(); )).toList();
// Connect WebSocket
final user = FirebaseAuth.instance.currentUser; final user = FirebaseAuth.instance.currentUser;
final token = await user?.getIdToken(); final token = await user?.getIdToken();
final wsUrl = ApiClient.baseUrl final wsUrl = ApiClient.baseUrl
@@ -203,86 +145,82 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
_wsSubscription = _channel!.stream.listen( _wsSubscription = _channel!.stream.listen(
(raw) { (raw) {
final data = jsonDecode(raw as String) as Map<String, dynamic>; final data = jsonDecode(raw as String) as Map<String, dynamic>;
add(_MessageReceived(data)); _onMessageReceived(data);
}, },
onError: (_) => add(_ConnectionError()), onError: (_) {},
onDone: () => add(_ConnectionError()), onDone: () {},
); );
// Send auth message
_channel!.sink.add(jsonEncode({ _channel!.sink.add(jsonEncode({
'type': WsMessage.auth, 'type': WsMessage.auth,
'token': token, 'token': token,
'session_id': event.sessionId, 'session_id': sessionId,
})); }));
emit(ChatConnected( state = ChatConnectedData(
messages: messages, messages: messages,
sessionClosing: isClosing, sessionClosing: isClosing,
)); );
} catch (e) { } catch (e) {
emit(ChatError('Gagal terhubung ke chat.')); state = const ChatErrorData('Gagal terhubung ke chat.');
} }
} }
void _onDisconnect(DisconnectChat event, Emitter<ChatState> emit) { void disconnect() {
_cleanup(); _cleanup();
emit(ChatInitial()); state = const ChatInitialData();
} }
void _onSendMessage(SendMessage event, Emitter<ChatState> emit) { void sendMessage(String content) {
if (state is! ChatConnected || _channel == null) return; if (state is! ChatConnectedData || _channel == null) return;
final current = state as ChatConnected; final current = state as ChatConnectedData;
// Add message locally with 'sending' status
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}'; final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
final msg = ChatMessage( final msg = ChatMessage(
id: tempId, id: tempId,
senderType: UserType.customer, senderType: UserType.customer,
content: event.content, content: content,
status: 'sending', status: 'sending',
createdAt: DateTime.now(), createdAt: DateTime.now(),
); );
emit(current.copyWith(messages: [...current.messages, msg])); state = current.copyWith(messages: [...current.messages, msg]);
_channel!.sink.add(jsonEncode({ _channel!.sink.add(jsonEncode({
'type': WsMessage.message, 'type': WsMessage.message,
'content': event.content, 'content': content,
'_temp_id': tempId, '_temp_id': tempId,
})); }));
} }
void _onSendTyping(SendTyping event, Emitter<ChatState> emit) { void sendTyping() {
if (_channel == null) return; if (_channel == null) return;
_channel!.sink.add(jsonEncode({'type': WsMessage.typing})); _channel!.sink.add(jsonEncode({'type': WsMessage.typing}));
} }
void _onMarkDelivered(MarkMessagesDelivered event, Emitter<ChatState> emit) { void markDelivered(List<String> messageIds) {
if (_channel == null) return; if (_channel == null) return;
_channel!.sink.add(jsonEncode({ _channel!.sink.add(jsonEncode({
'type': WsMessage.delivered, 'type': WsMessage.delivered,
'message_ids': event.messageIds, 'message_ids': messageIds,
})); }));
} }
void _onMarkRead(MarkMessagesRead event, Emitter<ChatState> emit) { void markRead(List<String> messageIds) {
if (_channel == null) return; if (_channel == null) return;
_channel!.sink.add(jsonEncode({ _channel!.sink.add(jsonEncode({
'type': WsMessage.read, 'type': WsMessage.read,
'message_ids': event.messageIds, 'message_ids': messageIds,
})); }));
} }
void _onMessageReceived(_MessageReceived event, Emitter<ChatState> emit) { void _onMessageReceived(Map<String, dynamic> data) {
if (state is! ChatConnected) return; if (state is! ChatConnectedData) return;
final current = state as ChatConnected; final current = state as ChatConnectedData;
final data = event.data;
final type = data['type'] as String?; final type = data['type'] as String?;
switch (type) { switch (type) {
case WsMessage.authOk: case WsMessage.authOk:
// Already connected
break; break;
case WsMessage.message: case WsMessage.message:
@@ -294,9 +232,8 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
status: MessageStatus.sent, status: MessageStatus.sent,
createdAt: DateTime.parse(data['created_at'] as String), createdAt: DateTime.parse(data['created_at'] as String),
); );
emit(current.copyWith(messages: [...current.messages, msg])); state = current.copyWith(messages: [...current.messages, msg]);
// Auto-acknowledge delivery markDelivered([msg.id]);
add(MarkMessagesDelivered([msg.id]));
break; break;
case WsMessage.messageAck: case WsMessage.messageAck:
@@ -308,7 +245,6 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
} }
return m; return m;
}).toList(); }).toList();
// Replace temp ID with real ID
final idx = updatedMessages.indexWhere((m) => m.status == status && m.senderType == UserType.customer); final idx = updatedMessages.indexWhere((m) => m.status == status && m.senderType == UserType.customer);
if (idx >= 0) { if (idx >= 0) {
final old = updatedMessages[idx]; final old = updatedMessages[idx];
@@ -321,7 +257,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
createdAt: old.createdAt, createdAt: old.createdAt,
); );
} }
emit(current.copyWith(messages: updatedMessages)); state = current.copyWith(messages: updatedMessages);
break; break;
case WsMessage.messageStatus: case WsMessage.messageStatus:
@@ -333,47 +269,47 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
} }
return m; return m;
}).toList(); }).toList();
emit(current.copyWith(messages: updatedMessages)); state = current.copyWith(messages: updatedMessages);
break; break;
case WsMessage.typing: case WsMessage.typing:
emit(current.copyWith(isOtherTyping: true)); state = current.copyWith(isOtherTyping: true);
_typingTimer?.cancel(); _typingTimer?.cancel();
_typingTimer = Timer(const Duration(seconds: 3), () { _typingTimer = Timer(const Duration(seconds: 3), () {
if (state is ChatConnected) { if (state is ChatConnectedData) {
emit((state as ChatConnected).copyWith(isOtherTyping: false)); state = (state as ChatConnectedData).copyWith(isOtherTyping: false);
} }
}); });
break; break;
case WsMessage.sessionTimer: case WsMessage.sessionTimer:
final remaining = data['remaining_seconds'] as int?; final remaining = data['remaining_seconds'] as int?;
emit(current.copyWith(remainingSeconds: remaining)); state = current.copyWith(remainingSeconds: remaining);
break; break;
case WsMessage.sessionExpired: case WsMessage.sessionExpired:
emit(current.copyWith(sessionExpired: true)); state = current.copyWith(sessionExpired: true);
break; break;
case WsMessage.sessionPaused: case WsMessage.sessionPaused:
emit(current.copyWith(sessionPaused: true)); state = current.copyWith(sessionPaused: true);
break; break;
case WsMessage.sessionResumed: case WsMessage.sessionResumed:
emit(current.copyWith(sessionPaused: false, sessionExpired: false, sessionClosing: false)); state = current.copyWith(sessionPaused: false, sessionExpired: false, sessionClosing: false);
break; break;
case WsMessage.sessionClosing: case WsMessage.sessionClosing:
emit(current.copyWith(sessionClosing: true)); state = current.copyWith(sessionClosing: true);
break; break;
case WsMessage.extensionResponse: case WsMessage.extensionResponse:
final accepted = data['accepted'] as bool? ?? false; final accepted = data['accepted'] as bool? ?? false;
emit(current.copyWith( state = current.copyWith(
extensionResponse: data, extensionResponse: data,
sessionPaused: accepted ? false : current.sessionPaused, sessionPaused: accepted ? false : current.sessionPaused,
sessionExpired: accepted ? false : current.sessionExpired, sessionExpired: accepted ? false : current.sessionExpired,
)); );
break; break;
case WsMessage.sessionCompleted: case WsMessage.sessionCompleted:
@@ -381,15 +317,10 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
break; break;
case WsMessage.error: case WsMessage.error:
// Keep connected but show error
break; break;
} }
} }
void _onConnectionError(_ConnectionError event, Emitter<ChatState> emit) {
// Could implement reconnection logic here
}
void _cleanup() { void _cleanup() {
_wsSubscription?.cancel(); _wsSubscription?.cancel();
_wsSubscription = null; _wsSubscription = null;
@@ -398,10 +329,4 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
_typingTimer?.cancel(); _typingTimer?.cancel();
_typingTimer = null; _typingTimer = null;
} }
@override
Future<void> close() {
_cleanup();
return super.close();
}
} }

View File

@@ -0,0 +1,24 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'chat_notifier.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$chatHash() => r'c67d0e916a9474e5142d1f07649792cd448607e4';
/// See also [Chat].
@ProviderFor(Chat)
final chatProvider = NotifierProvider<Chat, ChatData>.internal(
Chat.new,
name: r'chatProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$chatHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$Chat = Notifier<ChatData>;
// 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

@@ -1,87 +0,0 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../api/api_client.dart';
// Events
abstract class ChatOpeningEvent extends Equatable {
@override
List<Object?> get props => [];
}
class LoadPricing extends ChatOpeningEvent {}
// States
abstract class ChatOpeningState extends Equatable {
@override
List<Object?> get props => [];
}
class PricingInitial extends ChatOpeningState {}
class PricingLoading extends ChatOpeningState {}
class PricingLoaded extends ChatOpeningState {
final List<PriceTier> tiers;
final bool freeTrialEligible;
final int freeTrialDurationMinutes;
PricingLoaded({
required this.tiers,
required this.freeTrialEligible,
this.freeTrialDurationMinutes = 5,
});
@override
List<Object?> get props => [tiers, freeTrialEligible, freeTrialDurationMinutes];
}
class PricingError extends ChatOpeningState {
final String message;
PricingError(this.message);
@override
List<Object?> get props => [message];
}
// Model
class PriceTier {
final int durationMinutes;
final int price;
final String label;
PriceTier({required this.durationMinutes, required this.price, required this.label});
factory PriceTier.fromJson(Map<String, dynamic> json) {
return PriceTier(
durationMinutes: json['duration_minutes'] as int,
price: json['price'] as int,
label: json['label'] as String,
);
}
}
// Bloc
class ChatOpeningBloc extends Bloc<ChatOpeningEvent, ChatOpeningState> {
final ApiClient apiClient;
ChatOpeningBloc({required this.apiClient}) : super(PricingInitial()) {
on<LoadPricing>(_onLoadPricing);
}
Future<void> _onLoadPricing(LoadPricing event, Emitter<ChatOpeningState> emit) async {
emit(PricingLoading());
try {
final response = await apiClient.get('/api/client/chat/pricing');
final data = response['data'] as Map<String, dynamic>;
final tiersJson = data['tiers'] as List<dynamic>;
final tiers = tiersJson.map((t) => PriceTier.fromJson(t as Map<String, dynamic>)).toList();
final freeTrial = data['free_trial'] as Map<String, dynamic>;
emit(PricingLoaded(
tiers: tiers,
freeTrialEligible: freeTrial['eligible'] as bool? ?? false,
freeTrialDurationMinutes: freeTrial['duration_minutes'] as int? ?? 5,
));
} catch (e) {
emit(PricingError('Gagal memuat harga. Coba lagi.'));
}
}
}

View File

@@ -0,0 +1,49 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../api/api_client_provider.dart';
part 'chat_opening_provider.g.dart';
class PriceTier {
final int durationMinutes;
final int price;
final String label;
PriceTier({required this.durationMinutes, required this.price, required this.label});
factory PriceTier.fromJson(Map<String, dynamic> json) {
return PriceTier(
durationMinutes: json['duration_minutes'] as int,
price: json['price'] as int,
label: json['label'] as String,
);
}
}
class PricingData {
final List<PriceTier> tiers;
final bool freeTrialEligible;
final int freeTrialDurationMinutes;
const PricingData({
required this.tiers,
required this.freeTrialEligible,
this.freeTrialDurationMinutes = 5,
});
}
@riverpod
Future<PricingData> chatPricing(Ref ref) async {
final apiClient = ref.read(apiClientProvider);
final response = await apiClient.get('/api/client/chat/pricing');
final data = response['data'] as Map<String, dynamic>;
final tiersJson = data['tiers'] as List<dynamic>;
final tiers = tiersJson.map((t) => PriceTier.fromJson(t as Map<String, dynamic>)).toList();
final freeTrial = data['free_trial'] as Map<String, dynamic>;
return PricingData(
tiers: tiers,
freeTrialEligible: freeTrial['eligible'] as bool? ?? false,
freeTrialDurationMinutes: freeTrial['duration_minutes'] as int? ?? 5,
);
}

View File

@@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'chat_opening_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$chatPricingHash() => r'53ca829d46f7ba3b481e7a6b54482c62f9fe3ad0';
/// See also [chatPricing].
@ProviderFor(chatPricing)
final chatPricingProvider = AutoDisposeFutureProvider<PricingData>.internal(
chatPricing,
name: r'chatPricingProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$chatPricingHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef ChatPricingRef = AutoDisposeFutureProviderRef<PricingData>;
// 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

@@ -1,96 +0,0 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../api/api_client.dart';
// Events
abstract class SessionClosureEvent extends Equatable {
@override
List<Object?> get props => [];
}
class RequestExtension extends SessionClosureEvent {
final String sessionId;
final int durationMinutes;
final int price;
RequestExtension({required this.sessionId, required this.durationMinutes, required this.price});
@override
List<Object?> get props => [sessionId, durationMinutes, price];
}
class DeclineExtension extends SessionClosureEvent {}
class ResetClosure extends SessionClosureEvent {}
class SubmitGoodbye extends SessionClosureEvent {
final String sessionId;
final String message;
SubmitGoodbye({required this.sessionId, required this.message});
@override
List<Object?> get props => [sessionId, message];
}
// States
abstract class SessionClosureState extends Equatable {
@override
List<Object?> get props => [];
}
class ClosureInitial extends SessionClosureState {}
class ExtendingWaitingMitra extends SessionClosureState {}
class ClosureShowGoodbye extends SessionClosureState {}
class ClosureSubmitting extends SessionClosureState {}
class ClosureComplete extends SessionClosureState {}
class ClosureError extends SessionClosureState {
final String message;
ClosureError(this.message);
@override
List<Object?> get props => [message];
}
// Bloc
class SessionClosureBloc extends Bloc<SessionClosureEvent, SessionClosureState> {
final ApiClient apiClient;
SessionClosureBloc({required this.apiClient}) : super(ClosureInitial()) {
on<RequestExtension>(_onRequestExtension);
on<DeclineExtension>(_onDeclineExtension);
on<ResetClosure>(_onReset);
on<SubmitGoodbye>(_onSubmitGoodbye);
}
Future<void> _onRequestExtension(RequestExtension event, Emitter<SessionClosureState> emit) async {
emit(ExtendingWaitingMitra());
try {
await apiClient.post('/api/client/chat/session/${event.sessionId}/extend', data: {
'duration_minutes': event.durationMinutes,
'price': event.price,
});
// Response will come via WebSocket (ChatBloc handles it)
} catch (e) {
emit(ClosureError('Gagal meminta perpanjangan.'));
}
}
void _onDeclineExtension(DeclineExtension event, Emitter<SessionClosureState> emit) {
emit(ClosureShowGoodbye());
}
void _onReset(ResetClosure event, Emitter<SessionClosureState> emit) {
emit(ClosureInitial());
}
Future<void> _onSubmitGoodbye(SubmitGoodbye event, Emitter<SessionClosureState> emit) async {
emit(ClosureSubmitting());
try {
await apiClient.post('/api/shared/sessions/${event.sessionId}/close-message', data: {
'message': event.message,
});
emit(ClosureComplete());
} catch (e) {
emit(ClosureError('Gagal mengirim pesan penutup.'));
}
}
}

View File

@@ -0,0 +1,74 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../api/api_client_provider.dart';
part 'session_closure_notifier.g.dart';
// States
sealed class SessionClosureData {
const SessionClosureData();
}
class ClosureInitialData extends SessionClosureData {
const ClosureInitialData();
}
class ExtendingWaitingMitraData extends SessionClosureData {
const ExtendingWaitingMitraData();
}
class ClosureShowGoodbyeData extends SessionClosureData {
const ClosureShowGoodbyeData();
}
class ClosureSubmittingData extends SessionClosureData {
const ClosureSubmittingData();
}
class ClosureCompleteData extends SessionClosureData {
const ClosureCompleteData();
}
class ClosureErrorData extends SessionClosureData {
final String message;
const ClosureErrorData(this.message);
}
@Riverpod(keepAlive: true)
class SessionClosure extends _$SessionClosure {
@override
SessionClosureData build() => const ClosureInitialData();
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: {
'duration_minutes': durationMinutes,
'price': price,
});
// Response will come via WebSocket (ChatBloc/ChatNotifier handles it)
} catch (e) {
state = const ClosureErrorData('Gagal meminta perpanjangan.');
}
}
void declineExtension() {
state = const ClosureShowGoodbyeData();
}
void reset() {
state = const ClosureInitialData();
}
Future<void> submitGoodbye(String sessionId, String message) async {
state = const ClosureSubmittingData();
try {
await ref.read(apiClientProvider).post('/api/shared/sessions/$sessionId/close-message', data: {
'message': message,
});
state = const ClosureCompleteData();
} catch (e) {
state = const ClosureErrorData('Gagal mengirim pesan penutup.');
}
}
}

View File

@@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'session_closure_notifier.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$sessionClosureHash() => r'5799a386e1e9c925601567b1fb8c684be7c7e23c';
/// See also [SessionClosure].
@ProviderFor(SessionClosure)
final sessionClosureProvider =
NotifierProvider<SessionClosure, SessionClosureData>.internal(
SessionClosure.new,
name: r'sessionClosureProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$sessionClosureHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$SessionClosure = Notifier<SessionClosureData>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

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

View File

@@ -0,0 +1,24 @@
// 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

View File

@@ -84,12 +84,17 @@ class NotificationService {
} }
static void _navigateFromMessage(Map<String, dynamic> data) { static void _navigateFromMessage(Map<String, dynamic> data) {
if (_router == null) return;
final sessionId = data['session_id'] as String?; final sessionId = data['session_id'] as String?;
if (sessionId == null || _router == null) return;
final type = data['type'] as String?; final type = data['type'] as String?;
if (type == 'chat_message' || type == 'chat_request') {
_router!.push('/chat/session/$sessionId'); if (type == 'session_closing' || type == 'session_expired') {
// Navigate to the chat session — closure UI will show
if (sessionId != null) {
_router!.push('/chat/session/$sessionId', extra: 'Bestie');
}
} else if ((type == 'chat_message' || type == 'paired') && sessionId != null) {
_router!.push('/chat/session/$sessionId', extra: 'Bestie');
} }
} }
} }

View File

@@ -1,230 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:equatable/equatable.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../api/api_client.dart';
import '../constants.dart';
// Events
abstract class PairingEvent extends Equatable {
@override
List<Object?> get props => [];
}
class RequestPairing extends PairingEvent {}
class RequestPairingWithTier extends PairingEvent {
final int? durationMinutes;
final int? price;
final bool isFreeTrial;
RequestPairingWithTier({this.durationMinutes, this.price, this.isFreeTrial = false});
@override
List<Object?> get props => [durationMinutes, price, isFreeTrial];
}
class CancelPairing extends PairingEvent {}
class _PairingStatusUpdate extends PairingEvent {
final Map<String, dynamic> data;
_PairingStatusUpdate(this.data);
@override
List<Object?> get props => [data];
}
class _PairingTimeout extends PairingEvent {}
class _ConnectionError extends PairingEvent {}
// States
abstract class PairingState extends Equatable {
@override
List<Object?> get props => [];
}
class PairingInitial extends PairingState {}
class PairingSearching extends PairingState {
final String sessionId;
PairingSearching(this.sessionId);
@override
List<Object?> get props => [sessionId];
}
class PairingBestieFound extends PairingState {
final String sessionId;
final String mitraName;
PairingBestieFound({required this.sessionId, required this.mitraName});
@override
List<Object?> get props => [sessionId, mitraName];
}
class PairingActive extends PairingState {
final String sessionId;
final String mitraName;
PairingActive({required this.sessionId, required this.mitraName});
@override
List<Object?> get props => [sessionId, mitraName];
}
class PairingNoBestie extends PairingState {}
class PairingCancelled extends PairingState {}
class PairingError extends PairingState {
final String message;
PairingError(this.message);
@override
List<Object?> get props => [message];
}
// Bloc
class PairingBloc extends Bloc<PairingEvent, PairingState> {
final ApiClient apiClient;
Timer? _timeoutTimer;
WebSocketChannel? _channel;
StreamSubscription? _wsSubscription;
PairingBloc({required this.apiClient}) : super(PairingInitial()) {
on<RequestPairing>(_onRequestPairing);
on<RequestPairingWithTier>(_onRequestPairingWithTier);
on<CancelPairing>(_onCancelPairing);
on<_PairingStatusUpdate>(_onStatusUpdate);
on<_PairingTimeout>(_onTimeout);
on<_ConnectionError>(_onConnectionError);
}
Future<void> _onRequestPairing(RequestPairing event, Emitter<PairingState> emit) async {
await _doPairingRequest(emit, {});
}
Future<void> _onRequestPairingWithTier(RequestPairingWithTier event, Emitter<PairingState> emit) async {
final body = <String, dynamic>{};
if (event.isFreeTrial) {
body['is_free_trial'] = true;
} else {
body['duration_minutes'] = event.durationMinutes;
body['price'] = event.price;
}
await _doPairingRequest(emit, body);
}
Future<void> _doPairingRequest(Emitter<PairingState> emit, Map<String, dynamic> body) async {
if (state is! PairingInitial) {
emit(PairingInitial());
}
try {
// Connect to WebSocket first to listen for pairing status
await _connectWebSocket();
final response = await apiClient.post('/api/client/chat/request', data: body);
final data = response['data'] as Map<String, dynamic>;
final sessionId = data['id'] as String;
emit(PairingSearching(sessionId));
_timeoutTimer = Timer(const Duration(seconds: 60), () {
add(_PairingTimeout());
});
} on DioException catch (e) {
_cleanup();
final code = e.response?.data?['error']?['code'];
if (code == 'NO_MITRA_AVAILABLE') {
emit(PairingNoBestie());
} else if (code == 'ALREADY_ACTIVE') {
emit(PairingError('Kamu sudah memiliki sesi aktif.'));
} else if (code == 'FREE_TRIAL_INELIGIBLE') {
emit(PairingError('Kamu tidak memenuhi syarat untuk free trial.'));
} else {
emit(PairingError('Gagal memulai. Coba lagi.'));
}
}
}
Future<void> _connectWebSocket() async {
_closeWebSocket();
final user = FirebaseAuth.instance.currentUser;
if (user == null) return;
final token = await user.getIdToken();
final wsUrl = ApiClient.baseUrl
.replaceFirst('https://', 'wss://')
.replaceFirst('http://', 'ws://');
_channel = WebSocketChannel.connect(Uri.parse('$wsUrl/api/shared/ws'));
_wsSubscription = _channel!.stream.listen(
(raw) {
final data = jsonDecode(raw as String) as Map<String, dynamic>;
if (data['type'] == WsMessage.authOk) return;
add(_PairingStatusUpdate(data));
},
onError: (_) => add(_ConnectionError()),
onDone: () => add(_ConnectionError()),
);
// Authenticate without session_id — just for receiving pairing status
_channel!.sink.add(jsonEncode({
'type': WsMessage.auth,
'token': token,
}));
}
Future<void> _onConnectionError(_ConnectionError event, Emitter<PairingState> emit) async {
// WebSocket disconnected during pairing — stay in current state,
// FCM will still deliver notifications
}
Future<void> _onStatusUpdate(_PairingStatusUpdate event, Emitter<PairingState> emit) async {
final data = event.data;
final type = data['type'] as String?;
if (type == WsMessage.paired) {
_cleanup();
final mitraName = data['mitra_display_name'] as String? ?? 'Bestie';
final sessionId = data['session_id'] as String;
emit(PairingBestieFound(sessionId: sessionId, mitraName: mitraName));
// Brief delay then transition to active
await Future.delayed(const Duration(seconds: 2));
emit(PairingActive(sessionId: sessionId, mitraName: mitraName));
} else if (type == SessionStatus.expired) {
_cleanup();
emit(PairingNoBestie());
}
}
Future<void> _onCancelPairing(CancelPairing event, Emitter<PairingState> emit) async {
if (state is PairingSearching) {
final sessionId = (state as PairingSearching).sessionId;
try {
await apiClient.post('/api/client/chat/request/$sessionId/cancel');
} catch (_) {}
_cleanup();
emit(PairingCancelled());
}
}
Future<void> _onTimeout(_PairingTimeout event, Emitter<PairingState> emit) async {
_cleanup();
emit(PairingNoBestie());
}
void _closeWebSocket() {
_wsSubscription?.cancel();
_wsSubscription = null;
_channel?.sink.close();
_channel = null;
}
void _cleanup() {
_timeoutTimer?.cancel();
_timeoutTimer = null;
_closeWebSocket();
}
@override
Future<void> close() {
_cleanup();
return super.close();
}
}

View File

@@ -0,0 +1,183 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../api/api_client.dart';
import '../api/api_client_provider.dart';
import '../constants.dart';
part 'pairing_notifier.g.dart';
// States
sealed class PairingData {
const PairingData();
}
class PairingInitialData extends PairingData {
const PairingInitialData();
}
class PairingSearchingData extends PairingData {
final String sessionId;
const PairingSearchingData(this.sessionId);
}
class PairingBestieFoundData extends PairingData {
final String sessionId;
final String mitraName;
const PairingBestieFoundData({required this.sessionId, required this.mitraName});
}
class PairingActiveData extends PairingData {
final String sessionId;
final String mitraName;
const PairingActiveData({required this.sessionId, required this.mitraName});
}
class PairingNoBestieData extends PairingData {
const PairingNoBestieData();
}
class PairingCancelledData extends PairingData {
const PairingCancelledData();
}
class PairingErrorData extends PairingData {
final String message;
const PairingErrorData(this.message);
}
@Riverpod(keepAlive: true)
class Pairing extends _$Pairing {
Timer? _timeoutTimer;
WebSocketChannel? _channel;
StreamSubscription? _wsSubscription;
ApiClient get _apiClient => ref.read(apiClientProvider);
@override
PairingData build() => const PairingInitialData();
Future<void> requestPairing() async {
await _doPairingRequest({});
}
Future<void> requestPairingWithTier({int? durationMinutes, int? price, bool isFreeTrial = false}) async {
final body = <String, dynamic>{};
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();
}
try {
await _connectWebSocket();
final response = await _apiClient.post('/api/client/chat/request', data: body);
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();
});
} on DioException catch (e) {
_cleanup();
final code = e.response?.data?['error']?['code'];
if (code == 'NO_MITRA_AVAILABLE') {
state = const PairingNoBestieData();
} 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.');
}
}
}
Future<void> _connectWebSocket() async {
_closeWebSocket();
final user = FirebaseAuth.instance.currentUser;
if (user == null) return;
final token = await user.getIdToken();
final wsUrl = ApiClient.baseUrl
.replaceFirst('https://', 'wss://')
.replaceFirst('http://', 'ws://');
_channel = WebSocketChannel.connect(Uri.parse('$wsUrl/api/shared/ws'));
_wsSubscription = _channel!.stream.listen(
(raw) {
final data = jsonDecode(raw as String) as Map<String, dynamic>;
if (data['type'] == WsMessage.authOk) return;
_onStatusUpdate(data);
},
onError: (_) {},
onDone: () {},
);
_channel!.sink.add(jsonEncode({
'type': WsMessage.auth,
'token': token,
}));
}
Future<void> _onStatusUpdate(Map<String, dynamic> data) async {
final type = data['type'] as String?;
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);
await Future.delayed(const Duration(seconds: 2));
state = PairingActiveData(sessionId: sessionId, mitraName: mitraName);
} else if (type == SessionStatus.expired) {
_cleanup();
state = const PairingNoBestieData();
}
}
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 reset() {
_cleanup();
state = const PairingInitialData();
}
void _closeWebSocket() {
_wsSubscription?.cancel();
_wsSubscription = null;
_channel?.sink.close();
_channel = null;
}
void _cleanup() {
_timeoutTimer?.cancel();
_timeoutTimer = null;
_closeWebSocket();
}
}

View File

@@ -0,0 +1,24 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'pairing_notifier.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$pairingHash() => r'93049804c1d55a0195a56b97d6e7f34fe6ab8086';
/// See also [Pairing].
@ProviderFor(Pairing)
final pairingProvider = NotifierProvider<Pairing, PairingData>.internal(
Pairing.new,
name: r'pairingProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$pairingHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$Pairing = Notifier<PairingData>;
// 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

@@ -1,15 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/auth/auth_bloc.dart'; import '../../../core/auth/auth_notifier.dart';
class DisplayNameScreen extends StatefulWidget { class DisplayNameScreen extends ConsumerStatefulWidget {
const DisplayNameScreen({super.key}); const DisplayNameScreen({super.key});
@override @override
State<DisplayNameScreen> createState() => _DisplayNameScreenState(); ConsumerState<DisplayNameScreen> createState() => _DisplayNameScreenState();
} }
class _DisplayNameScreenState extends State<DisplayNameScreen> { class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> {
final _controller = TextEditingController(); final _controller = TextEditingController();
@override @override
@@ -21,18 +21,21 @@ class _DisplayNameScreenState extends State<DisplayNameScreen> {
void _submit() { void _submit() {
final name = _controller.text.trim(); final name = _controller.text.trim();
if (name.isEmpty) return; if (name.isEmpty) return;
context.read<AuthBloc>().add(AnonymousLoginRequested(name)); ref.read(authProvider.notifier).loginAnonymous(name);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocListener<AuthBloc, AuthState>( final authState = ref.watch(authProvider);
listener: (context, state) { final isLoading = authState is AsyncLoading;
if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message))); ref.listen(authProvider, (prev, next) {
if (next is AsyncError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
} }
}, });
child: Scaffold(
return Scaffold(
appBar: AppBar(title: const Text('Siapa namamu?')), appBar: AppBar(title: const Text('Siapa namamu?')),
body: Padding( body: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
@@ -51,18 +54,15 @@ class _DisplayNameScreenState extends State<DisplayNameScreen> {
onSubmitted: (_) => _submit(), onSubmitted: (_) => _submit(),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
BlocBuilder<AuthBloc, AuthState>( ElevatedButton(
builder: (context, state) => ElevatedButton( onPressed: isLoading ? null : _submit,
onPressed: state is AuthLoading ? null : _submit, child: isLoading
child: state is AuthLoading
? const CircularProgressIndicator() ? const CircularProgressIndicator()
: const Text('Lanjut'), : const Text('Lanjut'),
), ),
),
], ],
), ),
), ),
),
); );
} }
} }

View File

@@ -1,18 +1,18 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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_bloc.dart'; import '../../../core/auth/auth_notifier.dart';
/// Shown when anonymity is disabled by admin. /// Shown when anonymity is disabled by admin.
/// User must link their account. Display name is pre-filled. /// User must link their account. Display name is pre-filled.
class ForceRegisterScreen extends StatefulWidget { class ForceRegisterScreen extends ConsumerStatefulWidget {
const ForceRegisterScreen({super.key}); const ForceRegisterScreen({super.key});
@override @override
State<ForceRegisterScreen> createState() => _ForceRegisterScreenState(); ConsumerState<ForceRegisterScreen> createState() => _ForceRegisterScreenState();
} }
class _ForceRegisterScreenState extends State<ForceRegisterScreen> { class _ForceRegisterScreenState extends ConsumerState<ForceRegisterScreen> {
final _phoneController = TextEditingController(); final _phoneController = TextEditingController();
@override @override
@@ -23,20 +23,24 @@ class _ForceRegisterScreenState extends State<ForceRegisterScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocListener<AuthBloc, AuthState>( final authState = ref.watch(authProvider);
listener: (context, state) { final isLoading = authState is AsyncLoading;
if (state is AuthOtpSent) {
ref.listen(authProvider, (prev, next) {
final data = next.valueOrNull;
if (data is AuthOtpSentData) {
context.push('/auth/otp', extra: _phoneController.text.trim()); context.push('/auth/otp', extra: _phoneController.text.trim());
} }
if (state is AuthAuthenticated) { if (data is AuthAuthenticatedData) {
// After linking, link account to existing anonymous record // After social login succeeds, link account to existing anonymous record
context.read<AuthBloc>().add(LinkAccountRequested()); ref.read(authProvider.notifier).linkAccount();
} }
if (state is AuthError) { if (next is AsyncError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message))); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
} }
}, });
child: Scaffold(
return Scaffold(
appBar: AppBar(title: const Text('Verifikasi Akun')), appBar: AppBar(title: const Text('Verifikasi Akun')),
body: Padding( body: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
@@ -48,23 +52,19 @@ class _ForceRegisterScreenState extends State<ForceRegisterScreen> {
style: TextStyle(fontSize: 16), style: TextStyle(fontSize: 16),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
BlocBuilder<AuthBloc, AuthState>( ElevatedButton.icon(
builder: (context, state) => ElevatedButton.icon(
icon: const Icon(Icons.g_mobiledata), icon: const Icon(Icons.g_mobiledata),
onPressed: state is AuthLoading ? null onPressed: isLoading ? null
: () => context.read<AuthBloc>().add(GoogleLoginRequested()), : () => ref.read(authProvider.notifier).loginGoogle(),
label: const Text('Lanjut dengan Google'), label: const Text('Lanjut dengan Google'),
), ),
),
const SizedBox(height: 12), const SizedBox(height: 12),
BlocBuilder<AuthBloc, AuthState>( ElevatedButton.icon(
builder: (context, state) => ElevatedButton.icon(
icon: const Icon(Icons.apple), icon: const Icon(Icons.apple),
onPressed: state is AuthLoading ? null onPressed: isLoading ? null
: () => context.read<AuthBloc>().add(AppleLoginRequested()), : () => ref.read(authProvider.notifier).loginApple(),
label: const Text('Lanjut dengan Apple'), label: const Text('Lanjut dengan Apple'),
), ),
),
const Padding( const Padding(
padding: EdgeInsets.symmetric(vertical: 24), padding: EdgeInsets.symmetric(vertical: 24),
child: Row(children: [ child: Row(children: [
@@ -83,22 +83,19 @@ class _ForceRegisterScreenState extends State<ForceRegisterScreen> {
keyboardType: TextInputType.phone, keyboardType: TextInputType.phone,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
BlocBuilder<AuthBloc, AuthState>( ElevatedButton(
builder: (context, state) => ElevatedButton( onPressed: isLoading ? null : () {
onPressed: state is AuthLoading ? null : () {
final phone = _phoneController.text.trim(); final phone = _phoneController.text.trim();
if (phone.isEmpty) return; if (phone.isEmpty) return;
context.read<AuthBloc>().add(PhoneOtpRequested(phone)); ref.read(authProvider.notifier).requestOtp(phone);
}, },
child: state is AuthLoading child: isLoading
? const CircularProgressIndicator() ? const CircularProgressIndicator()
: const Text('Kirim OTP'), : const Text('Kirim OTP'),
), ),
),
], ],
), ),
), ),
),
); );
} }
} }

View File

@@ -1,17 +1,28 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/auth/auth_bloc.dart'; import '../../../core/auth/auth_notifier.dart';
class OtpScreen extends StatefulWidget { class OtpScreen extends ConsumerStatefulWidget {
final String phone; final String phone;
const OtpScreen({super.key, required this.phone}); const OtpScreen({super.key, required this.phone});
@override @override
State<OtpScreen> createState() => _OtpScreenState(); ConsumerState<OtpScreen> createState() => _OtpScreenState();
} }
class _OtpScreenState extends State<OtpScreen> { class _OtpScreenState extends ConsumerState<OtpScreen> {
final _otpController = TextEditingController(); final _otpController = TextEditingController();
String? _verificationId;
@override
void initState() {
super.initState();
// Capture verification ID from current state
final data = ref.read(authProvider).valueOrNull;
if (data is AuthOtpSentData) {
_verificationId = data.verificationId;
}
}
@override @override
void dispose() { void dispose() {
@@ -21,13 +32,22 @@ class _OtpScreenState extends State<OtpScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocListener<AuthBloc, AuthState>( final authState = ref.watch(authProvider);
listener: (context, state) { final isLoading = authState is AsyncLoading;
if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message))); // Update verification ID if state changes
final data = authState.valueOrNull;
if (data is AuthOtpSentData) {
_verificationId = data.verificationId;
} }
},
child: Scaffold( ref.listen(authProvider, (prev, next) {
if (next is AsyncError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
}
});
return Scaffold(
appBar: AppBar(title: const Text('Masukkan OTP')), appBar: AppBar(title: const Text('Masukkan OTP')),
body: Padding( body: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
@@ -46,23 +66,19 @@ class _OtpScreenState extends State<OtpScreen> {
maxLength: 6, maxLength: 6,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
BlocBuilder<AuthBloc, AuthState>( ElevatedButton(
builder: (context, state) => ElevatedButton( onPressed: isLoading ? null : () {
onPressed: state is AuthLoading ? null : () {
final otp = _otpController.text.trim(); final otp = _otpController.text.trim();
if (otp.length != 6) return; if (otp.length != 6 || _verificationId == null) return;
final verificationId = (state is AuthOtpSent) ? state.verificationId : ''; ref.read(authProvider.notifier).verifyOtp(_verificationId!, otp);
context.read<AuthBloc>().add(OtpVerified(verificationId, otp));
}, },
child: state is AuthLoading child: isLoading
? const CircularProgressIndicator() ? const CircularProgressIndicator()
: const Text('Verifikasi'), : const Text('Verifikasi'),
), ),
),
], ],
), ),
), ),
),
); );
} }
} }

View File

@@ -1,16 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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_bloc.dart'; import '../../../core/auth/auth_notifier.dart';
class RegisterScreen extends StatefulWidget { class RegisterScreen extends ConsumerStatefulWidget {
const RegisterScreen({super.key}); const RegisterScreen({super.key});
@override @override
State<RegisterScreen> createState() => _RegisterScreenState(); ConsumerState<RegisterScreen> createState() => _RegisterScreenState();
} }
class _RegisterScreenState extends State<RegisterScreen> { class _RegisterScreenState extends ConsumerState<RegisterScreen> {
final _phoneController = TextEditingController(); final _phoneController = TextEditingController();
@override @override
@@ -21,39 +21,39 @@ class _RegisterScreenState extends State<RegisterScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocListener<AuthBloc, AuthState>( final authState = ref.watch(authProvider);
listener: (context, state) { final isLoading = authState is AsyncLoading;
if (state is AuthOtpSent) {
ref.listen(authProvider, (prev, next) {
final data = next.valueOrNull;
if (data is AuthOtpSentData) {
context.push('/auth/otp', extra: _phoneController.text.trim()); context.push('/auth/otp', extra: _phoneController.text.trim());
} }
if (state is AuthError) { if (next is AsyncError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message))); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
} }
}, });
child: Scaffold(
return Scaffold(
appBar: AppBar(title: const Text('Masuk / Daftar')), appBar: AppBar(title: const Text('Masuk / Daftar')),
body: Padding( body: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
BlocBuilder<AuthBloc, AuthState>( ElevatedButton.icon(
builder: (context, state) => ElevatedButton.icon(
icon: const Icon(Icons.g_mobiledata), icon: const Icon(Icons.g_mobiledata),
onPressed: state is AuthLoading ? null onPressed: isLoading ? null
: () => context.read<AuthBloc>().add(GoogleLoginRequested()), : () => ref.read(authProvider.notifier).loginGoogle(),
label: const Text('Lanjut dengan Google'), label: const Text('Lanjut dengan Google'),
), ),
),
const SizedBox(height: 12), const SizedBox(height: 12),
BlocBuilder<AuthBloc, AuthState>( ElevatedButton.icon(
builder: (context, state) => ElevatedButton.icon(
icon: const Icon(Icons.apple), icon: const Icon(Icons.apple),
onPressed: state is AuthLoading ? null onPressed: isLoading ? null
: () => context.read<AuthBloc>().add(AppleLoginRequested()), : () => ref.read(authProvider.notifier).loginApple(),
label: const Text('Lanjut dengan Apple'), label: const Text('Lanjut dengan Apple'),
), ),
),
const Padding( const Padding(
padding: EdgeInsets.symmetric(vertical: 24), padding: EdgeInsets.symmetric(vertical: 24),
child: Row(children: [ child: Row(children: [
@@ -72,22 +72,19 @@ class _RegisterScreenState extends State<RegisterScreen> {
keyboardType: TextInputType.phone, keyboardType: TextInputType.phone,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
BlocBuilder<AuthBloc, AuthState>( ElevatedButton(
builder: (context, state) => ElevatedButton( onPressed: isLoading ? null : () {
onPressed: state is AuthLoading ? null : () {
final phone = _phoneController.text.trim(); final phone = _phoneController.text.trim();
if (phone.isEmpty) return; if (phone.isEmpty) return;
context.read<AuthBloc>().add(PhoneOtpRequested(phone)); ref.read(authProvider.notifier).requestOtp(phone);
}, },
child: state is AuthLoading child: isLoading
? const CircularProgressIndicator() ? const CircularProgressIndicator()
: const Text('Kirim OTP'), : const Text('Kirim OTP'),
), ),
),
], ],
), ),
), ),
),
); );
} }
} }

View File

@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/auth/auth_notifier.dart';
class SetDisplayNameScreen extends ConsumerStatefulWidget {
const SetDisplayNameScreen({super.key});
@override
ConsumerState<SetDisplayNameScreen> createState() => _SetDisplayNameScreenState();
}
class _SetDisplayNameScreenState extends ConsumerState<SetDisplayNameScreen> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _submit() {
final name = _controller.text.trim();
if (name.isEmpty) return;
ref.read(authProvider.notifier).setDisplayName(name);
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
final isLoading = authState is AsyncLoading;
ref.listen(authProvider, (prev, next) {
if (next is AsyncError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
}
});
return Scaffold(
appBar: AppBar(title: const Text('Siapa namamu?')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('Pilih nama yang ingin kamu gunakan. Nama ini akan terlihat oleh Bestie kamu.'),
const SizedBox(height: 24),
TextField(
controller: _controller,
decoration: const InputDecoration(
labelText: 'Nama panggilan',
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.done,
onSubmitted: (_) => _submit(),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: isLoading ? null : _submit,
child: isLoading
? const CircularProgressIndicator()
: const Text('Lanjut'),
),
],
),
),
);
}
}

View File

@@ -1,9 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/pairing/pairing_bloc.dart'; import '../../../core/pairing/pairing_notifier.dart';
class BestieFoundScreen extends StatelessWidget { class BestieFoundScreen extends ConsumerWidget {
final String sessionId; final String sessionId;
final String mitraName; final String mitraName;
@@ -14,14 +14,14 @@ class BestieFoundScreen extends StatelessWidget {
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
return BlocListener<PairingBloc, PairingState>( ref.listen(pairingProvider, (prev, next) {
listener: (context, state) { if (next is PairingActiveData) {
if (state is PairingActive) { context.go('/chat/session/${next.sessionId}', extra: next.mitraName);
context.go('/chat/session/${state.sessionId}', extra: state.mitraName);
} }
}, });
child: Scaffold(
return Scaffold(
body: Center( body: Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(32), padding: const EdgeInsets.all(32),
@@ -46,7 +46,6 @@ class BestieFoundScreen extends StatelessWidget {
), ),
), ),
), ),
),
); );
} }
} }

View File

@@ -1,16 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/api/api_client.dart'; import '../../../core/api/api_client_provider.dart';
class ChatHistoryScreen extends StatefulWidget { class ChatHistoryScreen extends ConsumerStatefulWidget {
const ChatHistoryScreen({super.key}); const ChatHistoryScreen({super.key});
@override @override
State<ChatHistoryScreen> createState() => _ChatHistoryScreenState(); ConsumerState<ChatHistoryScreen> createState() => _ChatHistoryScreenState();
} }
class _ChatHistoryScreenState extends State<ChatHistoryScreen> { class _ChatHistoryScreenState extends ConsumerState<ChatHistoryScreen> {
List<Map<String, dynamic>> _sessions = []; List<Map<String, dynamic>> _sessions = [];
bool _loading = true; bool _loading = true;
@@ -22,7 +22,7 @@ class _ChatHistoryScreenState extends State<ChatHistoryScreen> {
Future<void> _loadHistory() async { Future<void> _loadHistory() async {
try { try {
final api = context.read<ApiClient>(); final api = ref.read(apiClientProvider);
final response = await api.get('/api/client/chat/history'); final response = await api.get('/api/client/chat/history');
final items = (response['data']['items'] as List<dynamic>).cast<Map<String, dynamic>>(); final items = (response['data']['items'] as List<dynamic>).cast<Map<String, dynamic>>();
setState(() { setState(() {

View File

@@ -1,23 +1,23 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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/chat_bloc.dart'; import '../../../core/chat/chat_notifier.dart';
import '../../../core/chat/session_closure_bloc.dart'; import '../../../core/chat/session_closure_notifier.dart';
import '../../../core/constants.dart'; import '../../../core/constants.dart';
import '../widgets/pricing_bottom_sheet.dart'; import '../widgets/pricing_bottom_sheet.dart';
class ChatScreen extends StatefulWidget { class ChatScreen extends ConsumerStatefulWidget {
final String sessionId; final String sessionId;
final String mitraName; final String mitraName;
const ChatScreen({super.key, required this.sessionId, required this.mitraName}); const ChatScreen({super.key, required this.sessionId, required this.mitraName});
@override @override
State<ChatScreen> createState() => _ChatScreenState(); ConsumerState<ChatScreen> createState() => _ChatScreenState();
} }
class _ChatScreenState extends State<ChatScreen> { class _ChatScreenState extends ConsumerState<ChatScreen> {
final _messageController = TextEditingController(); final _messageController = TextEditingController();
final _scrollController = ScrollController(); final _scrollController = ScrollController();
Timer? _typingThrottle; Timer? _typingThrottle;
@@ -25,12 +25,12 @@ class _ChatScreenState extends State<ChatScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
context.read<ChatBloc>().add(ConnectChat(widget.sessionId)); ref.read(chatProvider.notifier).connect(widget.sessionId);
} }
@override @override
void dispose() { void dispose() {
context.read<ChatBloc>().add(DisconnectChat()); ref.read(chatProvider.notifier).disconnect();
_messageController.dispose(); _messageController.dispose();
_scrollController.dispose(); _scrollController.dispose();
_typingThrottle?.cancel(); _typingThrottle?.cancel();
@@ -51,122 +51,100 @@ class _ChatScreenState extends State<ChatScreen> {
void _onTextChanged(String text) { void _onTextChanged(String text) {
if (_typingThrottle?.isActive ?? false) return; if (_typingThrottle?.isActive ?? false) return;
context.read<ChatBloc>().add(SendTyping()); ref.read(chatProvider.notifier).sendTyping();
_typingThrottle = Timer(const Duration(seconds: 2), () {}); _typingThrottle = Timer(const Duration(seconds: 2), () {});
} }
void _sendMessage() { void _sendMessage() {
final text = _messageController.text.trim(); final text = _messageController.text.trim();
if (text.isEmpty) return; if (text.isEmpty) return;
context.read<ChatBloc>().add(SendMessage(text)); ref.read(chatProvider.notifier).sendMessage(text);
_messageController.clear(); _messageController.clear();
_scrollToBottom(); _scrollToBottom();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MultiBlocListener( final chatState = ref.watch(chatProvider);
listeners: [ final closureState = ref.watch(sessionClosureProvider);
BlocListener<ChatBloc, ChatState>(
listenWhen: (prev, curr) { // Listen for closure complete to navigate home
if (prev is ChatConnected && curr is ChatConnected) { ref.listen(sessionClosureProvider, (prev, next) {
return prev.sessionExpired != curr.sessionExpired || if (next is ClosureCompleteData) {
prev.sessionClosing != curr.sessionClosing || context.go('/home');
prev.sessionPaused != curr.sessionPaused ||
prev.messages.length != curr.messages.length;
} }
return true; });
},
listener: (context, state) { // Listen for chat state changes to manage closure state
if (state is ChatConnected) { ref.listen(chatProvider, (prev, next) {
// Only trigger goodbye if closing AND not expired (expired shows extend dialog first) if (next is ChatConnectedData) {
if (state.sessionClosing && !state.sessionExpired) { if (next.sessionClosing && !next.sessionExpired) {
final closureState = context.read<SessionClosureBloc>().state; final closure = ref.read(sessionClosureProvider);
if (closureState is ClosureInitial) { if (closure is ClosureInitialData) {
context.read<SessionClosureBloc>().add(DeclineExtension()); ref.read(sessionClosureProvider.notifier).declineExtension();
} }
} }
// Extension accepted — reset closure bloc to go back to chat if (!next.sessionPaused && !next.sessionExpired && !next.sessionClosing) {
if (!state.sessionPaused && !state.sessionExpired && !state.sessionClosing) { final closure = ref.read(sessionClosureProvider);
final closureState = context.read<SessionClosureBloc>().state; if (closure is! ClosureInitialData) {
if (closureState is! ClosureInitial) { ref.read(sessionClosureProvider.notifier).reset();
context.read<SessionClosureBloc>().add(ResetClosure());
} }
} }
_scrollToBottom(); _scrollToBottom();
// Auto-mark received messages as read final unread = next.messages
final unread = state.messages
.where((m) => m.senderType == UserType.mitra && m.status != MessageStatus.read) .where((m) => m.senderType == UserType.mitra && m.status != MessageStatus.read)
.map((m) => m.id) .map((m) => m.id)
.toList(); .toList();
if (unread.isNotEmpty) { if (unread.isNotEmpty) {
context.read<ChatBloc>().add(MarkMessagesRead(unread)); ref.read(chatProvider.notifier).markRead(unread);
} }
} }
}, });
),
BlocListener<SessionClosureBloc, SessionClosureState>( return Scaffold(
listener: (context, state) {
if (state is ClosureComplete) {
context.go('/home');
}
},
),
],
child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(widget.mitraName), title: Text(widget.mitraName),
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
actions: [ actions: [
BlocBuilder<ChatBloc, ChatState>( if (chatState is ChatConnectedData && chatState.remainingSeconds != null)
builder: (context, state) { Padding(
if (state is ChatConnected && state.remainingSeconds != null) {
return Padding(
padding: const EdgeInsets.only(right: 16), padding: const EdgeInsets.only(right: 16),
child: Center( child: Center(
child: Text( child: Text(
'${state.remainingSeconds}s', '${chatState.remainingSeconds}s',
style: TextStyle( style: TextStyle(
color: state.remainingSeconds! < 30 ? Colors.red : null, color: chatState.remainingSeconds! < 30 ? Colors.red : null,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
), ),
);
}
return const SizedBox.shrink();
},
), ),
], ],
), ),
body: BlocBuilder<ChatBloc, ChatState>( body: _buildBody(chatState, closureState),
builder: (context, state) {
if (state is ChatConnecting) {
return const Center(child: CircularProgressIndicator());
}
if (state is ChatError) {
return Center(child: Text(state.message));
}
if (state is ChatConnected) {
return _buildChatBody(context, state);
}
return const SizedBox.shrink();
},
),
),
); );
} }
Widget _buildChatBody(BuildContext context, ChatConnected state) { Widget _buildBody(ChatData chatState, SessionClosureData closureState) {
// Show goodbye input (takes priority — user already decided to close) if (chatState is ChatConnectingData) {
final closureState = context.watch<SessionClosureBloc>().state; return const Center(child: CircularProgressIndicator());
if (closureState is ClosureShowGoodbye || closureState is ClosureSubmitting) { }
return _buildGoodbyeView(context, closureState); if (chatState is ChatErrorData) {
return Center(child: Text(chatState.message));
}
if (chatState is ChatConnectedData) {
return _buildChatBody(chatState, closureState);
}
return const SizedBox.shrink();
}
Widget _buildChatBody(ChatConnectedData state, SessionClosureData closureState) {
if (closureState is ClosureShowGoodbyeData || closureState is ClosureSubmittingData) {
return _buildGoodbyeView(closureState);
} }
// Show session expired dialog (extend or close?)
if (state.sessionExpired) { if (state.sessionExpired) {
return _buildExpiredView(context); return _buildExpiredView();
} }
if (state.sessionPaused) { if (state.sessionPaused) {
@@ -195,7 +173,7 @@ class _ChatScreenState extends State<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)),
), ),
), ),
_buildInputBar(context, state), _buildInputBar(),
], ],
); );
} }
@@ -250,7 +228,7 @@ class _ChatScreenState extends State<ChatScreen> {
} }
} }
Widget _buildInputBar(BuildContext context, ChatConnected state) { Widget _buildInputBar() {
return SafeArea( return SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
@@ -280,7 +258,7 @@ class _ChatScreenState extends State<ChatScreen> {
); );
} }
Widget _buildExpiredView(BuildContext context) { Widget _buildExpiredView() {
return Center( return Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(32), padding: const EdgeInsets.all(32),
@@ -289,9 +267,8 @@ class _ChatScreenState extends State<ChatScreen> {
duration: const Duration(seconds: 300), duration: const Duration(seconds: 300),
builder: (context, remaining, _) { builder: (context, remaining, _) {
if (remaining <= 0) { if (remaining <= 0) {
// Auto-decline when countdown reaches 0
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<SessionClosureBloc>().add(DeclineExtension()); ref.read(sessionClosureProvider.notifier).declineExtension();
}); });
} }
final minutes = remaining ~/ 60; final minutes = remaining ~/ 60;
@@ -320,7 +297,7 @@ class _ChatScreenState extends State<ChatScreen> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
TextButton( TextButton(
onPressed: () => context.read<SessionClosureBloc>().add(DeclineExtension()), onPressed: () => ref.read(sessionClosureProvider.notifier).declineExtension(),
child: const Text('Tidak, akhiri sesi'), child: const Text('Tidak, akhiri sesi'),
), ),
], ],
@@ -331,7 +308,7 @@ class _ChatScreenState extends State<ChatScreen> {
); );
} }
Widget _buildGoodbyeView(BuildContext context, SessionClosureState closureState) { Widget _buildGoodbyeView(SessionClosureData closureState) {
final controller = TextEditingController(); final controller = TextEditingController();
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.all(32), padding: const EdgeInsets.all(32),
@@ -354,17 +331,17 @@ class _ChatScreenState extends State<ChatScreen> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton( ElevatedButton(
onPressed: closureState is ClosureSubmitting onPressed: closureState is ClosureSubmittingData
? null ? null
: () { : () {
final text = controller.text.trim(); final text = controller.text.trim();
if (text.isNotEmpty) { if (text.isNotEmpty) {
context.read<SessionClosureBloc>().add( ref.read(sessionClosureProvider.notifier).submitGoodbye(
SubmitGoodbye(sessionId: widget.sessionId, message: text), widget.sessionId, text,
); );
} }
}, },
child: closureState is ClosureSubmitting child: closureState is ClosureSubmittingData
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Kirim & Selesai'), : const Text('Kirim & Selesai'),
), ),

View File

@@ -1,18 +1,18 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_client.dart'; import '../../../core/api/api_client_provider.dart';
import '../../../core/constants.dart'; import '../../../core/constants.dart';
class ChatTranscriptScreen extends StatefulWidget { class ChatTranscriptScreen extends ConsumerStatefulWidget {
final String sessionId; final String sessionId;
const ChatTranscriptScreen({super.key, required this.sessionId}); const ChatTranscriptScreen({super.key, required this.sessionId});
@override @override
State<ChatTranscriptScreen> createState() => _ChatTranscriptScreenState(); ConsumerState<ChatTranscriptScreen> createState() => _ChatTranscriptScreenState();
} }
class _ChatTranscriptScreenState extends State<ChatTranscriptScreen> { class _ChatTranscriptScreenState extends ConsumerState<ChatTranscriptScreen> {
List<Map<String, dynamic>> _messages = []; List<Map<String, dynamic>> _messages = [];
List<Map<String, dynamic>> _closures = []; List<Map<String, dynamic>> _closures = [];
bool _loading = true; bool _loading = true;
@@ -25,7 +25,7 @@ class _ChatTranscriptScreenState extends State<ChatTranscriptScreen> {
Future<void> _loadTranscript() async { Future<void> _loadTranscript() async {
try { try {
final api = context.read<ApiClient>(); final api = ref.read(apiClientProvider);
final response = await api.get('/api/shared/chat/${widget.sessionId}/transcript'); final response = await api.get('/api/shared/chat/${widget.sessionId}/transcript');
final data = response['data'] as Map<String, dynamic>; final data = response['data'] as Map<String, dynamic>;
setState(() { setState(() {

View File

@@ -1,27 +1,27 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/pairing/pairing_bloc.dart'; import '../../../core/pairing/pairing_notifier.dart';
class SearchingScreen extends StatelessWidget { class SearchingScreen extends ConsumerWidget {
const SearchingScreen({super.key}); const SearchingScreen({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
return BlocListener<PairingBloc, PairingState>( ref.listen(pairingProvider, (prev, next) {
listener: (context, state) { if (next is PairingBestieFoundData) {
if (state is PairingBestieFound) {
context.go('/chat/found', extra: { context.go('/chat/found', extra: {
'sessionId': state.sessionId, 'sessionId': next.sessionId,
'mitraName': state.mitraName, 'mitraName': next.mitraName,
}); });
} else if (state is PairingNoBestie) { } else if (next is PairingNoBestieData) {
context.go('/chat/no-bestie'); context.go('/chat/no-bestie');
} else if (state is PairingCancelled) { } else if (next is PairingCancelledData) {
context.go('/home'); context.go('/home');
} }
}, });
child: Scaffold(
return Scaffold(
body: Center( body: Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(32), padding: const EdgeInsets.all(32),
@@ -42,14 +42,13 @@ class SearchingScreen extends StatelessWidget {
), ),
const SizedBox(height: 48), const SizedBox(height: 48),
OutlinedButton( OutlinedButton(
onPressed: () => context.read<PairingBloc>().add(CancelPairing()), onPressed: () => ref.read(pairingProvider.notifier).cancelPairing(),
child: const Text('Batalkan'), child: const Text('Batalkan'),
), ),
], ],
), ),
), ),
), ),
),
); );
} }
} }

View File

@@ -1,9 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/api/api_client.dart'; import '../../../core/api/api_client_provider.dart';
class SessionActiveScreen extends StatelessWidget { class SessionActiveScreen extends ConsumerWidget {
final String sessionId; final String sessionId;
final String mitraName; final String mitraName;
@@ -14,7 +14,7 @@ class SessionActiveScreen extends StatelessWidget {
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Sesi Aktif'), title: const Text('Sesi Aktif'),
@@ -42,7 +42,7 @@ class SessionActiveScreen extends StatelessWidget {
const SizedBox(height: 48), const SizedBox(height: 48),
ElevatedButton( ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.red), style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
onPressed: () => _endSession(context), onPressed: () => _endSession(context, ref),
child: const Text('Akhiri Sesi', style: TextStyle(color: Colors.white)), child: const Text('Akhiri Sesi', style: TextStyle(color: Colors.white)),
), ),
], ],
@@ -52,7 +52,7 @@ class SessionActiveScreen extends StatelessWidget {
); );
} }
Future<void> _endSession(BuildContext context) async { Future<void> _endSession(BuildContext context, WidgetRef ref) async {
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
@@ -67,7 +67,7 @@ class SessionActiveScreen extends StatelessWidget {
if (confirmed == true && context.mounted) { if (confirmed == true && context.mounted) {
try { try {
final apiClient = context.read<ApiClient>(); final apiClient = ref.read(apiClientProvider);
await apiClient.post('/api/client/chat/session/$sessionId/end'); await apiClient.post('/api/client/chat/session/$sessionId/end');
if (context.mounted) context.go('/home'); if (context.mounted) context.go('/home');
} catch (_) { } catch (_) {

View File

@@ -1,11 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_client.dart'; import '../../../core/chat/chat_opening_provider.dart';
import '../../../core/chat/chat_opening_bloc.dart'; import '../../../core/chat/session_closure_notifier.dart';
import '../../../core/chat/session_closure_bloc.dart'; import '../../../core/pairing/pairing_notifier.dart';
import '../../../core/pairing/pairing_bloc.dart';
class PricingBottomSheet extends StatelessWidget { class PricingBottomSheet extends ConsumerWidget {
/// If set, the bottom sheet is in "extension" mode — selecting a tier extends the session. /// If set, the bottom sheet is in "extension" mode — selecting a tier extends the session.
final String? extensionSessionId; final String? extensionSessionId;
@@ -16,15 +15,7 @@ class PricingBottomSheet extends StatelessWidget {
return showModalBottomSheet( return showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: (_) => BlocProvider( builder: (_) => const PricingBottomSheet(),
create: (ctx) => ChatOpeningBloc(apiClient: ctx.read<ApiClient>())..add(LoadPricing()),
child: MultiBlocProvider(
providers: [
BlocProvider.value(value: context.read<PairingBloc>()),
],
child: const PricingBottomSheet(),
),
),
); );
} }
@@ -33,15 +24,7 @@ class PricingBottomSheet extends StatelessWidget {
return showModalBottomSheet( return showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: (_) => BlocProvider( builder: (_) => PricingBottomSheet(extensionSessionId: sessionId),
create: (ctx) => ChatOpeningBloc(apiClient: ctx.read<ApiClient>())..add(LoadPricing()),
child: MultiBlocProvider(
providers: [
BlocProvider.value(value: context.read<SessionClosureBloc>()),
],
child: PricingBottomSheet(extensionSessionId: sessionId),
),
),
); );
} }
@@ -56,27 +39,20 @@ class PricingBottomSheet extends StatelessWidget {
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final isExtension = extensionSessionId != null; final isExtension = extensionSessionId != null;
final pricingAsync = ref.watch(chatPricingProvider);
return BlocBuilder<ChatOpeningBloc, ChatOpeningState>( return pricingAsync.when(
builder: (context, state) { loading: () => const SizedBox(
if (state is PricingLoading || state is PricingInitial) {
return const SizedBox(
height: 200, height: 200,
child: Center(child: CircularProgressIndicator()), child: Center(child: CircularProgressIndicator()),
); ),
} error: (error, _) => SizedBox(
if (state is PricingError) {
return SizedBox(
height: 200, height: 200,
child: Center(child: Text(state.message)), child: Center(child: Text('Gagal memuat harga. Coba lagi.')),
); ),
} data: (pricing) => DraggableScrollableSheet(
if (state is PricingLoaded) {
return DraggableScrollableSheet(
initialChildSize: 0.6, initialChildSize: 0.6,
minChildSize: 0.4, minChildSize: 0.4,
maxChildSize: 0.8, maxChildSize: 0.8,
@@ -93,23 +69,23 @@ class PricingBottomSheet extends StatelessWidget {
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
if (!isExtension && state.freeTrialEligible) ...[ if (!isExtension && pricing.freeTrialEligible) ...[
Card( Card(
color: Colors.green.shade50, color: Colors.green.shade50,
child: ListTile( child: ListTile(
leading: const Icon(Icons.card_giftcard, color: Colors.green), leading: const Icon(Icons.card_giftcard, color: Colors.green),
title: Text('Free Trial (${state.freeTrialDurationMinutes} Menit)'), title: Text('Free Trial (${pricing.freeTrialDurationMinutes} Menit)'),
subtitle: const Text('Gratis untuk pertama kali!'), subtitle: const Text('Gratis untuk pertama kali!'),
trailing: const Icon(Icons.arrow_forward_ios, size: 16), trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: () { onTap: () {
Navigator.of(context).pop(); Navigator.of(context).pop();
_startPairing(context, isFreeTrial: true); _startPairing(ref, isFreeTrial: true);
}, },
), ),
), ),
const Divider(height: 24), const Divider(height: 24),
], ],
...state.tiers.map((tier) => Card( ...pricing.tiers.map((tier) => Card(
child: ListTile( child: ListTile(
title: Text(tier.label), title: Text(tier.label),
trailing: Text( trailing: Text(
@@ -120,14 +96,14 @@ class PricingBottomSheet extends StatelessWidget {
Navigator.of(context).pop(); Navigator.of(context).pop();
if (isExtension) { if (isExtension) {
_requestExtension( _requestExtension(
context, ref,
sessionId: extensionSessionId!, sessionId: extensionSessionId!,
durationMinutes: tier.durationMinutes, durationMinutes: tier.durationMinutes,
price: tier.price, price: tier.price,
); );
} else { } else {
_startPairing( _startPairing(
context, ref,
durationMinutes: tier.durationMinutes, durationMinutes: tier.durationMinutes,
price: tier.price, price: tier.price,
); );
@@ -139,27 +115,23 @@ class PricingBottomSheet extends StatelessWidget {
), ),
); );
}, },
),
); );
} }
return const SizedBox.shrink(); void _startPairing(WidgetRef ref, {bool isFreeTrial = false, int? durationMinutes, int? price}) {
}, ref.read(pairingProvider.notifier).requestPairingWithTier(
);
}
void _startPairing(BuildContext context, {bool isFreeTrial = false, int? durationMinutes, int? price}) {
context.read<PairingBloc>().add(RequestPairingWithTier(
durationMinutes: durationMinutes, durationMinutes: durationMinutes,
price: price, price: price,
isFreeTrial: isFreeTrial, isFreeTrial: isFreeTrial,
)); );
} }
void _requestExtension(BuildContext context, {required String sessionId, required int durationMinutes, required int price}) { void _requestExtension(WidgetRef ref, {required String sessionId, required int durationMinutes, required int price}) {
context.read<SessionClosureBloc>().add(RequestExtension( ref.read(sessionClosureProvider.notifier).requestExtension(
sessionId: sessionId, sessionId,
durationMinutes: durationMinutes, durationMinutes: durationMinutes,
price: price, price: price,
)); );
} }
} }

View File

@@ -1,19 +1,20 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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_bloc.dart'; import '../../core/auth/auth_notifier.dart';
import '../../core/api/api_client.dart'; import '../../core/api/api_client_provider.dart';
import '../../core/pairing/pairing_bloc.dart'; import '../../core/chat/unread_notifier.dart';
import '../../core/pairing/pairing_notifier.dart';
import '../chat/widgets/pricing_bottom_sheet.dart'; import '../chat/widgets/pricing_bottom_sheet.dart';
class HomeScreen extends StatefulWidget { class HomeScreen extends ConsumerStatefulWidget {
const HomeScreen({super.key}); const HomeScreen({super.key});
@override @override
State<HomeScreen> createState() => _HomeScreenState(); ConsumerState<HomeScreen> createState() => _HomeScreenState();
} }
class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver { class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObserver {
Map<String, dynamic>? _activeSession; Map<String, dynamic>? _activeSession;
bool _loadingSession = true; bool _loadingSession = true;
@@ -40,13 +41,12 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
// Re-check when navigating back to this screen
_checkActiveSession(); _checkActiveSession();
} }
Future<void> _checkActiveSession() async { Future<void> _checkActiveSession() async {
try { try {
final apiClient = context.read<ApiClient>(); final apiClient = ref.read(apiClientProvider);
final response = await apiClient.get('/api/client/chat/session/active'); final response = await apiClient.get('/api/client/chat/session/active');
final data = response['data']; final data = response['data'];
if (mounted) { if (mounted) {
@@ -62,25 +62,26 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocListener<PairingBloc, PairingState>( final authState = ref.watch(authProvider);
listener: (context, state) { final authData = authState.valueOrNull;
if (state is PairingSearching) {
final displayName = switch (authData) {
AuthAuthenticatedData d => d.profile['display_name'] as String? ?? '',
AuthAnonymousData d => d.displayName,
_ => '',
};
ref.listen(pairingProvider, (prev, next) {
if (next is PairingSearchingData) {
context.go('/chat/searching'); context.go('/chat/searching');
} else if (state is PairingNoBestie) { } else if (next is PairingNoBestieData) {
context.go('/chat/no-bestie'); context.go('/chat/no-bestie');
} else if (state is PairingError) { } else if (next is PairingErrorData) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message)), SnackBar(content: Text(next.message)),
); );
} }
}, });
child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
final displayName = state is AuthAuthenticated
? state.profile['display_name'] as String
: state is AuthAnonymous
? state.displayName
: '';
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -92,7 +93,7 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
), ),
IconButton( IconButton(
icon: const Icon(Icons.logout), icon: const Icon(Icons.logout),
onPressed: () => context.read<AuthBloc>().add(LogoutRequested()), onPressed: () => ref.read(authProvider.notifier).logout(),
), ),
], ],
), ),
@@ -130,21 +131,19 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
), ),
), ),
); );
},
),
);
} }
} }
class _ActiveSessionCard extends StatelessWidget { class _ActiveSessionCard extends ConsumerWidget {
final Map<String, dynamic> session; final Map<String, dynamic> session;
final VoidCallback onTap; final VoidCallback onTap;
const _ActiveSessionCard({required this.session, required this.onTap}); const _ActiveSessionCard({required this.session, required this.onTap});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final mitraName = session['mitra_display_name'] as String? ?? 'Bestie'; final mitraName = session['mitra_display_name'] as String? ?? 'Bestie';
final unreadCount = ref.watch(unreadCountProvider);
return Card( return Card(
elevation: 2, elevation: 2,
@@ -155,10 +154,14 @@ class _ActiveSessionCard extends StatelessWidget {
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Row( child: Row(
children: [ children: [
const CircleAvatar( Badge(
isLabelVisible: unreadCount > 0,
label: Text('$unreadCount'),
child: const CircleAvatar(
backgroundColor: Colors.green, backgroundColor: Colors.green,
child: Icon(Icons.chat, color: Colors.white), child: Icon(Icons.chat, color: Colors.white),
), ),
),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: Column( child: Column(

View File

@@ -1,13 +1,9 @@
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'core/api/api_client_provider.dart';
import 'core/api/api_client.dart'; import 'core/auth/auth_notifier.dart';
import 'core/auth/auth_bloc.dart';
import 'core/chat/chat_bloc.dart';
import 'core/chat/session_closure_bloc.dart';
import 'core/pairing/pairing_bloc.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';
@@ -16,69 +12,53 @@ void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
// Request notification permission
final messaging = FirebaseMessaging.instance; final messaging = FirebaseMessaging.instance;
await messaging.requestPermission(); await messaging.requestPermission();
runApp(const App()); runApp(const ProviderScope(child: App()));
} }
class App extends StatefulWidget { class App extends ConsumerStatefulWidget {
const App({super.key}); const App({super.key});
@override @override
State<App> createState() => _AppState(); ConsumerState<App> createState() => _AppState();
} }
class _AppState extends State<App> { class _AppState extends ConsumerState<App> {
final _apiClient = ApiClient(); bool _fcmRegistered = false;
late final AuthBloc _authBloc;
late final GoRouter _router;
@override void _registerFcmToken() {
void initState() { if (_fcmRegistered) return;
super.initState(); _fcmRegistered = true;
_authBloc = AuthBloc(apiClient: _apiClient)..add(AppStarted()); Future(() async {
_router = buildRouter(_authBloc);
NotificationService.initialize(_router);
_registerFcmToken();
}
Future<void> _registerFcmToken() async {
// Listen for auth state, then register token
_authBloc.stream.listen((state) async {
if (state is AuthAuthenticated || state is AuthAnonymous) {
try { try {
final token = await FirebaseMessaging.instance.getToken(); final token = await FirebaseMessaging.instance.getToken();
if (token != null) { if (token != null) {
await _apiClient.post('/api/shared/device-token', data: {'token': token}); await ref.read(apiClientProvider).post('/api/shared/device-token', data: {'token': token});
} }
} catch (_) {} } catch (_) {
_fcmRegistered = false;
} }
}); });
} }
@override
void dispose() {
_authBloc.close();
_router.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MultiBlocProvider( ref.listen(authProvider, (prev, next) {
providers: [ final data = next.valueOrNull;
BlocProvider.value(value: _authBloc), if (data is AuthAuthenticatedData || data is AuthAnonymousData) {
BlocProvider(create: (_) => PairingBloc(apiClient: _apiClient)), _registerFcmToken();
BlocProvider(create: (_) => ChatBloc(apiClient: _apiClient)), }
BlocProvider(create: (_) => SessionClosureBloc(apiClient: _apiClient)), });
RepositoryProvider.value(value: _apiClient),
], final router = ref.watch(routerProvider);
child: MaterialApp.router(
NotificationService.initialize(router);
return MaterialApp.router(
title: 'Halo Bestie', title: 'Halo Bestie',
routerConfig: _router, routerConfig: router,
),
); );
} }
} }

View File

@@ -1,12 +1,13 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.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_bloc.dart'; import 'core/auth/auth_notifier.dart';
import 'features/auth/screens/welcome_screen.dart'; import 'features/auth/screens/welcome_screen.dart';
import 'features/auth/screens/display_name_screen.dart'; import 'features/auth/screens/display_name_screen.dart';
import 'features/auth/screens/register_screen.dart'; import 'features/auth/screens/register_screen.dart';
import 'features/auth/screens/otp_screen.dart'; import 'features/auth/screens/otp_screen.dart';
import 'features/auth/screens/force_register_screen.dart'; import 'features/auth/screens/force_register_screen.dart';
import 'features/auth/screens/set_display_name_screen.dart';
import 'features/splash/splash_screen.dart'; import 'features/splash/splash_screen.dart';
import 'features/home/home_screen.dart'; import 'features/home/home_screen.dart';
import 'features/chat/screens/searching_screen.dart'; import 'features/chat/screens/searching_screen.dart';
@@ -16,38 +17,47 @@ import 'features/chat/screens/chat_screen.dart';
import 'features/chat/screens/chat_history_screen.dart'; import 'features/chat/screens/chat_history_screen.dart';
import 'features/chat/screens/chat_transcript_screen.dart'; import 'features/chat/screens/chat_transcript_screen.dart';
/// Converts a BLoC stream into a ChangeNotifier for GoRouter's refreshListenable. class RouterNotifier extends ChangeNotifier {
class _BlocRefreshNotifier extends ChangeNotifier { final Ref _ref;
late final StreamSubscription _subscription;
_BlocRefreshNotifier(AuthBloc bloc) { RouterNotifier(this._ref) {
_subscription = bloc.stream.listen((_) => notifyListeners()); _ref.listen(authProvider, (_, __) => notifyListeners());
}
@override
void dispose() {
_subscription.cancel();
super.dispose();
} }
} }
GoRouter buildRouter(AuthBloc authBloc) { final routerProvider = Provider<GoRouter>((ref) => buildRouter(ref));
GoRouter buildRouter(Ref ref) {
final notifier = RouterNotifier(ref);
return GoRouter( return GoRouter(
initialLocation: '/splash', initialLocation: '/splash',
refreshListenable: _BlocRefreshNotifier(authBloc), refreshListenable: notifier,
redirect: (context, state) { redirect: (context, state) {
final authState = authBloc.state; final authState = ref.read(authProvider);
final isSplash = state.matchedLocation == '/splash'; final isSplash = state.matchedLocation == '/splash';
final isAuthRoute = state.matchedLocation.startsWith('/auth') || final isAuthRoute = state.matchedLocation.startsWith('/auth') ||
state.matchedLocation == '/welcome'; state.matchedLocation == '/welcome';
// Show splash while loading // Show splash only during initial load — don't redirect away from auth routes
if (authState is AuthLoading) return isSplash ? null : '/splash'; if (authState is AsyncLoading) {
if (isSplash || isAuthRoute) return null;
return '/splash';
}
if (authState is AuthAuthenticated || authState is AuthAnonymous) { final data = authState.valueOrNull;
if (data == null) {
// Error state — show login
if (!isAuthRoute && !isSplash) return '/welcome';
if (isSplash) return '/welcome';
return null;
}
if (data is AuthAuthenticatedData || data is AuthAnonymousData) {
return (isSplash || isAuthRoute) ? '/home' : null; return (isSplash || isAuthRoute) ? '/home' : null;
} }
if (authState is AuthForceRegister) return '/auth/force-register'; if (data is AuthNeedsDisplayNameData) return '/auth/set-name';
if (data is AuthForceRegisterData) return '/auth/force-register';
if (!isAuthRoute && !isSplash) return '/welcome'; if (!isAuthRoute && !isSplash) return '/welcome';
if (isSplash) return '/welcome'; if (isSplash) return '/welcome';
return null; return null;
@@ -58,6 +68,7 @@ GoRouter buildRouter(AuthBloc authBloc) {
GoRoute(path: '/auth/display-name', builder: (_, __) => const DisplayNameScreen()), GoRoute(path: '/auth/display-name', builder: (_, __) => const DisplayNameScreen()),
GoRoute(path: '/auth/register', builder: (_, __) => const RegisterScreen()), GoRoute(path: '/auth/register', builder: (_, __) => const RegisterScreen()),
GoRoute(path: '/auth/otp', builder: (context, state) => OtpScreen(phone: state.extra as String)), GoRoute(path: '/auth/otp', builder: (context, state) => OtpScreen(phone: state.extra as String)),
GoRoute(path: '/auth/set-name', builder: (_, __) => const SetDisplayNameScreen()),
GoRoute(path: '/auth/force-register', builder: (_, __) => const ForceRegisterScreen()), GoRoute(path: '/auth/force-register', builder: (_, __) => const ForceRegisterScreen()),
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()), GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
GoRoute(path: '/chat/searching', builder: (_, __) => const SearchingScreen()), GoRoute(path: '/chat/searching', builder: (_, __) => const SearchingScreen()),

View File

@@ -1,6 +1,14 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
url: "https://pub.dev"
source: hosted
version: "85.0.0"
_flutterfire_internals: _flutterfire_internals:
dependency: transitive dependency: transitive
description: description:
@@ -9,6 +17,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.35" version: "1.3.35"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: f4ad0fea5f102201015c9aae9d93bc02f75dd9491529a8c21f88d17a8523d44c
url: "https://pub.dev"
source: hosted
version: "7.6.0"
analyzer_plugin:
dependency: transitive
description:
name: analyzer_plugin
sha256: a5ab7590c27b779f3d4de67f31c4109dbe13dd7339f86461a6f2a8ab2594d8ce
url: "https://pub.dev"
source: hosted
version: "0.13.4"
args: args:
dependency: transitive dependency: transitive
description: description:
@@ -25,14 +49,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.13.1" version: "2.13.1"
bloc:
dependency: transitive
description:
name: bloc
sha256: "106842ad6569f0b60297619e9e0b1885c2fb9bf84812935490e6c5275777804e"
url: "https://pub.dev"
source: hosted
version: "8.1.4"
boolean_selector: boolean_selector:
dependency: transitive dependency: transitive
description: description:
@@ -41,6 +57,70 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.2"
build:
dependency: transitive
description:
name: build
sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0
url: "https://pub.dev"
source: hosted
version: "2.4.2"
build_config:
dependency: transitive
description:
name: build_config
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
build_daemon:
dependency: transitive
description:
name: build_daemon
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
url: "https://pub.dev"
source: hosted
version: "4.1.1"
build_resolvers:
dependency: transitive
description:
name: build_resolvers
sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0
url: "https://pub.dev"
source: hosted
version: "2.4.4"
build_runner:
dependency: "direct dev"
description:
name: build_runner
sha256: "74691599a5bc750dc96a6b4bfd48f7d9d66453eab04c7f4063134800d6a5c573"
url: "https://pub.dev"
source: hosted
version: "2.4.14"
build_runner_core:
dependency: transitive
description:
name: build_runner_core
sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021"
url: "https://pub.dev"
source: hosted
version: "8.0.0"
built_collection:
dependency: transitive
description:
name: built_collection
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
url: "https://pub.dev"
source: hosted
version: "5.1.1"
built_value:
dependency: transitive
description:
name: built_value
sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af"
url: "https://pub.dev"
source: hosted
version: "8.12.5"
characters: characters:
dependency: transitive dependency: transitive
description: description:
@@ -49,6 +129,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
ci:
dependency: transitive
description:
name: ci
sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13"
url: "https://pub.dev"
source: hosted
version: "0.1.0"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock: clock:
dependency: transitive dependency: transitive
description: description:
@@ -57,6 +161,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" version: "1.1.2"
code_builder:
dependency: transitive
description:
name: code_builder
sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d"
url: "https://pub.dev"
source: hosted
version: "4.11.1"
collection: collection:
dependency: transitive dependency: transitive
description: description:
@@ -65,6 +177,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
crypto: crypto:
dependency: transitive dependency: transitive
description: description:
@@ -73,6 +193,46 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.7" version: "3.0.7"
custom_lint:
dependency: "direct dev"
description:
name: custom_lint
sha256: "9656925637516c5cf0f5da018b33df94025af2088fe09c8ae2ca54c53f2d9a84"
url: "https://pub.dev"
source: hosted
version: "0.7.6"
custom_lint_builder:
dependency: transitive
description:
name: custom_lint_builder
sha256: "6cdc8e87e51baaaba9c43e283ed8d28e59a0c4732279df62f66f7b5984655414"
url: "https://pub.dev"
source: hosted
version: "0.7.6"
custom_lint_core:
dependency: transitive
description:
name: custom_lint_core
sha256: "31110af3dde9d29fb10828ca33f1dce24d2798477b167675543ce3d208dee8be"
url: "https://pub.dev"
source: hosted
version: "0.7.5"
custom_lint_visitor:
dependency: transitive
description:
name: custom_lint_visitor
sha256: "4a86a0d8415a91fbb8298d6ef03e9034dc8e323a599ddc4120a0e36c433983a2"
url: "https://pub.dev"
source: hosted
version: "1.0.0+7.7.0"
dart_style:
dependency: transitive
description:
name: dart_style
sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
dbus: dbus:
dependency: transitive dependency: transitive
description: description:
@@ -97,14 +257,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.2"
equatable:
dependency: "direct main"
description:
name: equatable
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
url: "https://pub.dev"
source: hosted
version: "2.0.8"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@@ -201,19 +353,27 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.8.7" version: "3.8.7"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_bloc: flutter_hooks:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_bloc name: flutter_hooks
sha256: b594505eac31a0518bdcb4b5b79573b8d9117b193cc80cc12e17d639b10aa27a sha256: cde36b12f7188c85286fba9b38cc5a902e7279f36dd676967106c041dc9dde70
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.1.6" version: "0.20.5"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -254,6 +414,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.0" version: "3.0.0"
flutter_riverpod:
dependency: "direct main"
description:
name: flutter_riverpod
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -264,6 +432,30 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
freezed_annotation:
dependency: transitive
description:
name: freezed_annotation
sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
frontend_server_client:
dependency: transitive
description:
name: frontend_server_client
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
url: "https://pub.dev"
source: hosted
version: "4.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
go_router: go_router:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -320,6 +512,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.4+4" version: "0.12.4+4"
graphs:
dependency: transitive
description:
name: graphs
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
hooks_riverpod:
dependency: "direct main"
description:
name: hooks_riverpod
sha256: "70bba33cfc5670c84b796e6929c54b8bc5be7d0fe15bb28c2560500b9ad06966"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
hotreloader:
dependency: transitive
description:
name: hotreloader
sha256: "66871df468fc24eee81f1a0a7cb98acc104716f9b7376d355437b48d633c4ebf"
url: "https://pub.dev"
source: hosted
version: "4.4.0"
http: http:
dependency: transitive dependency: transitive
description: description:
@@ -328,6 +544,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.6.0" version: "1.6.0"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
url: "https://pub.dev"
source: hosted
version: "3.2.2"
http_parser: http_parser:
dependency: transitive dependency: transitive
description: description:
@@ -336,6 +560,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
io:
dependency: transitive
description:
name: io
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
url: "https://pub.dev"
source: hosted
version: "1.0.5"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
url: "https://pub.dev"
source: hosted
version: "4.11.0"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@@ -408,14 +656,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.0" version: "2.0.0"
nested: package_config:
dependency: transitive dependency: transitive
description: description:
name: nested name: package_config
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" version: "2.2.0"
path: path:
dependency: transitive dependency: transitive
description: description:
@@ -472,14 +720,78 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
provider: pool:
dependency: transitive dependency: transitive
description: description:
name: provider name: pool
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.5+1" version: "1.5.2"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
pubspec_parse:
dependency: transitive
description:
name: pubspec_parse
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
riverpod:
dependency: transitive
description:
name: riverpod
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
riverpod_analyzer_utils:
dependency: transitive
description:
name: riverpod_analyzer_utils
sha256: "03a17170088c63aab6c54c44456f5ab78876a1ddb6032ffde1662ddab4959611"
url: "https://pub.dev"
source: hosted
version: "0.5.10"
riverpod_annotation:
dependency: "direct main"
description:
name: riverpod_annotation
sha256: e14b0bf45b71326654e2705d462f21b958f987087be850afd60578fcd502d1b8
url: "https://pub.dev"
source: hosted
version: "2.6.1"
riverpod_generator:
dependency: "direct dev"
description:
name: riverpod_generator
sha256: "44a0992d54473eb199ede00e2260bd3c262a86560e3c6f6374503d86d0580e36"
url: "https://pub.dev"
source: hosted
version: "2.6.5"
riverpod_lint:
dependency: "direct dev"
description:
name: riverpod_lint
sha256: "89a52b7334210dbff8605c3edf26cfe69b15062beed5cbfeff2c3812c33c9e35"
url: "https://pub.dev"
source: hosted
version: "2.6.5"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
shared_preferences: shared_preferences:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -536,6 +848,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.1" version: "2.4.1"
shelf:
dependency: transitive
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.dev"
source: hosted
version: "1.4.2"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67
url: "https://pub.dev"
source: hosted
version: "2.0.1"
sign_in_with_apple: sign_in_with_apple:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -565,6 +893,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
source_gen:
dependency: transitive
description:
name: source_gen
sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
source_span: source_span:
dependency: transitive dependency: transitive
description: description:
@@ -581,6 +917,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.12.1" version: "1.12.1"
state_notifier:
dependency: transitive
description:
name: state_notifier
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
url: "https://pub.dev"
source: hosted
version: "1.0.0"
stream_channel: stream_channel:
dependency: transitive dependency: transitive
description: description:
@@ -589,6 +933,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.4" version: "2.1.4"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
url: "https://pub.dev"
source: hosted
version: "2.1.1"
string_scanner: string_scanner:
dependency: transitive dependency: transitive
description: description:
@@ -621,6 +973,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.11.0" version: "0.11.0"
timing:
dependency: transitive
description:
name: timing
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -629,6 +989,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
uuid:
dependency: transitive
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@@ -645,6 +1013,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.0.2" version: "15.0.2"
watcher:
dependency: transitive
description:
name: watcher
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
web: web:
dependency: transitive dependency: transitive
description: description:
@@ -677,6 +1053,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.6.1" version: "6.6.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks: sdks:
dart: ">=3.10.0 <4.0.0" dart: ">=3.10.0 <4.0.0"
flutter: ">=3.38.1" flutter: ">=3.38.1"

View File

@@ -25,8 +25,10 @@ dependencies:
web_socket_channel: ^2.4.5 web_socket_channel: ^2.4.5
# State management # State management
flutter_bloc: ^8.1.5 flutter_riverpod: ^2.6.1
equatable: ^2.0.5 hooks_riverpod: ^2.6.1
riverpod_annotation: ^2.6.1
flutter_hooks: ^0.20.5
# Storage # Storage
shared_preferences: ^2.2.3 shared_preferences: ^2.2.3
@@ -39,6 +41,10 @@ dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_lints: ^3.0.0 flutter_lints: ^3.0.0
riverpod_generator: ^2.6.2
build_runner: ^2.4.13
custom_lint: ^0.7.0
riverpod_lint: ^2.6.2
flutter: flutter:
uses-material-design: true uses-material-design: true

View File

@@ -6,6 +6,7 @@ import MitrasPage from './pages/mitras/MitrasPage'
import SessionsPage from './pages/sessions/SessionsPage' import SessionsPage from './pages/sessions/SessionsPage'
import UsersPage from './pages/users/UsersPage' import UsersPage from './pages/users/UsersPage'
import SettingsPage from './pages/settings/SettingsPage' import SettingsPage from './pages/settings/SettingsPage'
import MitraActivityPage from './pages/mitra-activity/MitraActivityPage'
import Layout from './components/Layout' import Layout from './components/Layout'
const ProtectedRoute = ({ children }) => { const ProtectedRoute = ({ children }) => {
@@ -25,6 +26,7 @@ export default function App() {
<Route path="sessions" element={<SessionsPage />} /> <Route path="sessions" element={<SessionsPage />} />
<Route path="users" element={<UsersPage />} /> <Route path="users" element={<UsersPage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
<Route path="mitra-activity" element={<MitraActivityPage />} />
</Route> </Route>
</Routes> </Routes>
) )

View File

@@ -13,6 +13,7 @@ export default function Layout() {
<li><NavLink to="/mitras">Mitra</NavLink></li> <li><NavLink to="/mitras">Mitra</NavLink></li>
<li><NavLink to="/sessions">Sesi</NavLink></li> <li><NavLink to="/sessions">Sesi</NavLink></li>
<li><NavLink to="/users">Users</NavLink></li> <li><NavLink to="/users">Users</NavLink></li>
<li><NavLink to="/mitra-activity">Aktivitas Mitra</NavLink></li>
<li><NavLink to="/settings">Settings</NavLink></li> <li><NavLink to="/settings">Settings</NavLink></li>
</ul> </ul>
<div style={{ marginTop: 'auto', paddingTop: 16 }}> <div style={{ marginTop: 'auto', paddingTop: 16 }}>

View File

@@ -0,0 +1,177 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { apiClient } from '../../core/api/api-client'
const fetchSummary = async ({ mitra_id, date_from, date_to }) => {
const params = new URLSearchParams()
if (mitra_id) params.set('mitra_id', mitra_id)
if (date_from) params.set('date_from', date_from)
if (date_to) params.set('date_to', date_to)
const res = await apiClient.get(`/internal/mitra-activity/summary?${params}`)
return res.data.data
}
const fetchLog = async ({ mitra_id, date_from, date_to, page, limit }) => {
const params = new URLSearchParams()
if (mitra_id) params.set('mitra_id', mitra_id)
if (date_from) params.set('date_from', date_from)
if (date_to) params.set('date_to', date_to)
params.set('page', String(page))
params.set('limit', String(limit))
const res = await apiClient.get(`/internal/mitra-activity/log?${params}`)
return res.data.data
}
const fetchMitras = async () => {
const res = await apiClient.get('/internal/mitras')
return res.data.data
}
const responseColor = (response) => {
switch (response) {
case 'accepted': return '#22c55e'
case 'declined': return '#ef4444'
case 'missed': return '#f97316'
case 'ignored': return '#9ca3af'
default: return '#6b7280'
}
}
const formatDate = (dateStr) => {
if (!dateStr) return '-'
const d = new Date(dateStr)
return `${d.toLocaleDateString('id-ID')} ${d.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' })}`
}
export default function MitraActivityPage() {
const [mitraFilter, setMitraFilter] = useState('')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [logPage, setLogPage] = useState(1)
const logLimit = 20
const filters = { mitra_id: mitraFilter || undefined, date_from: dateFrom || undefined, date_to: dateTo || undefined }
const { data: mitras } = useQuery({ queryKey: ['mitras-list'], queryFn: fetchMitras })
const { data: summary, isLoading: summaryLoading } = useQuery({
queryKey: ['mitra-activity-summary', filters],
queryFn: () => fetchSummary(filters),
})
const { data: logData, isLoading: logLoading } = useQuery({
queryKey: ['mitra-activity-log', filters, logPage],
queryFn: () => fetchLog({ ...filters, page: logPage, limit: logLimit }),
})
return (
<div>
<h1>Aktivitas Mitra</h1>
<div style={{ display: 'flex', gap: 12, marginBottom: 24, flexWrap: 'wrap', alignItems: 'end' }}>
<div>
<label style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>Mitra</label>
<select value={mitraFilter} onChange={e => { setMitraFilter(e.target.value); setLogPage(1) }} style={{ padding: '6px 8px' }}>
<option value="">Semua Mitra</option>
{(mitras || []).map(m => (
<option key={m.id} value={m.id}>{m.display_name}</option>
))}
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>Dari</label>
<input type="date" value={dateFrom} onChange={e => { setDateFrom(e.target.value); setLogPage(1) }} />
</div>
<div>
<label style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>Sampai</label>
<input type="date" value={dateTo} onChange={e => { setDateTo(e.target.value); setLogPage(1) }} />
</div>
</div>
<section style={{ marginBottom: 32 }}>
<h2>Ringkasan</h2>
{summaryLoading ? <p>Loading...</p> : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid #ddd', textAlign: 'left' }}>
<th style={{ padding: 8 }}>Mitra</th>
<th style={{ padding: 8 }}>Total</th>
<th style={{ padding: 8 }}>Accepted</th>
<th style={{ padding: 8 }}>Rejected</th>
<th style={{ padding: 8 }}>Missed</th>
<th style={{ padding: 8 }}>Ignored</th>
<th style={{ padding: 8 }}>Rate (%)</th>
<th style={{ padding: 8 }}>Avg Response (s)</th>
</tr>
</thead>
<tbody>
{(summary || []).map(s => (
<tr key={s.mitra_id} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: 8 }}>{s.mitra_display_name}</td>
<td style={{ padding: 8 }}>{s.total_requests}</td>
<td style={{ padding: 8, color: '#22c55e' }}>{s.accepted_count}</td>
<td style={{ padding: 8, color: '#ef4444' }}>{s.rejected_count}</td>
<td style={{ padding: 8, color: '#f97316' }}>{s.missed_count}</td>
<td style={{ padding: 8, color: '#9ca3af' }}>{s.ignored_count}</td>
<td style={{ padding: 8 }}>{s.acceptance_rate ?? '-'}%</td>
<td style={{ padding: 8 }}>{s.avg_response_time_seconds ?? '-'}</td>
</tr>
))}
{(!summary || summary.length === 0) && (
<tr><td colSpan={8} style={{ padding: 16, textAlign: 'center', color: '#999' }}>Tidak ada data</td></tr>
)}
</tbody>
</table>
)}
</section>
<section>
<h2>Detail Log</h2>
{logLoading ? <p>Loading...</p> : (
<>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid #ddd', textAlign: 'left' }}>
<th style={{ padding: 8 }}>Mitra</th>
<th style={{ padding: 8 }}>Session</th>
<th style={{ padding: 8 }}>Response</th>
<th style={{ padding: 8 }}>Response Time (s)</th>
<th style={{ padding: 8 }}>Active Sessions</th>
<th style={{ padding: 8 }}>Notified At</th>
<th style={{ padding: 8 }}>Responded At</th>
</tr>
</thead>
<tbody>
{(logData?.items || []).map(item => (
<tr key={item.id} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: 8 }}>{item.mitra_display_name}</td>
<td style={{ padding: 8, fontSize: 11, fontFamily: 'monospace' }}>{item.session_id?.substring(0, 8)}...</td>
<td style={{ padding: 8 }}>
<span style={{ color: responseColor(item.response), fontWeight: 'bold' }}>
{item.response || '-'}
</span>
</td>
<td style={{ padding: 8 }}>{item.response_time_seconds ?? '-'}</td>
<td style={{ padding: 8 }}>{item.active_session_count}</td>
<td style={{ padding: 8, fontSize: 12 }}>{formatDate(item.notified_at)}</td>
<td style={{ padding: 8, fontSize: 12 }}>{formatDate(item.responded_at)}</td>
</tr>
))}
{(!logData?.items || logData.items.length === 0) && (
<tr><td colSpan={7} style={{ padding: 16, textAlign: 'center', color: '#999' }}>Tidak ada data</td></tr>
)}
</tbody>
</table>
{logData && logData.total > logLimit && (
<div style={{ display: 'flex', gap: 8, marginTop: 12, alignItems: 'center' }}>
<button disabled={logPage <= 1} onClick={() => setLogPage(p => p - 1)}>Prev</button>
<span>Halaman {logData.page} dari {Math.ceil(logData.total / logLimit)}</span>
<button disabled={logPage >= Math.ceil(logData.total / logLimit)} onClick={() => setLogPage(p => p + 1)}>Next</button>
</div>
)}
</>
)}
</section>
</div>
)
}

View File

@@ -42,6 +42,17 @@ const updateExtensionTimeoutConfig = async (extension_timeout_seconds) => {
return res.data.data return res.data.data
} }
// Phase 3.1: Mitra Ping Config
const fetchMitraPingConfig = async () => {
const res = await apiClient.get('/internal/config/mitra-ping')
return res.data.data
}
const updateMitraPingConfig = async (data) => {
const res = await apiClient.patch('/internal/config/mitra-ping', data)
return res.data.data
}
const fetchEarlyEndConfig = async () => { const fetchEarlyEndConfig = async () => {
const res = await apiClient.get('/internal/config/early-end') const res = await apiClient.get('/internal/config/early-end')
return res.data.data return res.data.data
@@ -101,7 +112,17 @@ export default function SettingsPage() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-early-end'] }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-early-end'] }),
}) })
if (isLoading || maxLoading || ftLoading || etLoading || eeLoading) return <div>Loading...</div> // Phase 3.1: Mitra Ping
const { data: mpData, isLoading: mpLoading } = useQuery({
queryKey: ['config-mitra-ping'],
queryFn: fetchMitraPingConfig,
})
const mpMutation = useMutation({
mutationFn: updateMitraPingConfig,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-mitra-ping'] }),
})
if (isLoading || maxLoading || ftLoading || etLoading || eeLoading || mpLoading) return <div>Loading...</div>
return ( return (
<div> <div>
@@ -215,6 +236,39 @@ export default function SettingsPage() {
</label> </label>
{eeMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>} {eeMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section> </section>
<section style={{ marginBottom: 24 }}>
<h2>Mitra Online Status (Ping)</h2>
<p>Konfigurasi apakah mitra harus mengirim ping (heartbeat) untuk tetap online.</p>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<input
type="checkbox"
checked={mpData?.require_ping ?? true}
onChange={e => mpMutation.mutate({ require_ping: e.target.checked })}
disabled={mpMutation.isPending}
/>
Wajibkan Mitra Ping (Heartbeat)
</label>
<p style={{ fontSize: 12, color: '#666', marginBottom: 8 }}>
Jika dinonaktifkan, mitra akan tetap online tanpa perlu mengirim ping. QC bertanggung jawab atas kualitas layanan mitra.
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<label>Interval Ping:</label>
<input
type="number"
min="5"
value={mpData?.ping_interval_seconds ?? 15}
onChange={e => {
const val = parseInt(e.target.value, 10)
if (val >= 5) mpMutation.mutate({ ping_interval_seconds: val })
}}
disabled={mpMutation.isPending}
style={{ width: 80 }}
/>
<span>detik</span>
</div>
{mpMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
</div> </div>
) )
} }

View File

@@ -2,7 +2,8 @@
<application <application
android:label="mitra_app" android:label="mitra_app"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

View File

@@ -0,0 +1,8 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'api_client.dart';
part 'api_client_provider.g.dart';
@Riverpod(keepAlive: true)
ApiClient apiClient(Ref ref) => ApiClient();

View File

@@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'api_client_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$apiClientHash() => r'90c807f03b90249684265cc91739139c2c89eeb9';
/// See also [apiClient].
@ProviderFor(apiClient)
final apiClientProvider = Provider<ApiClient>.internal(
apiClient,
name: r'apiClientProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$apiClientHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef ApiClientRef = ProviderRef<ApiClient>;
// 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

@@ -1,145 +0,0 @@
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter_bloc/flutter_bloc.dart';
import '../api/api_client.dart';
// Events
abstract class AuthEvent extends Equatable {
@override
List<Object?> get props => [];
}
class AppStarted extends AuthEvent {}
class PhoneOtpRequested extends AuthEvent {
final String phone;
PhoneOtpRequested(this.phone);
@override List<Object?> get props => [phone];
}
class OtpVerified extends AuthEvent {
final String verificationId;
final String smsCode;
OtpVerified(this.verificationId, this.smsCode);
@override List<Object?> get props => [verificationId, smsCode];
}
class LogoutRequested extends AuthEvent {}
// States
abstract class AuthState extends Equatable {
@override
List<Object?> get props => [];
}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthAuthenticated extends AuthState {
final Map<String, dynamic> profile;
AuthAuthenticated(this.profile);
@override List<Object?> get props => [profile];
}
class AuthOtpSent extends AuthState {
final String verificationId;
AuthOtpSent(this.verificationId);
@override List<Object?> get props => [verificationId];
}
class AuthError extends AuthState {
final String message;
AuthError(this.message);
@override List<Object?> get props => [message];
}
// Bloc
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final ApiClient apiClient;
final _auth = FirebaseAuth.instance;
ConfirmationResult? _webConfirmationResult;
AuthBloc({required this.apiClient}) : super(AuthLoading()) {
on<AppStarted>(_onAppStarted);
on<PhoneOtpRequested>(_onPhoneOtpRequested);
on<OtpVerified>(_onOtpVerified);
on<LogoutRequested>(_onLogout);
}
Future<void> _onAppStarted(AppStarted event, Emitter<AuthState> emit) async {
if (_auth.currentUser != null) {
await _verifyAndEmit(emit);
}
}
Future<void> _onPhoneOtpRequested(PhoneOtpRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
if (kIsWeb) {
try {
final confirmationResult = await _auth.signInWithPhoneNumber(event.phone);
_webConfirmationResult = confirmationResult;
emit(AuthOtpSent('web'));
} catch (e) {
emit(AuthError('Gagal mengirim OTP. Coba lagi.'));
}
} else {
final completer = Completer<void>();
await _auth.verifyPhoneNumber(
phoneNumber: event.phone,
verificationCompleted: (_) {
if (!completer.isCompleted) completer.complete();
},
verificationFailed: (e) {
emit(AuthError('Gagal mengirim OTP. Coba lagi.'));
if (!completer.isCompleted) completer.complete();
},
codeSent: (verificationId, _) {
emit(AuthOtpSent(verificationId));
if (!completer.isCompleted) completer.complete();
},
codeAutoRetrievalTimeout: (_) {
if (!completer.isCompleted) completer.complete();
},
);
await completer.future;
}
}
Future<void> _onOtpVerified(OtpVerified event, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
if (kIsWeb && _webConfirmationResult != null) {
await _webConfirmationResult!.confirm(event.smsCode);
} else {
final credential = PhoneAuthProvider.credential(
verificationId: event.verificationId,
smsCode: event.smsCode,
);
await _auth.signInWithCredential(credential);
}
await _verifyAndEmit(emit);
} catch (e) {
emit(AuthError('OTP tidak valid. Coba lagi.'));
}
}
Future<void> _onLogout(LogoutRequested event, Emitter<AuthState> emit) async {
await _auth.signOut();
emit(AuthInitial());
}
Future<void> _verifyAndEmit(Emitter<AuthState> emit) async {
try {
final response = await apiClient.post('/api/mitra/auth/verify');
emit(AuthAuthenticated(response['data'] as Map<String, dynamic>));
} on Exception catch (e) {
await _auth.signOut();
// Surface specific errors from backend
final msg = e.toString();
if (msg.contains('ACCOUNT_NOT_FOUND')) {
emit(AuthError('Akun tidak ditemukan. Hubungi administrator.'));
} else if (msg.contains('ACCOUNT_INACTIVE')) {
emit(AuthError('Akun tidak aktif. Hubungi administrator.'));
} else {
emit(AuthError('Gagal masuk. Coba lagi.'));
}
}
}
}

View File

@@ -0,0 +1,119 @@
import 'dart:async';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../api/api_client.dart';
import '../api/api_client_provider.dart';
part 'auth_notifier.g.dart';
// States
sealed class MitraAuthData {
const MitraAuthData();
}
class MitraAuthInitialData extends MitraAuthData {
const MitraAuthInitialData();
}
class MitraAuthAuthenticatedData extends MitraAuthData {
final Map<String, dynamic> profile;
const MitraAuthAuthenticatedData(this.profile);
}
class MitraAuthOtpSentData extends MitraAuthData {
final String verificationId;
const MitraAuthOtpSentData(this.verificationId);
}
@Riverpod(keepAlive: true)
class MitraAuth extends _$MitraAuth {
FirebaseAuth get _auth => FirebaseAuth.instance;
ApiClient get _apiClient => ref.read(apiClientProvider);
ConfirmationResult? _webConfirmationResult;
@override
FutureOr<MitraAuthData> build() async {
if (_auth.currentUser != null) {
return await _verifyAndReturn();
}
return const MitraAuthInitialData(); // FIX: was missing in BLoC version
}
Future<void> requestOtp(String phone) async {
state = const AsyncLoading();
if (kIsWeb) {
try {
final confirmationResult = await _auth.signInWithPhoneNumber(phone);
_webConfirmationResult = confirmationResult;
state = const AsyncData(MitraAuthOtpSentData('web'));
} catch (e) {
state = AsyncError('Gagal mengirim OTP. Coba lagi.', StackTrace.current);
}
} else {
final completer = Completer<void>();
await _auth.verifyPhoneNumber(
phoneNumber: phone,
verificationCompleted: (credential) async {
try {
await _auth.signInWithCredential(credential);
state = AsyncData(await _verifyAndReturn());
} catch (_) {}
if (!completer.isCompleted) completer.complete();
},
verificationFailed: (e) {
state = AsyncError('Gagal mengirim OTP. Coba lagi.', StackTrace.current);
if (!completer.isCompleted) completer.complete();
},
codeSent: (verificationId, _) {
state = AsyncData(MitraAuthOtpSentData(verificationId));
if (!completer.isCompleted) completer.complete();
},
codeAutoRetrievalTimeout: (_) {
if (!completer.isCompleted) completer.complete();
},
);
await completer.future;
}
}
Future<void> verifyOtp(String verificationId, String smsCode) async {
state = const AsyncLoading();
try {
if (kIsWeb && _webConfirmationResult != null) {
await _webConfirmationResult!.confirm(smsCode);
} else if (_auth.currentUser == null) {
// Only sign in if not already signed in via auto-verification
final credential = PhoneAuthProvider.credential(
verificationId: verificationId,
smsCode: smsCode,
);
await _auth.signInWithCredential(credential);
}
state = AsyncData(await _verifyAndReturn());
} catch (e) {
state = AsyncError('OTP tidak valid. Coba lagi.', StackTrace.current);
}
}
Future<void> logout() async {
await _auth.signOut();
state = const AsyncData(MitraAuthInitialData());
}
Future<MitraAuthData> _verifyAndReturn() async {
try {
final response = await _apiClient.post('/api/mitra/auth/verify');
return MitraAuthAuthenticatedData(response['data'] as Map<String, dynamic>);
} on Exception catch (e) {
await _auth.signOut();
final msg = e.toString();
if (msg.contains('ACCOUNT_NOT_FOUND')) {
throw Exception('Akun tidak ditemukan. Hubungi administrator.');
} else if (msg.contains('ACCOUNT_INACTIVE')) {
throw Exception('Akun tidak aktif. Hubungi administrator.');
}
throw Exception('Gagal masuk. Coba lagi.');
}
}
}

View File

@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'auth_notifier.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$mitraAuthHash() => r'65235a41cde3a37feef0b3004a0a48b508bf9ac9';
/// See also [MitraAuth].
@ProviderFor(MitraAuth)
final mitraAuthProvider =
AsyncNotifierProvider<MitraAuth, MitraAuthData>.internal(
MitraAuth.new,
name: r'mitraAuthProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$mitraAuthHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$MitraAuth = AsyncNotifier<MitraAuthData>;
// 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

@@ -1,195 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:equatable/equatable.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../api/api_client.dart';
import '../constants.dart';
// Events
abstract class ChatRequestEvent extends Equatable {
@override
List<Object?> get props => [];
}
class StartListening extends ChatRequestEvent {}
class StopListening extends ChatRequestEvent {}
class _RequestReceived extends ChatRequestEvent {
final Map<String, dynamic> data;
_RequestReceived(this.data);
@override
List<Object?> get props => [data];
}
class _ConnectionError extends ChatRequestEvent {}
class AcceptRequest extends ChatRequestEvent {
final String sessionId;
AcceptRequest(this.sessionId);
@override
List<Object?> get props => [sessionId];
}
class DeclineRequest extends ChatRequestEvent {
final String sessionId;
DeclineRequest(this.sessionId);
@override
List<Object?> get props => [sessionId];
}
// States
abstract class ChatRequestState extends Equatable {
@override
List<Object?> get props => [];
}
class ChatRequestIdle extends ChatRequestState {}
class ChatRequestListening extends ChatRequestState {}
class ChatRequestIncoming extends ChatRequestState {
final String sessionId;
ChatRequestIncoming(this.sessionId);
@override
List<Object?> get props => [sessionId];
}
class ChatRequestAccepting extends ChatRequestState {}
class ChatRequestAccepted extends ChatRequestState {
final Map<String, dynamic> session;
ChatRequestAccepted(this.session);
@override
List<Object?> get props => [session];
}
class ChatRequestError extends ChatRequestState {
final String message;
ChatRequestError(this.message);
@override
List<Object?> get props => [message];
}
// Bloc
class ChatRequestBloc extends Bloc<ChatRequestEvent, ChatRequestState> {
final ApiClient apiClient;
WebSocketChannel? _channel;
StreamSubscription? _wsSubscription;
ChatRequestBloc({required this.apiClient}) : super(ChatRequestIdle()) {
on<StartListening>(_onStartListening);
on<StopListening>(_onStopListening);
on<_RequestReceived>(_onRequestReceived);
on<_ConnectionError>(_onConnectionError);
on<AcceptRequest>(_onAcceptRequest);
on<DeclineRequest>(_onDeclineRequest);
}
Future<void> _onStartListening(StartListening event, Emitter<ChatRequestState> emit) async {
_closeWebSocket();
emit(ChatRequestListening());
await _connectWebSocket();
}
Future<void> _onStopListening(StopListening event, Emitter<ChatRequestState> emit) async {
_closeWebSocket();
emit(ChatRequestIdle());
}
Future<void> _connectWebSocket() async {
try {
final user = FirebaseAuth.instance.currentUser;
if (user == null) return;
final token = await user.getIdToken();
final wsUrl = ApiClient.baseUrl
.replaceFirst('https://', 'wss://')
.replaceFirst('http://', 'ws://');
_channel = WebSocketChannel.connect(Uri.parse('$wsUrl/api/shared/ws'));
_wsSubscription = _channel!.stream.listen(
(raw) {
final data = jsonDecode(raw as String) as Map<String, dynamic>;
if (data['type'] == WsMessage.authOk) return; // Auth confirmed, no action needed
add(_RequestReceived(data));
},
onError: (_) => add(_ConnectionError()),
onDone: () => add(_ConnectionError()),
);
// Authenticate without session_id — just for receiving notifications
_channel!.sink.add(jsonEncode({
'type': WsMessage.auth,
'token': token,
}));
} catch (_) {
add(_ConnectionError());
}
}
Future<void> _onConnectionError(_ConnectionError event, Emitter<ChatRequestState> emit) async {
_closeWebSocket();
// Stay in listening state — FCM will still deliver notifications
if (state is! ChatRequestIdle) {
emit(ChatRequestListening());
}
}
Future<void> _onRequestReceived(_RequestReceived event, Emitter<ChatRequestState> emit) async {
final data = event.data;
final type = data['type'] as String?;
if (type == WsMessage.chatRequest) {
emit(ChatRequestIncoming(data['session_id'] as String));
} else if (type == WsMessage.chatRequestClosed) {
// Request was taken by another mitra or cancelled
if (state is ChatRequestIncoming) {
emit(ChatRequestListening());
}
} else if (type == 'session_rerouted') {
// A session was rerouted away from us — refresh active sessions
emit(ChatRequestListening());
} else if (type == 'session_assigned') {
// A session was force-assigned to us
emit(ChatRequestAccepted({'session_id': data['session_id']}));
}
}
Future<void> _onAcceptRequest(AcceptRequest event, Emitter<ChatRequestState> emit) async {
emit(ChatRequestAccepting());
try {
final response = await apiClient.post('/api/mitra/chat-requests/${event.sessionId}/accept');
emit(ChatRequestAccepted(response['data'] as Map<String, dynamic>));
} on DioException catch (e) {
final code = e.response?.data?['error']?['code'];
if (code == 'REQUEST_UNAVAILABLE') {
emit(ChatRequestListening());
} else {
emit(ChatRequestError('Gagal menerima. Coba lagi.'));
}
}
}
Future<void> _onDeclineRequest(DeclineRequest event, Emitter<ChatRequestState> emit) async {
try {
await apiClient.post('/api/mitra/chat-requests/${event.sessionId}/decline');
} catch (_) {}
emit(ChatRequestListening());
}
void _closeWebSocket() {
_wsSubscription?.cancel();
_wsSubscription = null;
_channel?.sink.close();
_channel = null;
}
@override
Future<void> close() {
_closeWebSocket();
return super.close();
}
}

View File

@@ -0,0 +1,276 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../api/api_client.dart';
import '../api/api_client_provider.dart';
import '../constants.dart';
import '../notifications/notification_service.dart';
part 'chat_request_notifier.g.dart';
// Stale reason for dismissed requests
enum StaleReason {
cancelledByCustomer, // "Permintaan dibatalkan oleh customer"
acceptedByOther, // "Permintaan diterima oleh Bestie lain"
expired, // "Permintaan kedaluwarsa"
}
// States
sealed class ChatRequestData {
const ChatRequestData();
}
class ChatRequestIdleData extends ChatRequestData {
const ChatRequestIdleData();
}
class ChatRequestListeningData extends ChatRequestData {
const ChatRequestListeningData();
}
class ChatRequestIncomingData extends ChatRequestData {
final String sessionId;
final int? durationMinutes;
final bool? isFreeTrial;
final DateTime? createdAt;
const ChatRequestIncomingData(
this.sessionId, {
this.durationMinutes,
this.isFreeTrial,
this.createdAt,
});
}
class ChatRequestStaleData extends ChatRequestData {
final String sessionId;
final StaleReason reason;
const ChatRequestStaleData(this.sessionId, this.reason);
}
class ChatRequestAcceptingData extends ChatRequestData {
const ChatRequestAcceptingData();
}
class ChatRequestAcceptedData extends ChatRequestData {
final Map<String, dynamic> session;
const ChatRequestAcceptedData(this.session);
}
class ChatRequestErrorData extends ChatRequestData {
final String message;
const ChatRequestErrorData(this.message);
}
@Riverpod(keepAlive: true)
class ChatRequest extends _$ChatRequest {
WebSocketChannel? _channel;
StreamSubscription? _wsSubscription;
final List<Map<String, dynamic>> _pendingQueue = [];
ApiClient get _apiClient => ref.read(apiClientProvider);
@override
ChatRequestData build() => const ChatRequestIdleData();
Future<void> startListening() async {
// Don't reset state if showing a request, stale message, or actively accepting
if (state is ChatRequestIncomingData ||
state is ChatRequestStaleData ||
state is ChatRequestAcceptingData ||
state is ChatRequestAcceptedData) {
// Still reconnect WebSocket if needed, but don't change state
if (_channel == null) await _connectWebSocket();
return;
}
_closeWebSocket();
state = const ChatRequestListeningData();
await _connectWebSocket();
}
void stopListening() {
_closeWebSocket();
_pendingQueue.clear();
state = const ChatRequestIdleData();
}
Future<void> _connectWebSocket() async {
try {
final user = FirebaseAuth.instance.currentUser;
if (user == null) return;
final token = await user.getIdToken();
final wsUrl = ApiClient.baseUrl
.replaceFirst('https://', 'wss://')
.replaceFirst('http://', 'ws://');
_channel = WebSocketChannel.connect(Uri.parse('$wsUrl/api/shared/ws'));
_wsSubscription = _channel!.stream.listen(
(raw) {
try {
final text = raw is String ? raw : String.fromCharCodes(raw as List<int>);
final data = jsonDecode(text) as Map<String, dynamic>;
if (data['type'] == WsMessage.authOk) return;
_onRequestReceived(data);
} catch (_) {}
},
onError: (_) => _onConnectionError(),
onDone: () => _onConnectionError(),
);
_channel!.sink.add(jsonEncode({
'type': WsMessage.auth,
'token': token,
}));
} catch (_) {
_onConnectionError();
}
}
void _onConnectionError() {
_closeWebSocket();
if (state is! ChatRequestIdleData) {
state = const ChatRequestListeningData();
}
}
void _onRequestReceived(Map<String, dynamic> data) {
final type = data['type'] as String?;
if (type == WsMessage.chatRequest) {
final sessionId = data['session_id'] as String;
// If already showing a request or stale message, queue it
if (state is ChatRequestIncomingData ||
state is ChatRequestStaleData ||
state is ChatRequestAcceptingData) {
if (!_pendingQueue.any((q) => q['session_id'] == sessionId)) {
_pendingQueue.add(data);
}
} else {
state = ChatRequestIncomingData(
sessionId,
durationMinutes: data['duration_minutes'] as int?,
isFreeTrial: data['is_free_trial'] as bool?,
createdAt: data['created_at'] != null
? DateTime.tryParse(data['created_at'] as String)
: null,
);
}
// Show local notification
NotificationService.showLocalNotification(
title: 'Permintaan Chat Baru',
body: 'Ada pelanggan yang ingin curhat! Ketuk untuk menerima.',
data: {'type': 'chat_request', 'session_id': sessionId, 'action': 'open_accept'},
);
} else if (type == WsMessage.chatRequestClosed) {
final closedSessionId = data['session_id'] as String;
final reason = data['reason'] as String?;
// Remove from queue if queued
_pendingQueue.removeWhere((q) => q['session_id'] == closedSessionId);
// If currently displayed, transition to stale
if (state is ChatRequestIncomingData &&
(state as ChatRequestIncomingData).sessionId == closedSessionId) {
final staleReason = switch (reason) {
'cancelled_by_customer' => StaleReason.cancelledByCustomer,
'accepted_by_other' => StaleReason.acceptedByOther,
'expired' => StaleReason.expired,
_ => StaleReason.expired,
};
state = ChatRequestStaleData(closedSessionId, staleReason);
}
} else if (type == 'session_rerouted') {
_pendingQueue.clear();
state = const ChatRequestListeningData();
} else if (type == 'session_assigned') {
_pendingQueue.clear();
state = ChatRequestAcceptedData({'session_id': data['session_id']});
}
}
/// Called when user taps a chat_request notification.
Future<void> setIncomingFromNotification(String sessionId) async {
state = ChatRequestIncomingData(sessionId);
await validateIncomingRequest();
}
/// Check if the current incoming request is still valid.
Future<void> validateIncomingRequest() async {
if (state is! ChatRequestIncomingData) return;
final sessionId = (state as ChatRequestIncomingData).sessionId;
try {
final response = await _apiClient.get('/api/mitra/chat-requests/$sessionId/status');
final status = response['data']?['status'] as String?;
if (status != 'pending_acceptance') {
state = ChatRequestStaleData(sessionId, StaleReason.expired);
}
} catch (_) {
// On error, keep current state
}
}
/// Swipe down on active request — ignore without sending reject to backend.
void ignore() {
_advanceQueue();
}
/// Acknowledge a stale message (OK button or swipe down).
void acknowledgeStale() {
_advanceQueue();
}
/// Show next queued request or return to listening.
void _advanceQueue() {
if (_pendingQueue.isNotEmpty) {
final next = _pendingQueue.removeAt(0);
final sessionId = next['session_id'] as String;
state = ChatRequestIncomingData(
sessionId,
durationMinutes: next['duration_minutes'] as int?,
isFreeTrial: next['is_free_trial'] as bool?,
createdAt: next['created_at'] != null
? DateTime.tryParse(next['created_at'] as String)
: null,
);
validateIncomingRequest();
} else {
state = const ChatRequestListeningData();
}
}
Future<void> accept(String sessionId) async {
state = const ChatRequestAcceptingData();
try {
final response = await _apiClient.post('/api/mitra/chat-requests/$sessionId/accept');
_pendingQueue.clear();
state = ChatRequestAcceptedData(response['data'] as Map<String, dynamic>);
} on DioException catch (e) {
final code = e.response?.data?['error']?['code'];
if (code == 'REQUEST_UNAVAILABLE') {
state = ChatRequestStaleData(sessionId, StaleReason.acceptedByOther);
} else {
state = const ChatRequestErrorData('Gagal menerima. Coba lagi.');
}
}
}
Future<void> decline(String sessionId) async {
try {
await _apiClient.post('/api/mitra/chat-requests/$sessionId/decline');
} catch (_) {}
_advanceQueue();
}
void _closeWebSocket() {
_wsSubscription?.cancel();
_wsSubscription = null;
_channel?.sink.close();
_channel = null;
}
}

View File

@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'chat_request_notifier.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$chatRequestHash() => r'c80b16e371658fbbaca88a75b48e16a3c0e057b3';
/// See also [ChatRequest].
@ProviderFor(ChatRequest)
final chatRequestProvider =
NotifierProvider<ChatRequest, ChatRequestData>.internal(
ChatRequest.new,
name: r'chatRequestProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$chatRequestHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$ChatRequest = Notifier<ChatRequestData>;
// 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

@@ -1,91 +0,0 @@
import 'package:dio/dio.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../api/api_client.dart';
// Events
abstract class ExtensionEvent extends Equatable {
@override
List<Object?> get props => [];
}
class RespondToExtension extends ExtensionEvent {
final String sessionId;
final String extensionId;
final bool accepted;
RespondToExtension({required this.sessionId, required this.extensionId, required this.accepted});
@override
List<Object?> get props => [sessionId, extensionId, accepted];
}
class SubmitGoodbye extends ExtensionEvent {
final String sessionId;
final String message;
SubmitGoodbye({required this.sessionId, required this.message});
@override
List<Object?> get props => [sessionId, message];
}
// States
abstract class ExtensionState extends Equatable {
@override
List<Object?> get props => [];
}
class ExtensionIdle extends ExtensionState {}
class ExtensionResponding extends ExtensionState {}
class ExtensionShowGoodbye extends ExtensionState {}
class ExtensionSubmitting extends ExtensionState {}
class ExtensionComplete extends ExtensionState {}
class ExtensionError extends ExtensionState {
final String message;
ExtensionError(this.message);
@override
List<Object?> get props => [message];
}
// Bloc
class ExtensionBloc extends Bloc<ExtensionEvent, ExtensionState> {
final ApiClient apiClient;
ExtensionBloc({required this.apiClient}) : super(ExtensionIdle()) {
on<RespondToExtension>(_onRespond);
on<SubmitGoodbye>(_onSubmitGoodbye);
}
Future<void> _onRespond(RespondToExtension event, Emitter<ExtensionState> emit) async {
emit(ExtensionResponding());
try {
await apiClient.post('/api/mitra/chat-requests/sessions/${event.sessionId}/extend-response', data: {
'extension_id': event.extensionId,
'accepted': event.accepted,
});
if (!event.accepted) {
emit(ExtensionShowGoodbye());
} else {
emit(ExtensionIdle());
}
} on DioException catch (e) {
final code = e.response?.data?['error']?['code'];
if (code == 'EXTENSION_RESOLVED') {
// Extension already timed out or resolved — move to goodbye
emit(ExtensionShowGoodbye());
} else {
emit(ExtensionError('Gagal merespon perpanjangan.'));
}
}
}
Future<void> _onSubmitGoodbye(SubmitGoodbye event, Emitter<ExtensionState> emit) async {
emit(ExtensionSubmitting());
try {
await apiClient.post('/api/shared/sessions/${event.sessionId}/close-message', data: {
'message': event.message,
});
emit(ExtensionComplete());
} catch (e) {
emit(ExtensionError('Gagal mengirim pesan penutup.'));
}
}
}

View File

@@ -0,0 +1,79 @@
import 'package:dio/dio.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../api/api_client_provider.dart';
part 'extension_notifier.g.dart';
// States
sealed class ExtensionData {
const ExtensionData();
}
class ExtensionIdleData extends ExtensionData {
const ExtensionIdleData();
}
class ExtensionRespondingData extends ExtensionData {
const ExtensionRespondingData();
}
class ExtensionShowGoodbyeData extends ExtensionData {
const ExtensionShowGoodbyeData();
}
class ExtensionSubmittingData extends ExtensionData {
const ExtensionSubmittingData();
}
class ExtensionCompleteData extends ExtensionData {
const ExtensionCompleteData();
}
class ExtensionErrorData extends ExtensionData {
final String message;
const ExtensionErrorData(this.message);
}
@Riverpod(keepAlive: true)
class MitraExtension extends _$MitraExtension {
@override
ExtensionData build() => const ExtensionIdleData();
Future<void> respond(String sessionId, {required String extensionId, required bool accepted}) async {
state = const ExtensionRespondingData();
try {
await ref.read(apiClientProvider).post('/api/mitra/chat-requests/sessions/$sessionId/extend-response', data: {
'extension_id': extensionId,
'accepted': accepted,
});
if (!accepted) {
state = const ExtensionShowGoodbyeData();
} else {
state = const ExtensionIdleData();
}
} on DioException catch (e) {
final code = e.response?.data?['error']?['code'];
if (code == 'EXTENSION_RESOLVED') {
state = const ExtensionShowGoodbyeData();
} else {
state = const ExtensionErrorData('Gagal merespon perpanjangan.');
}
}
}
Future<void> submitGoodbye(String sessionId, String message) async {
state = const ExtensionSubmittingData();
try {
await ref.read(apiClientProvider).post('/api/shared/sessions/$sessionId/close-message', data: {
'message': message,
});
state = const ExtensionCompleteData();
} catch (e) {
state = const ExtensionErrorData('Gagal mengirim pesan penutup.');
}
}
void reset() {
state = const ExtensionIdleData();
}
}

View File

@@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'extension_notifier.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$mitraExtensionHash() => r'4eed73b51454238e2cd40a255c148f232f281913';
/// See also [MitraExtension].
@ProviderFor(MitraExtension)
final mitraExtensionProvider =
NotifierProvider<MitraExtension, ExtensionData>.internal(
MitraExtension.new,
name: r'mitraExtensionProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$mitraExtensionHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$MitraExtension = Notifier<ExtensionData>;
// 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

@@ -1,77 +1,36 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:equatable/equatable.dart';
import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:web_socket_channel/web_socket_channel.dart'; 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 '../constants.dart'; import '../constants.dart';
// Events part 'mitra_chat_notifier.g.dart';
abstract class MitraChatEvent extends Equatable {
@override
List<Object?> get props => [];
}
class ConnectChat extends MitraChatEvent {
final String sessionId;
ConnectChat(this.sessionId);
@override
List<Object?> get props => [sessionId];
}
class DisconnectChat extends MitraChatEvent {}
class SendMessage extends MitraChatEvent {
final String content;
SendMessage(this.content);
@override
List<Object?> get props => [content];
}
class SendTyping extends MitraChatEvent {}
class _MessageReceived extends MitraChatEvent {
final Map<String, dynamic> data;
_MessageReceived(this.data);
@override
List<Object?> get props => [data];
}
class _ConnectionError extends MitraChatEvent {}
class MarkMessagesDelivered extends MitraChatEvent {
final List<String> messageIds;
MarkMessagesDelivered(this.messageIds);
@override
List<Object?> get props => [messageIds];
}
class MarkMessagesRead extends MitraChatEvent {
final List<String> messageIds;
MarkMessagesRead(this.messageIds);
@override
List<Object?> get props => [messageIds];
}
// States // States
abstract class MitraChatState extends Equatable { sealed class MitraChatData {
@override const MitraChatData();
List<Object?> get props => [];
} }
class ChatInitial extends MitraChatState {} class MitraChatInitialData extends MitraChatData {
class ChatConnecting extends MitraChatState {} const MitraChatInitialData();
}
class ChatConnected extends MitraChatState { class MitraChatConnectingData extends MitraChatData {
final List<ChatMessage> messages; const MitraChatConnectingData();
}
class MitraChatConnectedData extends MitraChatData {
final List<MitraChatMessage> messages;
final bool isOtherTyping; final bool isOtherTyping;
final int? remainingSeconds; final int? remainingSeconds;
final bool sessionExpired; final bool sessionExpired;
final bool sessionClosing; final bool sessionClosing;
final Map<String, dynamic>? extensionRequest; final Map<String, dynamic>? extensionRequest;
ChatConnected({ const MitraChatConnectedData({
required this.messages, required this.messages,
this.isOtherTyping = false, this.isOtherTyping = false,
this.remainingSeconds, this.remainingSeconds,
@@ -80,8 +39,8 @@ class ChatConnected extends MitraChatState {
this.extensionRequest, this.extensionRequest,
}); });
ChatConnected copyWith({ MitraChatConnectedData copyWith({
List<ChatMessage>? messages, List<MitraChatMessage>? messages,
bool? isOtherTyping, bool? isOtherTyping,
int? remainingSeconds, int? remainingSeconds,
bool? sessionExpired, bool? sessionExpired,
@@ -89,7 +48,7 @@ class ChatConnected extends MitraChatState {
Map<String, dynamic>? extensionRequest, Map<String, dynamic>? extensionRequest,
bool clearExtensionRequest = false, bool clearExtensionRequest = false,
}) { }) {
return ChatConnected( return MitraChatConnectedData(
messages: messages ?? this.messages, messages: messages ?? this.messages,
isOtherTyping: isOtherTyping ?? this.isOtherTyping, isOtherTyping: isOtherTyping ?? this.isOtherTyping,
remainingSeconds: remainingSeconds ?? this.remainingSeconds, remainingSeconds: remainingSeconds ?? this.remainingSeconds,
@@ -98,20 +57,15 @@ class ChatConnected extends MitraChatState {
extensionRequest: clearExtensionRequest ? null : (extensionRequest ?? this.extensionRequest), extensionRequest: clearExtensionRequest ? null : (extensionRequest ?? this.extensionRequest),
); );
} }
@override
List<Object?> get props => [messages, isOtherTyping, remainingSeconds, sessionExpired, sessionClosing, extensionRequest];
} }
class ChatError extends MitraChatState { class MitraChatErrorData extends MitraChatData {
final String message; final String message;
ChatError(this.message); const MitraChatErrorData(this.message);
@override
List<Object?> get props => [message];
} }
// Message model // Message model
class ChatMessage { class MitraChatMessage {
final String id; final String id;
final String senderType; final String senderType;
final String content; final String content;
@@ -119,7 +73,7 @@ class ChatMessage {
final String status; final String status;
final DateTime createdAt; final DateTime createdAt;
ChatMessage({ const MitraChatMessage({
required this.id, required this.id,
required this.senderType, required this.senderType,
required this.content, required this.content,
@@ -128,8 +82,8 @@ class ChatMessage {
required this.createdAt, required this.createdAt,
}); });
ChatMessage copyWith({String? status}) { MitraChatMessage copyWith({String? status}) {
return ChatMessage( return MitraChatMessage(
id: id, id: id,
senderType: senderType, senderType: senderType,
content: content, content: content,
@@ -140,44 +94,35 @@ class ChatMessage {
} }
} }
// Bloc @Riverpod(keepAlive: true)
class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> { class MitraChat extends _$MitraChat {
final ApiClient apiClient;
WebSocketChannel? _channel; WebSocketChannel? _channel;
StreamSubscription? _wsSubscription; StreamSubscription? _wsSubscription;
Timer? _typingTimer; Timer? _typingTimer;
MitraChatBloc({required this.apiClient}) : super(ChatInitial()) { ApiClient get _apiClient => ref.read(apiClientProvider);
on<ConnectChat>(_onConnect);
on<DisconnectChat>(_onDisconnect);
on<SendMessage>(_onSendMessage);
on<SendTyping>(_onSendTyping);
on<_MessageReceived>(_onMessageReceived);
on<_ConnectionError>(_onConnectionError);
on<MarkMessagesDelivered>(_onMarkDelivered);
on<MarkMessagesRead>(_onMarkRead);
}
Future<void> _onConnect(ConnectChat event, Emitter<MitraChatState> emit) async { @override
emit(ChatConnecting()); MitraChatData build() => const MitraChatInitialData();
Future<void> connect(String sessionId) async {
state = const MitraChatConnectingData();
try { try {
// Check session status before connecting final sessionInfo = await _apiClient.get('/api/shared/chat/$sessionId/info');
final sessionInfo = await apiClient.get('/api/shared/chat/${event.sessionId}/info');
final sessionData = sessionInfo['data'] as Map<String, dynamic>?; final sessionData = sessionInfo['data'] as Map<String, dynamic>?;
final sessionStatus = sessionData?['status'] as String?; final sessionStatus = sessionData?['status'] as String?;
if (sessionStatus == SessionStatus.completed || if (sessionStatus == SessionStatus.completed ||
sessionStatus == SessionStatus.cancelled || sessionStatus == SessionStatus.cancelled ||
sessionStatus == SessionStatus.expired) { sessionStatus == SessionStatus.expired) {
emit(ChatError('Sesi sudah berakhir.')); state = const MitraChatErrorData('Sesi sudah berakhir.');
return; return;
} }
final isClosing = sessionStatus == SessionStatus.closing; final isClosing = sessionStatus == SessionStatus.closing;
final response = await apiClient.get('/api/shared/chat/${event.sessionId}/messages'); final response = await _apiClient.get('/api/shared/chat/$sessionId/messages');
final messagesData = response['data'] as List<dynamic>; final messagesData = response['data'] as List<dynamic>;
final messages = messagesData.map((m) => ChatMessage( final messages = messagesData.map((m) => MitraChatMessage(
id: m['id'] as String, id: m['id'] as String,
senderType: m['sender_type'] as String, senderType: m['sender_type'] as String,
content: m['content'] as String, content: m['content'] as String,
@@ -196,73 +141,69 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
_wsSubscription = _channel!.stream.listen( _wsSubscription = _channel!.stream.listen(
(raw) { (raw) {
final data = jsonDecode(raw as String) as Map<String, dynamic>; final data = jsonDecode(raw as String) as Map<String, dynamic>;
add(_MessageReceived(data)); _onMessageReceived(data);
}, },
onError: (_) => add(_ConnectionError()), onError: (_) {},
onDone: () => add(_ConnectionError()), onDone: () {},
); );
_channel!.sink.add(jsonEncode({ _channel!.sink.add(jsonEncode({
'type': WsMessage.auth, 'type': WsMessage.auth,
'token': token, 'token': token,
'session_id': event.sessionId, 'session_id': sessionId,
})); }));
emit(ChatConnected( state = MitraChatConnectedData(messages: messages, sessionClosing: isClosing);
messages: messages,
sessionClosing: isClosing,
));
} catch (e) { } catch (e) {
emit(ChatError('Gagal terhubung ke chat.')); state = const MitraChatErrorData('Gagal terhubung ke chat.');
} }
} }
void _onDisconnect(DisconnectChat event, Emitter<MitraChatState> emit) { void disconnect() {
_cleanup(); _cleanup();
emit(ChatInitial()); state = const MitraChatInitialData();
} }
void _onSendMessage(SendMessage event, Emitter<MitraChatState> emit) { void sendMessage(String content) {
if (state is! ChatConnected || _channel == null) return; if (state is! MitraChatConnectedData || _channel == null) return;
final current = state as ChatConnected; final current = state as MitraChatConnectedData;
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}'; final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
final msg = ChatMessage( final msg = MitraChatMessage(
id: tempId, id: tempId,
senderType: UserType.mitra, senderType: UserType.mitra,
content: event.content, content: content,
status: 'sending', status: 'sending',
createdAt: DateTime.now(), createdAt: DateTime.now(),
); );
emit(current.copyWith(messages: [...current.messages, msg])); state = current.copyWith(messages: [...current.messages, msg]);
_channel!.sink.add(jsonEncode({ _channel!.sink.add(jsonEncode({
'type': WsMessage.message, 'type': WsMessage.message,
'content': event.content, 'content': content,
'_temp_id': tempId, '_temp_id': tempId,
})); }));
} }
void _onSendTyping(SendTyping event, Emitter<MitraChatState> emit) { void sendTyping() {
if (_channel == null) return; if (_channel == null) return;
_channel!.sink.add(jsonEncode({'type': WsMessage.typing})); _channel!.sink.add(jsonEncode({'type': WsMessage.typing}));
} }
void _onMarkDelivered(MarkMessagesDelivered event, Emitter<MitraChatState> emit) { void markDelivered(List<String> messageIds) {
if (_channel == null) return; if (_channel == null) return;
_channel!.sink.add(jsonEncode({'type': WsMessage.delivered, 'message_ids': event.messageIds})); _channel!.sink.add(jsonEncode({'type': WsMessage.delivered, 'message_ids': messageIds}));
} }
void _onMarkRead(MarkMessagesRead event, Emitter<MitraChatState> emit) { void markRead(List<String> messageIds) {
if (_channel == null) return; if (_channel == null) return;
_channel!.sink.add(jsonEncode({'type': WsMessage.read, 'message_ids': event.messageIds})); _channel!.sink.add(jsonEncode({'type': WsMessage.read, 'message_ids': messageIds}));
} }
void _onMessageReceived(_MessageReceived event, Emitter<MitraChatState> emit) { void _onMessageReceived(Map<String, dynamic> data) {
if (state is! ChatConnected) return; if (state is! MitraChatConnectedData) return;
final current = state as ChatConnected; final current = state as MitraChatConnectedData;
final data = event.data;
final type = data['type'] as String?; final type = data['type'] as String?;
switch (type) { switch (type) {
@@ -270,7 +211,7 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
break; break;
case WsMessage.message: case WsMessage.message:
final msg = ChatMessage( final msg = MitraChatMessage(
id: data['message_id'] as String, id: data['message_id'] as String,
senderType: data['sender_type'] as String, senderType: data['sender_type'] as String,
content: data['content'] as String, content: data['content'] as String,
@@ -278,8 +219,8 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
status: MessageStatus.sent, status: MessageStatus.sent,
createdAt: DateTime.parse(data['created_at'] as String), createdAt: DateTime.parse(data['created_at'] as String),
); );
emit(current.copyWith(messages: [...current.messages, msg])); state = current.copyWith(messages: [...current.messages, msg]);
add(MarkMessagesDelivered([msg.id])); markDelivered([msg.id]);
break; break;
case WsMessage.messageAck: case WsMessage.messageAck:
@@ -292,7 +233,7 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
final idx = updatedMessages.indexWhere((m) => m.status == status && m.senderType == UserType.mitra); final idx = updatedMessages.indexWhere((m) => m.status == status && m.senderType == UserType.mitra);
if (idx >= 0) { if (idx >= 0) {
final old = updatedMessages[idx]; final old = updatedMessages[idx];
updatedMessages[idx] = ChatMessage( updatedMessages[idx] = MitraChatMessage(
id: messageId, id: messageId,
senderType: old.senderType, senderType: old.senderType,
content: old.content, content: old.content,
@@ -301,7 +242,7 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
createdAt: old.createdAt, createdAt: old.createdAt,
); );
} }
emit(current.copyWith(messages: updatedMessages)); state = current.copyWith(messages: updatedMessages);
break; break;
case WsMessage.messageStatus: case WsMessage.messageStatus:
@@ -311,37 +252,37 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
if (messageIds.contains(m.id)) return m.copyWith(status: status); if (messageIds.contains(m.id)) return m.copyWith(status: status);
return m; return m;
}).toList(); }).toList();
emit(current.copyWith(messages: updatedMessages)); state = current.copyWith(messages: updatedMessages);
break; break;
case WsMessage.typing: case WsMessage.typing:
emit(current.copyWith(isOtherTyping: true)); state = current.copyWith(isOtherTyping: true);
_typingTimer?.cancel(); _typingTimer?.cancel();
_typingTimer = Timer(const Duration(seconds: 3), () { _typingTimer = Timer(const Duration(seconds: 3), () {
if (state is ChatConnected) { if (state is MitraChatConnectedData) {
emit((state as ChatConnected).copyWith(isOtherTyping: false)); state = (state as MitraChatConnectedData).copyWith(isOtherTyping: false);
} }
}); });
break; break;
case WsMessage.sessionTimer: case WsMessage.sessionTimer:
emit(current.copyWith(remainingSeconds: data['remaining_seconds'] as int?)); state = current.copyWith(remainingSeconds: data['remaining_seconds'] as int?);
break; break;
case WsMessage.sessionExpired: case WsMessage.sessionExpired:
emit(current.copyWith(sessionExpired: true)); state = current.copyWith(sessionExpired: true);
break; break;
case WsMessage.extensionRequest: case WsMessage.extensionRequest:
emit(current.copyWith(extensionRequest: data)); state = current.copyWith(extensionRequest: data);
break; break;
case WsMessage.sessionResumed: case WsMessage.sessionResumed:
emit(current.copyWith(sessionExpired: false, sessionClosing: false, clearExtensionRequest: true)); state = current.copyWith(sessionExpired: false, sessionClosing: false, clearExtensionRequest: true);
break; break;
case WsMessage.sessionClosing: case WsMessage.sessionClosing:
emit(current.copyWith(sessionClosing: true, clearExtensionRequest: true)); state = current.copyWith(sessionClosing: true, clearExtensionRequest: true);
break; break;
case WsMessage.sessionCompleted: case WsMessage.sessionCompleted:
@@ -350,8 +291,6 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
} }
} }
void _onConnectionError(_ConnectionError event, Emitter<MitraChatState> emit) {}
void _cleanup() { void _cleanup() {
_wsSubscription?.cancel(); _wsSubscription?.cancel();
_wsSubscription = null; _wsSubscription = null;
@@ -360,10 +299,4 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
_typingTimer?.cancel(); _typingTimer?.cancel();
_typingTimer = null; _typingTimer = null;
} }
@override
Future<void> close() {
_cleanup();
return super.close();
}
} }

View File

@@ -0,0 +1,24 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'mitra_chat_notifier.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$mitraChatHash() => r'827aa874dbcf49c17f94c0507f5e0a4064bcede3';
/// See also [MitraChat].
@ProviderFor(MitraChat)
final mitraChatProvider = NotifierProvider<MitraChat, MitraChatData>.internal(
MitraChat.new,
name: r'mitraChatProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$mitraChatHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$MitraChat = Notifier<MitraChatData>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -0,0 +1,54 @@
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 UnreadSessions extends _$UnreadSessions {
Timer? _pollTimer;
@override
Map<String, int> build() {
_startPolling();
ref.onDispose(_stopPolling);
return {};
}
void _startPolling() {
_stopPolling();
_fetchUnreadCounts();
_pollTimer = Timer.periodic(const Duration(seconds: 15), (_) {
_fetchUnreadCounts();
});
}
void _stopPolling() {
_pollTimer?.cancel();
_pollTimer = null;
}
Future<void> _fetchUnreadCounts() async {
try {
final apiClient = ref.read(apiClientProvider);
final response = await apiClient.get('/api/mitra/chat-requests/sessions/active-with-unread');
final sessions = response['data'] as List<dynamic>;
final counts = <String, int>{};
for (final s in sessions) {
final id = s['id'] as String;
final count = s['unread_count'] as int? ?? 0;
if (count > 0) counts[id] = count;
}
state = counts;
} catch (_) {}
}
int get totalUnread => state.values.fold(0, (a, b) => a + b);
void markSessionRead(String sessionId) {
state = {...state}..remove(sessionId);
}
void refresh() => _fetchUnreadCounts();
}

View File

@@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'unread_notifier.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$unreadSessionsHash() => r'd2ff837f1e781e6aa624b3d3ca2befb0d1d258e8';
/// See also [UnreadSessions].
@ProviderFor(UnreadSessions)
final unreadSessionsProvider =
NotifierProvider<UnreadSessions, Map<String, int>>.internal(
UnreadSessions.new,
name: r'unreadSessionsProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$unreadSessionsHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$UnreadSessions = Notifier<Map<String, 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

View File

@@ -0,0 +1,269 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../chat_request_notifier.dart';
import '../../../router.dart';
class ChatRequestOverlay extends ConsumerStatefulWidget {
final Widget child;
const ChatRequestOverlay({super.key, required this.child});
@override
ConsumerState<ChatRequestOverlay> createState() => _ChatRequestOverlayState();
}
class _ChatRequestOverlayState extends ConsumerState<ChatRequestOverlay>
with SingleTickerProviderStateMixin {
late final AnimationController _animController;
late final Animation<Offset> _slideAnimation;
bool _visible = false;
@override
void initState() {
super.initState();
_animController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(CurvedAnimation(parent: _animController, curve: Curves.easeOutCubic));
}
@override
void dispose() {
_animController.dispose();
super.dispose();
}
void _show() {
if (!_visible) {
setState(() => _visible = true);
_animController.forward();
}
}
void _hide() {
_animController.reverse().then((_) {
if (mounted) setState(() => _visible = false);
});
}
void _onSwipeDown(DragEndDetails details) {
if (details.primaryVelocity != null && details.primaryVelocity! > 200) {
final state = ref.read(chatRequestProvider);
if (state is ChatRequestIncomingData) {
ref.read(chatRequestProvider.notifier).ignore();
} else if (state is ChatRequestStaleData) {
ref.read(chatRequestProvider.notifier).acknowledgeStale();
}
}
}
@override
Widget build(BuildContext context) {
ref.listen(chatRequestProvider, (prev, next) {
if (next is ChatRequestIncomingData || next is ChatRequestStaleData) {
_show();
} else if (next is ChatRequestAcceptedData) {
_hide();
// Navigate to chat session
final session = next.session;
final sessionId = session['session_id'] as String? ?? session['id'] as String;
final router = ref.read(routerProvider);
router.push('/chat/session/$sessionId', extra: {
'customerName': session['customer_display_name'] as String? ?? 'Customer',
});
} else {
_hide();
}
});
return Directionality(
textDirection: TextDirection.ltr,
child: Stack(
children: [
widget.child,
if (_visible) ...[
// Semi-transparent dim
Positioned.fill(
child: GestureDetector(
onTap: () {}, // Block taps but don't dismiss
child: FadeTransition(
opacity: _animController,
child: Container(color: Colors.black.withOpacity(0.3)),
),
),
),
// Overlay content
Positioned(
left: 0,
right: 0,
bottom: 0,
child: SlideTransition(
position: _slideAnimation,
child: GestureDetector(
onVerticalDragEnd: _onSwipeDown,
child: _buildContent(),
),
),
),
],
],
),
);
}
Widget _buildContent() {
final requestState = ref.watch(chatRequestProvider);
if (requestState is ChatRequestIncomingData) {
return _buildActiveRequest(requestState);
}
if (requestState is ChatRequestStaleData) {
return _buildStaleRequest(requestState);
}
return const SizedBox.shrink();
}
Widget _buildActiveRequest(ChatRequestIncomingData data) {
final durationText = data.isFreeTrial == true
? 'Free Trial'
: data.durationMinutes != null
? '${data.durationMinutes} Menit'
: '';
return Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 10, offset: Offset(0, -2))],
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Drag handle
Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
const Icon(Icons.chat, size: 48, color: Colors.blue),
const SizedBox(height: 12),
const Text(
'Ada permintaan chat baru!',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
if (durationText.isNotEmpty)
Text(
'Durasi: $durationText',
style: const TextStyle(fontSize: 14, color: Colors.grey),
),
const SizedBox(height: 8),
const Text(
'Seorang customer ingin curhat denganmu.',
style: TextStyle(fontSize: 14, color: Colors.grey),
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () {
ref.read(chatRequestProvider.notifier).decline(data.sessionId);
},
child: const Text('Tolak'),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton(
onPressed: () {
ref.read(chatRequestProvider.notifier).accept(data.sessionId);
},
child: const Text('Terima'),
),
),
],
),
const SizedBox(height: 8),
Text(
'Geser ke bawah untuk mengabaikan',
style: TextStyle(fontSize: 12, color: Colors.grey.shade400),
),
],
),
),
),
);
}
Widget _buildStaleRequest(ChatRequestStaleData data) {
final message = switch (data.reason) {
StaleReason.cancelledByCustomer => 'Permintaan dibatalkan oleh customer',
StaleReason.acceptedByOther => 'Permintaan diterima oleh Bestie lain',
StaleReason.expired => 'Permintaan kedaluwarsa',
};
final icon = switch (data.reason) {
StaleReason.cancelledByCustomer => Icons.cancel_outlined,
StaleReason.acceptedByOther => Icons.people_outline,
StaleReason.expired => Icons.timer_off_outlined,
};
return Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 10, offset: Offset(0, -2))],
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Drag handle
Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
Icon(icon, size: 48, color: Colors.orange),
const SizedBox(height: 12),
Text(
message,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
ref.read(chatRequestProvider.notifier).acknowledgeStale();
},
child: const Text('OK'),
),
),
],
),
),
),
);
}
}

View File

@@ -8,6 +8,10 @@ class NotificationService {
static final _localNotifications = FlutterLocalNotificationsPlugin(); static final _localNotifications = FlutterLocalNotificationsPlugin();
static GoRouter? _router; static GoRouter? _router;
/// Callback for when a chat request notification is tapped.
/// Set this from the app to bridge notifications → Riverpod state.
static void Function(String sessionId)? onChatRequestTapped;
static const _channel = AndroidNotificationChannel( static const _channel = AndroidNotificationChannel(
'chat_messages', 'chat_messages',
'Chat Messages', 'Chat Messages',
@@ -83,13 +87,53 @@ class NotificationService {
_navigateFromMessage(message.data); _navigateFromMessage(message.data);
} }
static void _navigateFromMessage(Map<String, dynamic> data) { /// Show a local notification programmatically (e.g. from WebSocket while backgrounded)
final sessionId = data['session_id'] as String?; static Future<void> showLocalNotification({
if (sessionId == null || _router == null) return; required String title,
required String body,
Map<String, dynamic>? data,
}) async {
await _localNotifications.show(
id: DateTime.now().millisecondsSinceEpoch % 100000,
title: title,
body: body,
notificationDetails: NotificationDetails(
android: AndroidNotificationDetails(
_channel.id,
_channel.name,
channelDescription: _channel.description,
importance: Importance.high,
priority: Priority.high,
playSound: true,
enableVibration: true,
),
iOS: const DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
),
payload: data != null ? jsonEncode(data) : null,
);
}
static void _navigateFromMessage(Map<String, dynamic> data) {
if (_router == null) return;
final sessionId = data['session_id'] as String?;
final type = data['type'] as String?; final type = data['type'] as String?;
if (type == 'chat_message' || type == 'chat_request') { final action = data['action'] as String?;
_router!.push('/chat/session/$sessionId');
if (type == 'chat_request' && action == 'open_accept' && sessionId != null) {
// Update the notifier state with this session, then navigate
onChatRequestTapped?.call(sessionId);
_router!.go('/home');
} else if (type == 'session_closing' || type == 'session_expired') {
// Navigate to the chat session closure screen
if (sessionId != null) {
_router!.push('/chat/session/$sessionId', extra: {'customerName': 'Customer'});
}
} else if (type == 'chat_message' && sessionId != null) {
_router!.push('/chat/session/$sessionId', extra: {'customerName': 'Customer'});
} }
} }
} }

View File

@@ -1,128 +0,0 @@
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../api/api_client.dart';
// Events
abstract class StatusEvent extends Equatable {
@override
List<Object?> get props => [];
}
class StatusLoadRequested extends StatusEvent {}
class ToggleOnline extends StatusEvent {}
class ToggleOffline extends StatusEvent {}
class HeartbeatTick extends StatusEvent {}
class AppPaused extends StatusEvent {}
class AppResumed extends StatusEvent {}
// States
abstract class StatusState extends Equatable {
@override
List<Object?> get props => [];
}
class StatusInitial extends StatusState {}
class StatusLoaded extends StatusState {
final bool isOnline;
StatusLoaded({required this.isOnline});
@override
List<Object?> get props => [isOnline];
}
class StatusLoading extends StatusState {}
class StatusError extends StatusState {
final String message;
StatusError(this.message);
@override
List<Object?> get props => [message];
}
// Bloc
class StatusBloc extends Bloc<StatusEvent, StatusState> {
final ApiClient apiClient;
Timer? _heartbeatTimer;
StatusBloc({required this.apiClient}) : super(StatusInitial()) {
on<StatusLoadRequested>(_onLoad);
on<ToggleOnline>(_onToggleOnline);
on<ToggleOffline>(_onToggleOffline);
on<HeartbeatTick>(_onHeartbeat);
on<AppPaused>(_onAppPaused);
on<AppResumed>(_onAppResumed);
}
Future<void> _onLoad(StatusLoadRequested event, Emitter<StatusState> emit) async {
try {
final response = await apiClient.get('/api/mitra/status');
final data = response['data'] as Map<String, dynamic>;
emit(StatusLoaded(isOnline: data['is_online'] as bool));
} catch (e) {
emit(StatusLoaded(isOnline: false));
}
}
Future<void> _onToggleOnline(ToggleOnline event, Emitter<StatusState> emit) async {
emit(StatusLoading());
try {
await apiClient.post('/api/mitra/status/online');
_startHeartbeat();
emit(StatusLoaded(isOnline: true));
} catch (e) {
emit(StatusError('Gagal mengubah status. Coba lagi.'));
}
}
Future<void> _onToggleOffline(ToggleOffline event, Emitter<StatusState> emit) async {
emit(StatusLoading());
try {
await apiClient.post('/api/mitra/status/offline');
_stopHeartbeat();
emit(StatusLoaded(isOnline: false));
} catch (e) {
emit(StatusError('Gagal mengubah status. Coba lagi.'));
}
}
Future<void> _onHeartbeat(HeartbeatTick event, Emitter<StatusState> emit) async {
try {
await apiClient.post('/api/mitra/status/heartbeat');
} catch (_) {
// Heartbeat failure is non-critical; server will auto-offline after 45s
}
}
Future<void> _onAppPaused(AppPaused event, Emitter<StatusState> emit) async {
// Don't auto-offline on pause — heartbeat timeout (45s) handles truly offline mitras.
// This allows mitra to stay online when briefly switching apps.
_stopHeartbeat();
}
Future<void> _onAppResumed(AppResumed event, Emitter<StatusState> emit) async {
// Resume heartbeat if mitra was online
if (state is StatusLoaded && (state as StatusLoaded).isOnline) {
_startHeartbeat();
}
add(StatusLoadRequested());
}
void _startHeartbeat() {
_stopHeartbeat();
_heartbeatTimer = Timer.periodic(const Duration(seconds: 15), (_) {
add(HeartbeatTick());
});
}
void _stopHeartbeat() {
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
}
@override
Future<void> close() {
_stopHeartbeat();
return super.close();
}
}

View File

@@ -0,0 +1,101 @@
import 'dart:async';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../api/api_client_provider.dart';
part 'status_notifier.g.dart';
// States
sealed class OnlineStatusData {
const OnlineStatusData();
}
class StatusInitialData extends OnlineStatusData {
const StatusInitialData();
}
class StatusLoadedData extends OnlineStatusData {
final bool isOnline;
const StatusLoadedData({required this.isOnline});
}
class StatusLoadingData extends OnlineStatusData {
const StatusLoadingData();
}
class StatusErrorData extends OnlineStatusData {
final String message;
const StatusErrorData(this.message);
}
@Riverpod(keepAlive: true)
class OnlineStatus extends _$OnlineStatus {
Timer? _heartbeatTimer;
bool _requirePing = true;
int _pingIntervalSeconds = 15;
@override
OnlineStatusData build() => const StatusInitialData();
Future<void> load() async {
try {
final response = await ref.read(apiClientProvider).get('/api/mitra/status');
final data = response['data'] as Map<String, dynamic>;
_requirePing = data['require_ping'] as bool? ?? true;
_pingIntervalSeconds = data['ping_interval_seconds'] as int? ?? 15;
state = StatusLoadedData(isOnline: data['is_online'] as bool);
} catch (e) {
state = const StatusLoadedData(isOnline: false);
}
}
Future<void> toggleOnline() async {
state = const StatusLoadingData();
try {
await ref.read(apiClientProvider).post('/api/mitra/status/online');
if (_requirePing) _startHeartbeat();
state = const StatusLoadedData(isOnline: true);
} catch (e) {
state = const StatusErrorData('Gagal mengubah status. Coba lagi.');
}
}
Future<void> toggleOffline() async {
state = const StatusLoadingData();
try {
await ref.read(apiClientProvider).post('/api/mitra/status/offline');
_stopHeartbeat();
state = const StatusLoadedData(isOnline: false);
} catch (e) {
state = const StatusErrorData('Gagal mengubah status. Coba lagi.');
}
}
void onAppPaused() {
if (_requirePing) _stopHeartbeat();
}
void onAppResumed() {
if (_requirePing && state is StatusLoadedData && (state as StatusLoadedData).isOnline) {
_startHeartbeat();
}
load();
}
void _startHeartbeat() {
_stopHeartbeat();
_heartbeatTimer = Timer.periodic(Duration(seconds: _pingIntervalSeconds), (_) {
_heartbeatTick();
});
}
void _stopHeartbeat() {
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
}
Future<void> _heartbeatTick() async {
try {
await ref.read(apiClientProvider).post('/api/mitra/status/heartbeat');
} catch (_) {}
}
}

View File

@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'status_notifier.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$onlineStatusHash() => r'6b42328eaba0f7934b0e3eaa54eb6b764f1c4e53';
/// See also [OnlineStatus].
@ProviderFor(OnlineStatus)
final onlineStatusProvider =
NotifierProvider<OnlineStatus, OnlineStatusData>.internal(
OnlineStatus.new,
name: r'onlineStatusProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$onlineStatusHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$OnlineStatus = Notifier<OnlineStatusData>;
// 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

@@ -1,16 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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_bloc.dart'; import '../../../core/auth/auth_notifier.dart';
class LoginScreen extends StatefulWidget { class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key}); const LoginScreen({super.key});
@override @override
State<LoginScreen> createState() => _LoginScreenState(); ConsumerState<LoginScreen> createState() => _LoginScreenState();
} }
class _LoginScreenState extends State<LoginScreen> { class _LoginScreenState extends ConsumerState<LoginScreen> {
final _phoneController = TextEditingController(); final _phoneController = TextEditingController();
@override @override
@@ -21,16 +21,20 @@ class _LoginScreenState extends State<LoginScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocListener<AuthBloc, AuthState>( final authState = ref.watch(mitraAuthProvider);
listener: (context, state) { final isLoading = authState is AsyncLoading;
if (state is AuthOtpSent) {
ref.listen(mitraAuthProvider, (prev, next) {
final data = next.valueOrNull;
if (data is MitraAuthOtpSentData) {
context.push('/otp', extra: _phoneController.text.trim()); context.push('/otp', extra: _phoneController.text.trim());
} }
if (state is AuthError) { if (next is AsyncError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message))); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
} }
}, });
child: Scaffold(
return Scaffold(
body: SafeArea( body: SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
@@ -54,23 +58,20 @@ class _LoginScreenState extends State<LoginScreen> {
keyboardType: TextInputType.phone, keyboardType: TextInputType.phone,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
BlocBuilder<AuthBloc, AuthState>( ElevatedButton(
builder: (context, state) => ElevatedButton( onPressed: isLoading ? null : () {
onPressed: state is AuthLoading ? null : () {
final phone = _phoneController.text.trim(); final phone = _phoneController.text.trim();
if (phone.isEmpty) return; if (phone.isEmpty) return;
context.read<AuthBloc>().add(PhoneOtpRequested(phone)); ref.read(mitraAuthProvider.notifier).requestOtp(phone);
}, },
child: state is AuthLoading child: isLoading
? const CircularProgressIndicator() ? const CircularProgressIndicator()
: const Text('Kirim OTP'), : const Text('Kirim OTP'),
), ),
),
], ],
), ),
), ),
), ),
),
); );
} }
} }

View File

@@ -1,20 +1,30 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/auth/auth_bloc.dart'; import '../../../core/auth/auth_notifier.dart';
class OtpScreen extends StatefulWidget { class OtpScreen extends ConsumerStatefulWidget {
final String phone; final String phone;
const OtpScreen({super.key, required this.phone}); const OtpScreen({super.key, required this.phone});
@override @override
State<OtpScreen> createState() => _OtpScreenState(); ConsumerState<OtpScreen> createState() => _OtpScreenState();
} }
class _OtpScreenState extends State<OtpScreen> { class _OtpScreenState extends ConsumerState<OtpScreen> {
final List<TextEditingController> _controllers = final List<TextEditingController> _controllers =
List.generate(6, (_) => TextEditingController()); List.generate(6, (_) => TextEditingController());
final List<FocusNode> _focusNodes = List.generate(6, (_) => FocusNode()); final List<FocusNode> _focusNodes = List.generate(6, (_) => FocusNode());
String? _verificationId;
@override
void initState() {
super.initState();
final data = ref.read(mitraAuthProvider).valueOrNull;
if (data is MitraAuthOtpSentData) {
_verificationId = data.verificationId;
}
}
@override @override
void dispose() { void dispose() {
@@ -50,28 +60,32 @@ class _OtpScreenState extends State<OtpScreen> {
void _submit() { void _submit() {
final otp = _otp; final otp = _otp;
if (otp.length != 6) return; if (otp.length != 6 || _verificationId == null) return;
final state = context.read<AuthBloc>().state; ref.read(mitraAuthProvider.notifier).verifyOtp(_verificationId!, otp);
final verificationId = state is AuthOtpSent ? state.verificationId : '';
context.read<AuthBloc>().add(OtpVerified(verificationId, otp));
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocListener<AuthBloc, AuthState>( final authState = ref.watch(mitraAuthProvider);
listener: (context, state) { final isLoading = authState is AsyncLoading;
if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar( // Update verification ID if state changes
SnackBar(content: Text(state.message)), final data = authState.valueOrNull;
); if (data is MitraAuthOtpSentData) {
// Clear fields on error _verificationId = data.verificationId;
}
ref.listen(mitraAuthProvider, (prev, next) {
if (next is AsyncError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
for (final c in _controllers) { for (final c in _controllers) {
c.clear(); c.clear();
} }
_focusNodes[0].requestFocus(); _focusNodes[0].requestFocus();
} }
}, });
child: Scaffold(
return Scaffold(
appBar: AppBar(title: const Text('Masukkan OTP')), appBar: AppBar(title: const Text('Masukkan OTP')),
body: Padding( body: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
@@ -116,18 +130,15 @@ class _OtpScreenState extends State<OtpScreen> {
}), }),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
BlocBuilder<AuthBloc, AuthState>( ElevatedButton(
builder: (context, state) => ElevatedButton( onPressed: isLoading ? null : _submit,
onPressed: state is AuthLoading ? null : _submit, child: isLoading
child: state is AuthLoading
? const CircularProgressIndicator() ? const CircularProgressIndicator()
: const Text('Verifikasi'), : const Text('Verifikasi'),
), ),
),
], ],
), ),
), ),
),
); );
} }
} }

View File

@@ -1,16 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/api/api_client.dart'; import '../../../core/api/api_client_provider.dart';
class ActiveSessionsScreen extends StatefulWidget { class ActiveSessionsScreen extends ConsumerStatefulWidget {
const ActiveSessionsScreen({super.key}); const ActiveSessionsScreen({super.key});
@override @override
State<ActiveSessionsScreen> createState() => _ActiveSessionsScreenState(); ConsumerState<ActiveSessionsScreen> createState() => _ActiveSessionsScreenState();
} }
class _ActiveSessionsScreenState extends State<ActiveSessionsScreen> { class _ActiveSessionsScreenState extends ConsumerState<ActiveSessionsScreen> {
List<Map<String, dynamic>> _sessions = []; List<Map<String, dynamic>> _sessions = [];
bool _loading = true; bool _loading = true;
@@ -22,7 +22,7 @@ class _ActiveSessionsScreenState extends State<ActiveSessionsScreen> {
Future<void> _loadSessions() async { Future<void> _loadSessions() async {
try { try {
final apiClient = context.read<ApiClient>(); final apiClient = ref.read(apiClientProvider);
final response = await apiClient.get('/api/mitra/chat-requests/sessions/active'); final response = await apiClient.get('/api/mitra/chat-requests/sessions/active');
setState(() { setState(() {
_sessions = List<Map<String, dynamic>>.from(response['data'] as List); _sessions = List<Map<String, dynamic>>.from(response['data'] as List);
@@ -48,7 +48,7 @@ class _ActiveSessionsScreenState extends State<ActiveSessionsScreen> {
if (confirmed == true) { if (confirmed == true) {
try { try {
final apiClient = context.read<ApiClient>(); final apiClient = ref.read(apiClientProvider);
await apiClient.post('/api/mitra/chat-requests/sessions/$sessionId/end'); await apiClient.post('/api/mitra/chat-requests/sessions/$sessionId/end');
_loadSessions(); _loadSessions();
} catch (_) { } catch (_) {

View File

@@ -1,16 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/api/api_client.dart'; import '../../../core/api/api_client_provider.dart';
class MitraChatHistoryScreen extends StatefulWidget { class MitraChatHistoryScreen extends ConsumerStatefulWidget {
const MitraChatHistoryScreen({super.key}); const MitraChatHistoryScreen({super.key});
@override @override
State<MitraChatHistoryScreen> createState() => _MitraChatHistoryScreenState(); ConsumerState<MitraChatHistoryScreen> createState() => _MitraChatHistoryScreenState();
} }
class _MitraChatHistoryScreenState extends State<MitraChatHistoryScreen> { class _MitraChatHistoryScreenState extends ConsumerState<MitraChatHistoryScreen> {
List<Map<String, dynamic>> _sessions = []; List<Map<String, dynamic>> _sessions = [];
bool _loading = true; bool _loading = true;
@@ -22,7 +22,7 @@ class _MitraChatHistoryScreenState extends State<MitraChatHistoryScreen> {
Future<void> _loadHistory() async { Future<void> _loadHistory() async {
try { try {
final api = context.read<ApiClient>(); final api = ref.read(apiClientProvider);
final response = await api.get('/api/mitra/chat-requests/history'); final response = await api.get('/api/mitra/chat-requests/history');
final items = (response['data']['items'] as List<dynamic>).cast<Map<String, dynamic>>(); final items = (response['data']['items'] as List<dynamic>).cast<Map<String, dynamic>>();
setState(() { setState(() {

View File

@@ -1,18 +1,18 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_client.dart'; import '../../../core/api/api_client_provider.dart';
import '../../../core/constants.dart'; import '../../../core/constants.dart';
class MitraChatTranscriptScreen extends StatefulWidget { class MitraChatTranscriptScreen extends ConsumerStatefulWidget {
final String sessionId; final String sessionId;
const MitraChatTranscriptScreen({super.key, required this.sessionId}); const MitraChatTranscriptScreen({super.key, required this.sessionId});
@override @override
State<MitraChatTranscriptScreen> createState() => _MitraChatTranscriptScreenState(); ConsumerState<MitraChatTranscriptScreen> createState() => _MitraChatTranscriptScreenState();
} }
class _MitraChatTranscriptScreenState extends State<MitraChatTranscriptScreen> { class _MitraChatTranscriptScreenState extends ConsumerState<MitraChatTranscriptScreen> {
List<Map<String, dynamic>> _messages = []; List<Map<String, dynamic>> _messages = [];
List<Map<String, dynamic>> _closures = []; List<Map<String, dynamic>> _closures = [];
bool _loading = true; bool _loading = true;
@@ -25,7 +25,7 @@ class _MitraChatTranscriptScreenState extends State<MitraChatTranscriptScreen> {
Future<void> _loadTranscript() async { Future<void> _loadTranscript() async {
try { try {
final api = context.read<ApiClient>(); final api = ref.read(apiClientProvider);
final response = await api.get('/api/shared/chat/${widget.sessionId}/transcript'); final response = await api.get('/api/shared/chat/${widget.sessionId}/transcript');
final data = response['data'] as Map<String, dynamic>; final data = response['data'] as Map<String, dynamic>;
setState(() { setState(() {

View File

@@ -1,22 +1,22 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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/mitra_chat_bloc.dart'; import '../../../core/chat/mitra_chat_notifier.dart';
import '../../../core/chat/extension_bloc.dart'; import '../../../core/chat/extension_notifier.dart';
import '../../../core/constants.dart'; import '../../../core/constants.dart';
class MitraChatScreen extends StatefulWidget { class MitraChatScreen extends ConsumerStatefulWidget {
final String sessionId; final String sessionId;
final String customerName; final String customerName;
const MitraChatScreen({super.key, required this.sessionId, required this.customerName}); const MitraChatScreen({super.key, required this.sessionId, required this.customerName});
@override @override
State<MitraChatScreen> createState() => _MitraChatScreenState(); ConsumerState<MitraChatScreen> createState() => _MitraChatScreenState();
} }
class _MitraChatScreenState extends State<MitraChatScreen> { class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
final _messageController = TextEditingController(); final _messageController = TextEditingController();
final _scrollController = ScrollController(); final _scrollController = ScrollController();
Timer? _typingThrottle; Timer? _typingThrottle;
@@ -24,12 +24,12 @@ class _MitraChatScreenState extends State<MitraChatScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
context.read<MitraChatBloc>().add(ConnectChat(widget.sessionId)); ref.read(mitraChatProvider.notifier).connect(widget.sessionId);
} }
@override @override
void dispose() { void dispose() {
context.read<MitraChatBloc>().add(DisconnectChat()); ref.read(mitraChatProvider.notifier).disconnect();
_messageController.dispose(); _messageController.dispose();
_scrollController.dispose(); _scrollController.dispose();
_typingThrottle?.cancel(); _typingThrottle?.cancel();
@@ -50,100 +50,89 @@ class _MitraChatScreenState extends State<MitraChatScreen> {
void _onTextChanged(String text) { void _onTextChanged(String text) {
if (_typingThrottle?.isActive ?? false) return; if (_typingThrottle?.isActive ?? false) return;
context.read<MitraChatBloc>().add(SendTyping()); ref.read(mitraChatProvider.notifier).sendTyping();
_typingThrottle = Timer(const Duration(seconds: 2), () {}); _typingThrottle = Timer(const Duration(seconds: 2), () {});
} }
void _sendMessage() { void _sendMessage() {
final text = _messageController.text.trim(); final text = _messageController.text.trim();
if (text.isEmpty) return; if (text.isEmpty) return;
context.read<MitraChatBloc>().add(SendMessage(text)); ref.read(mitraChatProvider.notifier).sendMessage(text);
_messageController.clear(); _messageController.clear();
_scrollToBottom(); _scrollToBottom();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MultiBlocListener( final chatState = ref.watch(mitraChatProvider);
listeners: [ final extState = ref.watch(mitraExtensionProvider);
BlocListener<MitraChatBloc, MitraChatState>(
listener: (context, state) { // Listen for extension complete → navigate home
if (state is ChatConnected) { ref.listen(mitraExtensionProvider, (prev, next) {
if (next is ExtensionCompleteData) {
context.go('/home');
}
});
// Listen for chat state changes
ref.listen(mitraChatProvider, (prev, next) {
if (next is MitraChatConnectedData) {
_scrollToBottom(); _scrollToBottom();
final unread = state.messages final unread = next.messages
.where((m) => m.senderType == UserType.customer && m.status != MessageStatus.read) .where((m) => m.senderType == UserType.customer && m.status != MessageStatus.read)
.map((m) => m.id) .map((m) => m.id)
.toList(); .toList();
if (unread.isNotEmpty) { if (unread.isNotEmpty) {
context.read<MitraChatBloc>().add(MarkMessagesRead(unread)); ref.read(mitraChatProvider.notifier).markRead(unread);
}
if (state.sessionClosing) {
// Trigger goodbye view
} }
} }
}, });
),
BlocListener<ExtensionBloc, ExtensionState>( return Scaffold(
listener: (context, state) {
if (state is ExtensionComplete) {
context.go('/home');
}
},
),
],
child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(widget.customerName), title: Text(widget.customerName),
actions: [ actions: [
BlocBuilder<MitraChatBloc, MitraChatState>( if (chatState is MitraChatConnectedData && chatState.remainingSeconds != null)
builder: (context, state) { Padding(
if (state is ChatConnected && state.remainingSeconds != null) {
return Padding(
padding: const EdgeInsets.only(right: 16), padding: const EdgeInsets.only(right: 16),
child: Center( child: Center(
child: Text( child: Text(
'${state.remainingSeconds}s', '${chatState.remainingSeconds}s',
style: TextStyle( style: TextStyle(
color: state.remainingSeconds! < 30 ? Colors.red : null, color: chatState.remainingSeconds! < 30 ? Colors.red : null,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
), ),
);
}
return const SizedBox.shrink();
},
), ),
], ],
), ),
body: BlocBuilder<MitraChatBloc, MitraChatState>( body: _buildBody(chatState, extState),
builder: (context, state) {
if (state is ChatConnecting) {
return const Center(child: CircularProgressIndicator());
}
if (state is ChatError) {
return Center(child: Text(state.message));
}
if (state is ChatConnected) {
return _buildChatBody(context, state);
}
return const SizedBox.shrink();
},
),
),
); );
} }
Widget _buildChatBody(BuildContext context, ChatConnected state) { Widget _buildBody(MitraChatData chatState, ExtensionData extState) {
if (chatState is MitraChatConnectingData) {
return const Center(child: CircularProgressIndicator());
}
if (chatState is MitraChatErrorData) {
return Center(child: Text(chatState.message));
}
if (chatState is MitraChatConnectedData) {
return _buildChatBody(chatState, extState);
}
return const SizedBox.shrink();
}
Widget _buildChatBody(MitraChatConnectedData state, ExtensionData extState) {
// Extension request from customer // Extension request from customer
if (state.extensionRequest != null) { if (state.extensionRequest != null) {
return _buildExtensionView(context, state.extensionRequest!); return _buildExtensionView(state.extensionRequest!, extState);
} }
// Goodbye view // Goodbye view
final extState = context.watch<ExtensionBloc>().state; if (state.sessionClosing || extState is ExtensionShowGoodbyeData || extState is ExtensionSubmittingData) {
if (state.sessionClosing || extState is ExtensionShowGoodbye || extState is ExtensionSubmitting) { return _buildGoodbyeView(extState);
return _buildGoodbyeView(context, extState);
} }
return Column( return Column(
@@ -173,7 +162,7 @@ class _MitraChatScreenState extends State<MitraChatScreen> {
); );
} }
Widget _buildMessageBubble(ChatMessage msg, bool isMe) { Widget _buildMessageBubble(MitraChatMessage msg, bool isMe) {
return Align( return Align(
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
child: Container( child: Container(
@@ -253,13 +242,10 @@ class _MitraChatScreenState extends State<MitraChatScreen> {
); );
} }
Widget _buildExtensionView(BuildContext context, Map<String, dynamic> request) { Widget _buildExtensionView(Map<String, dynamic> request, ExtensionData extState) {
final duration = request['duration_minutes'] as int?; final duration = request['duration_minutes'] as int?;
final extensionId = request['extension_id'] as String?; final extensionId = request['extension_id'] as String?;
final isResponding = extState is ExtensionRespondingData;
return BlocBuilder<ExtensionBloc, ExtensionState>(
builder: (context, extState) {
final isResponding = extState is ExtensionResponding;
return Center( return Center(
child: Padding( child: Padding(
@@ -281,21 +267,21 @@ class _MitraChatScreenState extends State<MitraChatScreen> {
children: [ children: [
ElevatedButton( ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.green), style: ElevatedButton.styleFrom(backgroundColor: Colors.green),
onPressed: extensionId == null ? null : () => context.read<ExtensionBloc>().add(RespondToExtension( onPressed: extensionId == null ? null : () => ref.read(mitraExtensionProvider.notifier).respond(
sessionId: widget.sessionId, widget.sessionId,
extensionId: extensionId, extensionId: extensionId,
accepted: true, accepted: true,
)), ),
child: const Text('Terima', style: TextStyle(color: Colors.white)), child: const Text('Terima', style: TextStyle(color: Colors.white)),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
ElevatedButton( ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.red), style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
onPressed: extensionId == null ? null : () => context.read<ExtensionBloc>().add(RespondToExtension( onPressed: extensionId == null ? null : () => ref.read(mitraExtensionProvider.notifier).respond(
sessionId: widget.sessionId, widget.sessionId,
extensionId: extensionId, extensionId: extensionId,
accepted: false, accepted: false,
)), ),
child: const Text('Tolak', style: TextStyle(color: Colors.white)), child: const Text('Tolak', style: TextStyle(color: Colors.white)),
), ),
], ],
@@ -304,11 +290,9 @@ class _MitraChatScreenState extends State<MitraChatScreen> {
), ),
), ),
); );
},
);
} }
Widget _buildGoodbyeView(BuildContext context, ExtensionState extState) { Widget _buildGoodbyeView(ExtensionData extState) {
final controller = TextEditingController(); final controller = TextEditingController();
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.all(32), padding: const EdgeInsets.all(32),
@@ -331,17 +315,17 @@ class _MitraChatScreenState extends State<MitraChatScreen> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton( ElevatedButton(
onPressed: extState is ExtensionSubmitting onPressed: extState is ExtensionSubmittingData
? null ? null
: () { : () {
final text = controller.text.trim(); final text = controller.text.trim();
if (text.isNotEmpty) { if (text.isNotEmpty) {
context.read<ExtensionBloc>().add( ref.read(mitraExtensionProvider.notifier).submitGoodbye(
SubmitGoodbye(sessionId: widget.sessionId, message: text), widget.sessionId, text,
); );
} }
}, },
child: extState is ExtensionSubmitting child: extState is ExtensionSubmittingData
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Kirim & Selesai'), : const Text('Kirim & Selesai'),
), ),

View File

@@ -1,55 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/chat/chat_request_bloc.dart';
class IncomingRequestSheet extends StatelessWidget {
final String sessionId;
const IncomingRequestSheet({super.key, required this.sessionId});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.chat, size: 48, color: Colors.blue),
const SizedBox(height: 16),
const Text(
'Ada permintaan chat baru!',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
'Seorang customer ingin curhat denganmu.',
style: TextStyle(fontSize: 14, color: Colors.grey),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () {
context.read<ChatRequestBloc>().add(DeclineRequest(sessionId));
Navigator.of(context).pop();
},
child: const Text('Tolak'),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton(
onPressed: () {
context.read<ChatRequestBloc>().add(AcceptRequest(sessionId));
Navigator.of(context).pop();
},
child: const Text('Terima'),
),
),
],
),
],
),
);
}
}

View File

@@ -1,93 +1,38 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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_bloc.dart'; import '../../core/auth/auth_notifier.dart';
import '../../core/status/status_bloc.dart'; import '../../core/status/status_notifier.dart';
import '../../core/chat/chat_request_bloc.dart'; import '../../core/chat/chat_request_notifier.dart';
import '../chat/widgets/incoming_request_sheet.dart'; import '../../core/chat/unread_notifier.dart';
class HomeScreen extends StatefulWidget { class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key}); const HomeScreen({super.key});
@override @override
State<HomeScreen> createState() => _HomeScreenState(); Widget build(BuildContext context, WidgetRef ref) {
} final authState = ref.watch(mitraAuthProvider);
final authData = authState.valueOrNull;
class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver { final displayName = authData is MitraAuthAuthenticatedData
@override ? authData.profile['display_name'] as String
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
// Check if there's a pending request that was missed while backgrounded
final chatState = context.read<ChatRequestBloc>().state;
if (chatState is ChatRequestIncoming) {
_showIncomingRequest(chatState.sessionId);
}
}
}
void _showIncomingRequest(String sessionId) {
showModalBottomSheet(
context: context,
isDismissible: false,
builder: (_) => BlocProvider.value(
value: context.read<ChatRequestBloc>(),
child: IncomingRequestSheet(sessionId: sessionId),
),
);
}
@override
Widget build(BuildContext context) {
return MultiBlocListener(
listeners: [
BlocListener<StatusBloc, StatusState>(
listener: (context, state) {
if (state is StatusLoaded && state.isOnline) {
context.read<ChatRequestBloc>().add(StartListening());
} else if (state is StatusLoaded && !state.isOnline) {
context.read<ChatRequestBloc>().add(StopListening());
}
},
),
BlocListener<ChatRequestBloc, ChatRequestState>(
listener: (context, state) {
if (state is ChatRequestIncoming) {
_showIncomingRequest(state.sessionId);
} else if (state is ChatRequestAccepted) {
final session = state.session;
final sessionId = session['session_id'] as String? ?? session['id'] as String;
context.push('/chat/session/$sessionId', extra: {
'customerName': session['customer_display_name'] as String? ?? 'Customer',
});
}
},
),
],
child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, authState) {
final displayName = authState is AuthAuthenticated
? authState.profile['display_name'] as String
: ''; : '';
// Listen for status changes to start/stop chat request listening
ref.listen(onlineStatusProvider, (prev, next) {
if (next is StatusLoadedData && next.isOnline) {
ref.read(chatRequestProvider.notifier).startListening();
} else if (next is StatusLoadedData && !next.isOnline) {
ref.read(chatRequestProvider.notifier).stopListening();
}
});
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Halo Bestie Mitra'), title: const Text('Halo Bestie Mitra'),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.logout), icon: const Icon(Icons.logout),
onPressed: () => context.read<AuthBloc>().add(LogoutRequested()), onPressed: () => ref.read(mitraAuthProvider.notifier).logout(),
), ),
], ],
), ),
@@ -97,26 +42,24 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
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),
_StatusToggle(), const _StatusToggle(),
const SizedBox(height: 16), const SizedBox(height: 16),
_ActiveSessionsButton(), const _ActiveSessionsButton(),
], ],
), ),
), ),
); );
},
),
);
} }
} }
class _StatusToggle extends StatelessWidget { class _StatusToggle extends ConsumerWidget {
const _StatusToggle();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
return BlocBuilder<StatusBloc, StatusState>( final statusState = ref.watch(onlineStatusProvider);
builder: (context, state) { final isOnline = statusState is StatusLoadedData && statusState.isOnline;
final isOnline = state is StatusLoaded && state.isOnline; final isLoading = statusState is StatusLoadingData;
final isLoading = state is StatusLoading;
return Card( return Card(
child: Padding( child: Padding(
@@ -153,11 +96,11 @@ class _StatusToggle extends StatelessWidget {
value: isOnline, value: isOnline,
activeColor: Colors.green, activeColor: Colors.green,
onChanged: (_) { onChanged: (_) {
final bloc = context.read<StatusBloc>(); final notifier = ref.read(onlineStatusProvider.notifier);
if (isOnline) { if (isOnline) {
bloc.add(ToggleOffline()); notifier.toggleOffline();
} else { } else {
bloc.add(ToggleOnline()); notifier.toggleOnline();
} }
}, },
), ),
@@ -165,19 +108,26 @@ class _StatusToggle extends StatelessWidget {
), ),
), ),
); );
},
);
} }
} }
class _ActiveSessionsButton extends StatelessWidget { class _ActiveSessionsButton extends ConsumerWidget {
const _ActiveSessionsButton();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final unreadCounts = ref.watch(unreadSessionsProvider);
final totalUnread = unreadCounts.values.fold(0, (a, b) => a + b);
return Column( return Column(
children: [ children: [
Card( Card(
child: ListTile( child: ListTile(
leading: const Icon(Icons.chat_bubble_outline), leading: Badge(
isLabelVisible: totalUnread > 0,
label: Text('$totalUnread'),
child: const Icon(Icons.chat_bubble_outline),
),
title: const Text('Sesi Aktif'), title: const Text('Sesi Aktif'),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () => context.push('/sessions'), onTap: () => context.push('/sessions'),

View File

@@ -1,14 +1,12 @@
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'core/api/api_client_provider.dart';
import 'core/api/api_client.dart'; import 'core/auth/auth_notifier.dart';
import 'core/auth/auth_bloc.dart'; import 'core/status/status_notifier.dart';
import 'core/status/status_bloc.dart'; import 'core/chat/chat_request_notifier.dart';
import 'core/chat/chat_request_bloc.dart'; import 'core/chat/widgets/chat_request_overlay.dart';
import 'core/chat/mitra_chat_bloc.dart';
import 'core/chat/extension_bloc.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';
@@ -20,87 +18,76 @@ void main() async {
final messaging = FirebaseMessaging.instance; final messaging = FirebaseMessaging.instance;
await messaging.requestPermission(); await messaging.requestPermission();
runApp(const App()); runApp(const ProviderScope(child: App()));
} }
class App extends StatefulWidget { class App extends ConsumerStatefulWidget {
const App({super.key}); const App({super.key});
@override @override
State<App> createState() => _AppState(); ConsumerState<App> createState() => _AppState();
} }
class _AppState extends State<App> with WidgetsBindingObserver { class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
late final ApiClient _apiClient; bool _fcmRegistered = false;
late final AuthBloc _authBloc;
late final GoRouter _router;
late final StatusBloc _statusBloc;
late final ChatRequestBloc _chatRequestBloc;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
_apiClient = ApiClient();
_authBloc = AuthBloc(apiClient: _apiClient)..add(AppStarted());
_router = buildRouter(_authBloc);
NotificationService.initialize(_router);
_statusBloc = StatusBloc(apiClient: _apiClient);
_chatRequestBloc = ChatRequestBloc(apiClient: _apiClient);
_registerFcmToken();
}
Future<void> _registerFcmToken() {
return _authBloc.stream.where((s) => s is AuthAuthenticated).first.then((_) async {
try {
final token = await FirebaseMessaging.instance.getToken();
if (token != null) {
await _apiClient.post('/api/shared/device-token', data: {'token': token});
}
} catch (_) {}
});
} }
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
_authBloc.close();
_router.dispose();
_statusBloc.close();
_chatRequestBloc.close();
super.dispose(); super.dispose();
} }
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused || state == AppLifecycleState.detached) { if (state == AppLifecycleState.paused || state == AppLifecycleState.detached) {
_statusBloc.add(AppPaused()); ref.read(onlineStatusProvider.notifier).onAppPaused();
} else if (state == AppLifecycleState.resumed) { } else if (state == AppLifecycleState.resumed) {
_statusBloc.add(AppResumed()); ref.read(onlineStatusProvider.notifier).onAppResumed();
} }
} }
void _registerFcmToken() {
if (_fcmRegistered) return;
_fcmRegistered = true;
Future(() async {
try {
final token = await FirebaseMessaging.instance.getToken();
if (token != null) {
await ref.read(apiClientProvider).post('/api/shared/device-token', data: {'token': token});
}
} catch (_) {
_fcmRegistered = false;
}
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MultiBlocProvider( // Listen for auth changes to load status and register FCM
providers: [ ref.listen(mitraAuthProvider, (prev, next) {
BlocProvider.value(value: _authBloc), final data = next.valueOrNull;
BlocProvider.value(value: _statusBloc), if (data is MitraAuthAuthenticatedData) {
BlocProvider.value(value: _chatRequestBloc), ref.read(onlineStatusProvider.notifier).load();
BlocProvider(create: (_) => MitraChatBloc(apiClient: _apiClient)), _registerFcmToken();
BlocProvider(create: (_) => ExtensionBloc(apiClient: _apiClient)),
RepositoryProvider.value(value: _apiClient),
],
child: BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthAuthenticated) {
_statusBloc.add(StatusLoadRequested());
} }
}, });
final router = ref.watch(routerProvider);
NotificationService.initialize(router);
NotificationService.onChatRequestTapped = (sessionId) {
ref.read(chatRequestProvider.notifier).setIncomingFromNotification(sessionId);
};
return ChatRequestOverlay(
child: MaterialApp.router( child: MaterialApp.router(
title: 'Halo Bestie Mitra', title: 'Halo Bestie Mitra',
routerConfig: _router, routerConfig: router,
),
), ),
); );
} }

View File

@@ -1,7 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.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_bloc.dart'; import 'core/auth/auth_notifier.dart';
import 'features/splash/splash_screen.dart'; import 'features/splash/splash_screen.dart';
import 'features/auth/screens/login_screen.dart'; import 'features/auth/screens/login_screen.dart';
import 'features/auth/screens/otp_screen.dart'; import 'features/auth/screens/otp_screen.dart';
@@ -11,34 +11,43 @@ import 'features/chat/screens/mitra_chat_screen.dart';
import 'features/chat/screens/chat_history_screen.dart'; import 'features/chat/screens/chat_history_screen.dart';
import 'features/chat/screens/chat_transcript_screen.dart'; import 'features/chat/screens/chat_transcript_screen.dart';
class _BlocRefreshNotifier extends ChangeNotifier { class RouterNotifier extends ChangeNotifier {
late final StreamSubscription _subscription; final Ref _ref;
_BlocRefreshNotifier(AuthBloc bloc) { RouterNotifier(this._ref) {
_subscription = bloc.stream.listen((_) => notifyListeners()); _ref.listen(mitraAuthProvider, (_, __) => notifyListeners());
}
@override
void dispose() {
_subscription.cancel();
super.dispose();
} }
} }
GoRouter buildRouter(AuthBloc authBloc) { final routerProvider = Provider<GoRouter>((ref) => buildRouter(ref));
GoRouter buildRouter(Ref ref) {
final notifier = RouterNotifier(ref);
return GoRouter( return GoRouter(
initialLocation: '/splash', initialLocation: '/splash',
refreshListenable: _BlocRefreshNotifier(authBloc), refreshListenable: notifier,
redirect: (context, state) { redirect: (context, state) {
final authState = authBloc.state; final authState = ref.read(mitraAuthProvider);
final isSplash = state.matchedLocation == '/splash'; final isSplash = state.matchedLocation == '/splash';
final isAuthRoute = state.matchedLocation.startsWith('/login') || final isAuthRoute = state.matchedLocation.startsWith('/login') ||
state.matchedLocation.startsWith('/otp'); state.matchedLocation.startsWith('/otp');
// Show splash while loading // Show splash only during initial load — don't redirect away from auth routes
if (authState is AuthLoading) return isSplash ? null : '/splash'; if (authState is AsyncLoading) {
if (isSplash || isAuthRoute) return null;
return '/splash';
}
if (authState is AuthAuthenticated) { final data = authState.valueOrNull;
if (data == null) {
// Error state — show login
if (!isAuthRoute && !isSplash) return '/login';
if (isSplash) return '/login';
return null;
}
if (data is MitraAuthAuthenticatedData) {
return (isSplash || isAuthRoute) ? '/home' : null; return (isSplash || isAuthRoute) ? '/home' : null;
} }
if (!isAuthRoute && !isSplash) return '/login'; if (!isAuthRoute && !isSplash) return '/login';

View File

@@ -1,6 +1,14 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
url: "https://pub.dev"
source: hosted
version: "85.0.0"
_flutterfire_internals: _flutterfire_internals:
dependency: transitive dependency: transitive
description: description:
@@ -9,6 +17,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.35" version: "1.3.35"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: f4ad0fea5f102201015c9aae9d93bc02f75dd9491529a8c21f88d17a8523d44c
url: "https://pub.dev"
source: hosted
version: "7.6.0"
analyzer_plugin:
dependency: transitive
description:
name: analyzer_plugin
sha256: a5ab7590c27b779f3d4de67f31c4109dbe13dd7339f86461a6f2a8ab2594d8ce
url: "https://pub.dev"
source: hosted
version: "0.13.4"
args: args:
dependency: transitive dependency: transitive
description: description:
@@ -25,14 +49,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.13.1" version: "2.13.1"
bloc:
dependency: transitive
description:
name: bloc
sha256: "106842ad6569f0b60297619e9e0b1885c2fb9bf84812935490e6c5275777804e"
url: "https://pub.dev"
source: hosted
version: "8.1.4"
boolean_selector: boolean_selector:
dependency: transitive dependency: transitive
description: description:
@@ -41,6 +57,70 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.2"
build:
dependency: transitive
description:
name: build
sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0
url: "https://pub.dev"
source: hosted
version: "2.4.2"
build_config:
dependency: transitive
description:
name: build_config
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
build_daemon:
dependency: transitive
description:
name: build_daemon
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
url: "https://pub.dev"
source: hosted
version: "4.1.1"
build_resolvers:
dependency: transitive
description:
name: build_resolvers
sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0
url: "https://pub.dev"
source: hosted
version: "2.4.4"
build_runner:
dependency: "direct dev"
description:
name: build_runner
sha256: "74691599a5bc750dc96a6b4bfd48f7d9d66453eab04c7f4063134800d6a5c573"
url: "https://pub.dev"
source: hosted
version: "2.4.14"
build_runner_core:
dependency: transitive
description:
name: build_runner_core
sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021"
url: "https://pub.dev"
source: hosted
version: "8.0.0"
built_collection:
dependency: transitive
description:
name: built_collection
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
url: "https://pub.dev"
source: hosted
version: "5.1.1"
built_value:
dependency: transitive
description:
name: built_value
sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af"
url: "https://pub.dev"
source: hosted
version: "8.12.5"
characters: characters:
dependency: transitive dependency: transitive
description: description:
@@ -49,6 +129,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
ci:
dependency: transitive
description:
name: ci
sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13"
url: "https://pub.dev"
source: hosted
version: "0.1.0"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock: clock:
dependency: transitive dependency: transitive
description: description:
@@ -57,6 +161,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" version: "1.1.2"
code_builder:
dependency: transitive
description:
name: code_builder
sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d"
url: "https://pub.dev"
source: hosted
version: "4.11.1"
collection: collection:
dependency: transitive dependency: transitive
description: description:
@@ -65,6 +177,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
crypto: crypto:
dependency: transitive dependency: transitive
description: description:
@@ -73,6 +193,46 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.7" version: "3.0.7"
custom_lint:
dependency: "direct dev"
description:
name: custom_lint
sha256: "9656925637516c5cf0f5da018b33df94025af2088fe09c8ae2ca54c53f2d9a84"
url: "https://pub.dev"
source: hosted
version: "0.7.6"
custom_lint_builder:
dependency: transitive
description:
name: custom_lint_builder
sha256: "6cdc8e87e51baaaba9c43e283ed8d28e59a0c4732279df62f66f7b5984655414"
url: "https://pub.dev"
source: hosted
version: "0.7.6"
custom_lint_core:
dependency: transitive
description:
name: custom_lint_core
sha256: "31110af3dde9d29fb10828ca33f1dce24d2798477b167675543ce3d208dee8be"
url: "https://pub.dev"
source: hosted
version: "0.7.5"
custom_lint_visitor:
dependency: transitive
description:
name: custom_lint_visitor
sha256: "4a86a0d8415a91fbb8298d6ef03e9034dc8e323a599ddc4120a0e36c433983a2"
url: "https://pub.dev"
source: hosted
version: "1.0.0+7.7.0"
dart_style:
dependency: transitive
description:
name: dart_style
sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
dbus: dbus:
dependency: transitive dependency: transitive
description: description:
@@ -97,14 +257,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.2"
equatable:
dependency: "direct main"
description:
name: equatable
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
url: "https://pub.dev"
source: hosted
version: "2.0.8"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@@ -121,6 +273,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
firebase_auth: firebase_auth:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -193,19 +353,27 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.8.7" version: "3.8.7"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_bloc: flutter_hooks:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_bloc name: flutter_hooks
sha256: b594505eac31a0518bdcb4b5b79573b8d9117b193cc80cc12e17d639b10aa27a sha256: cde36b12f7188c85286fba9b38cc5a902e7279f36dd676967106c041dc9dde70
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.1.6" version: "0.20.5"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -246,6 +414,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.0" version: "3.0.0"
flutter_riverpod:
dependency: "direct main"
description:
name: flutter_riverpod
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -256,6 +432,30 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
freezed_annotation:
dependency: transitive
description:
name: freezed_annotation
sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
frontend_server_client:
dependency: transitive
description:
name: frontend_server_client
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
url: "https://pub.dev"
source: hosted
version: "4.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
go_router: go_router:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -264,6 +464,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "13.2.5" version: "13.2.5"
graphs:
dependency: transitive
description:
name: graphs
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
hooks_riverpod:
dependency: "direct main"
description:
name: hooks_riverpod
sha256: "70bba33cfc5670c84b796e6929c54b8bc5be7d0fe15bb28c2560500b9ad06966"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
hotreloader:
dependency: transitive
description:
name: hotreloader
sha256: "66871df468fc24eee81f1a0a7cb98acc104716f9b7376d355437b48d633c4ebf"
url: "https://pub.dev"
source: hosted
version: "4.4.0"
http: http:
dependency: transitive dependency: transitive
description: description:
@@ -272,6 +496,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.6.0" version: "1.6.0"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
url: "https://pub.dev"
source: hosted
version: "3.2.2"
http_parser: http_parser:
dependency: transitive dependency: transitive
description: description:
@@ -280,6 +512,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
io:
dependency: transitive
description:
name: io
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
url: "https://pub.dev"
source: hosted
version: "1.0.5"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
url: "https://pub.dev"
source: hosted
version: "4.11.0"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@@ -352,14 +608,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.0" version: "2.0.0"
nested: package_config:
dependency: transitive dependency: transitive
description: description:
name: nested name: package_config
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" version: "2.2.0"
path: path:
dependency: transitive dependency: transitive
description: description:
@@ -384,19 +640,107 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
provider: pool:
dependency: transitive dependency: transitive
description: description:
name: provider name: pool
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.5+1" version: "1.5.2"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
pubspec_parse:
dependency: transitive
description:
name: pubspec_parse
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
riverpod:
dependency: transitive
description:
name: riverpod
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
riverpod_analyzer_utils:
dependency: transitive
description:
name: riverpod_analyzer_utils
sha256: "03a17170088c63aab6c54c44456f5ab78876a1ddb6032ffde1662ddab4959611"
url: "https://pub.dev"
source: hosted
version: "0.5.10"
riverpod_annotation:
dependency: "direct main"
description:
name: riverpod_annotation
sha256: e14b0bf45b71326654e2705d462f21b958f987087be850afd60578fcd502d1b8
url: "https://pub.dev"
source: hosted
version: "2.6.1"
riverpod_generator:
dependency: "direct dev"
description:
name: riverpod_generator
sha256: "44a0992d54473eb199ede00e2260bd3c262a86560e3c6f6374503d86d0580e36"
url: "https://pub.dev"
source: hosted
version: "2.6.5"
riverpod_lint:
dependency: "direct dev"
description:
name: riverpod_lint
sha256: "89a52b7334210dbff8605c3edf26cfe69b15062beed5cbfeff2c3812c33c9e35"
url: "https://pub.dev"
source: hosted
version: "2.6.5"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
shelf:
dependency: transitive
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.dev"
source: hosted
version: "1.4.2"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67
url: "https://pub.dev"
source: hosted
version: "2.0.1"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
source_gen:
dependency: transitive
description:
name: source_gen
sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
source_span: source_span:
dependency: transitive dependency: transitive
description: description:
@@ -413,6 +757,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.12.1" version: "1.12.1"
state_notifier:
dependency: transitive
description:
name: state_notifier
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
url: "https://pub.dev"
source: hosted
version: "1.0.0"
stream_channel: stream_channel:
dependency: transitive dependency: transitive
description: description:
@@ -421,6 +773,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.4" version: "2.1.4"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
url: "https://pub.dev"
source: hosted
version: "2.1.1"
string_scanner: string_scanner:
dependency: transitive dependency: transitive
description: description:
@@ -453,6 +813,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.11.0" version: "0.11.0"
timing:
dependency: transitive
description:
name: timing
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -461,6 +829,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
uuid:
dependency: transitive
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@@ -477,6 +853,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.0.2" version: "15.0.2"
watcher:
dependency: transitive
description:
name: watcher
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
web: web:
dependency: transitive dependency: transitive
description: description:
@@ -509,6 +893,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.6.1" version: "6.6.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks: sdks:
dart: ">=3.10.0 <4.0.0" dart: ">=3.10.0 <4.0.0"
flutter: ">=3.38.1" flutter: ">=3.38.1"

View File

@@ -21,8 +21,10 @@ dependencies:
web_socket_channel: ^2.4.5 web_socket_channel: ^2.4.5
# State management # State management
flutter_bloc: ^8.1.5 flutter_riverpod: ^2.6.1
equatable: ^2.0.5 hooks_riverpod: ^2.6.1
riverpod_annotation: ^2.6.1
flutter_hooks: ^0.20.5
# Navigation # Navigation
go_router: ^13.2.1 go_router: ^13.2.1
@@ -32,6 +34,10 @@ dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_lints: ^3.0.0 flutter_lints: ^3.0.0
riverpod_generator: ^2.6.2
build_runner: ^2.4.13
custom_lint: ^0.7.0
riverpod_lint: ^2.6.2
flutter: flutter:
uses-material-design: true uses-material-design: true

View File

@@ -0,0 +1,735 @@
# Phase 3.1 Implementation Plan: Riverpod Migration & FCM Fallback
## Summary of Clarified Requirements
| Topic | Decision |
|---|---|
| Work stream order | Riverpod migration first, then FCM fallback |
| Riverpod style | Annotation-based (`@riverpod`) with code generation |
| Mitra AuthBloc bug | Fix stuck-loading: emit `AuthInitial` when `currentUser` is null |
| Mitra ping config | Control Center toggle: "require mitra ping" (boolean) + ping interval (seconds) |
| Non-ping mode | Mitra stays online without heartbeat; no auto-offline timeout; QC handles quality |
| Pairing FCM fallback | When WebSocket to mitra is closed, send pairing request via FCM push |
| Mitra pairing confirmation | Must manually accept (no auto-accept via FCM) |
| Unread badges (mitra) | Badge on "active sessions" button on home; badge on each session in list |
| Unread badges (customer) | Badge on `_ActiveSessionCard` widget on home screen |
| Badge clearing | Badges clear when messages are read |
| Closure FCM fallback | Backend sends closure signal to both parties; uses FCM if WebSocket is down |
| Closure screen | Must show closure screen on app (no silent updates) |
| Control center | New config: "require mitra ping" toggle + ping interval input |
| Backend changes for Riverpod | None — migration is Flutter-only |
---
## Work Stream 1: Riverpod Migration (Flutter-only)
### 1.1 Dependency Changes
#### Both `client_app/pubspec.yaml` and `mitra_app/pubspec.yaml`
**Add to dependencies:**
| Package | Purpose |
|---|---|
| `flutter_riverpod` | Core Riverpod provider framework |
| `hooks_riverpod` | Riverpod + flutter_hooks integration (`HookConsumerWidget`) |
| `riverpod_annotation` | `@riverpod` / `@Riverpod(keepAlive: true)` annotations |
| `flutter_hooks` | Hook utilities (`useTextEditingController`, `useEffect`, etc.) |
**Add to dev_dependencies:**
| Package | Purpose |
|---|---|
| `riverpod_generator` | Code generation for `@riverpod` providers |
| `build_runner` | Runs code generation (`dart run build_runner build`) |
| `custom_lint` | Required for riverpod_lint rules |
| `riverpod_lint` | Lint rules for Riverpod best practices |
**Remove after all Blocs are migrated:**
| Package | |
|---|---|
| `flutter_bloc` | Replaced by Riverpod |
| `equatable` | No longer needed — Riverpod state is compared by value |
### 1.2 App Root Changes
#### `client_app/lib/main.dart`
**Current:** `MultiBlocProvider` wraps `MaterialApp.router` with `AuthBloc`, `PairingBloc`, `ChatBloc`, `SessionClosureBloc`.
**Target:**
1. Wrap `runApp` call with `ProviderScope`: `runApp(const ProviderScope(child: App()))`
2. Convert `App` from `StatefulWidget` to `HookConsumerWidget`
3. Remove `MultiBlocProvider` wrapper — providers are globally available via `ref`
4. Replace `_authBloc.stream.listen(...)` for FCM token registration with `ref.listen(authProvider, ...)`
5. Move `ApiClient` into a Riverpod provider: `@Riverpod(keepAlive: true) ApiClient apiClient(Ref ref) => ApiClient()`
6. Router creation: Use `ref.watch(authProvider)` to get auth state for redirect logic; replace `_BlocRefreshNotifier` with a Riverpod-based `ChangeNotifier` or use `ref.listen` on the auth provider
**Files changed:**
- `client_app/lib/main.dart`
- `client_app/lib/router.dart` (remove `_BlocRefreshNotifier`, accept `WidgetRef` or use a provider for router)
#### `mitra_app/lib/main.dart`
**Current:** `MultiBlocProvider` wraps app with `AuthBloc`, `StatusBloc`, `ChatRequestBloc`, `MitraChatBloc`, `ExtensionBloc`. Also has `WidgetsBindingObserver` for lifecycle and `BlocListener<AuthBloc>` to trigger `StatusLoadRequested`.
**Target:**
1. Wrap with `ProviderScope`
2. Convert `App` to `HookConsumerWidget`
3. Remove `MultiBlocProvider` — use `ref.watch()` / `ref.listen()` instead
4. Move lifecycle observer to a dedicated provider or custom hook (`useAppLifecycleState`)
5. Replace `BlocListener<AuthBloc>` triggering status load with `ref.listen(authProvider, ...)` inside a provider or widget
**Files changed:**
- `mitra_app/lib/main.dart`
- `mitra_app/lib/router.dart`
### 1.3 Migration Per Bloc — Client App
Migration order: AuthBloc (simplest, foundational) → ChatOpeningBloc (simple, no side effects) → SessionClosureBloc (simple API calls) → PairingBloc (WebSocket + timers) → ChatBloc (most complex, WebSocket + message state).
#### 1.3.1 `client_app` AuthBloc → AuthNotifier
**Source file:** `client_app/lib/core/auth/auth_bloc.dart`
**Current:** BLoC with 8 events (AppStarted, AnonymousLoginRequested, GoogleLoginRequested, AppleLoginRequested, PhoneOtpRequested, OtpVerified, LinkAccountRequested, LogoutRequested) and 7 state classes.
**Target:** `@Riverpod(keepAlive: true)` AsyncNotifier.
```dart
@Riverpod(keepAlive: true)
class Auth extends _$Auth {
// State type: AsyncValue<AuthData> (sealed class)
// Build method: checks Firebase currentUser, calls _verifyAndReturn or returns AuthData.initial
// Methods: loginAnonymous(displayName), loginGoogle(), loginApple(),
// requestOtp(phone), verifyOtp(verificationId, smsCode),
// linkAccount(), logout()
}
```
**State design:** Replace 7 separate state classes with a single sealed class:
```dart
sealed class AuthData {
const AuthData();
}
class AuthDataInitial extends AuthData { const AuthDataInitial(); }
class AuthDataAuthenticated extends AuthData { final Map<String, dynamic> profile; ... }
class AuthDataAnonymous extends AuthData { final String customerId; final String displayName; ... }
class AuthDataOtpSent extends AuthData { final String verificationId; ... }
class AuthDataForceRegister extends AuthData { final String customerId; final String displayName; ... }
```
The `AsyncValue` wrapper handles loading/error automatically:
- `state = const AsyncLoading()` replaces `emit(AuthLoading())`
- `state = AsyncData(AuthDataAuthenticated(...))` replaces `emit(AuthAuthenticated(...))`
- `state = AsyncError(...)` replaces `emit(AuthError(...))`
**Widget changes:**
- `BlocBuilder<AuthBloc, AuthState>``ConsumerWidget` + `ref.watch(authProvider)`
- `BlocListener<AuthBloc, AuthState>``ref.listen(authProvider, ...)`
- `context.read<AuthBloc>().add(LogoutRequested())``ref.read(authProvider.notifier).logout()`
**Files affected:**
- `client_app/lib/core/auth/auth_bloc.dart``auth_notifier.dart` + `.g.dart`
- `client_app/lib/features/auth/screens/welcome_screen.dart`
- `client_app/lib/features/auth/screens/display_name_screen.dart`
- `client_app/lib/features/auth/screens/register_screen.dart`
- `client_app/lib/features/auth/screens/otp_screen.dart`
- `client_app/lib/features/auth/screens/force_register_screen.dart`
- `client_app/lib/features/home/home_screen.dart`
- `client_app/lib/router.dart`
- `client_app/lib/main.dart`
#### 1.3.2 `client_app` ChatOpeningBloc → ChatOpeningNotifier
**Source file:** `client_app/lib/core/chat/chat_opening_bloc.dart`
**Current:** Simple BLoC with one event (`LoadPricing`) and 4 states. Fetches pricing tiers from API.
**Target:** `@riverpod` FutureProvider (auto-dispose, since pricing is ephemeral):
```dart
@riverpod
Future<PricingData> chatPricing(Ref ref) async {
final apiClient = ref.read(apiClientProvider);
final response = await apiClient.get('/api/client/chat/pricing');
// parse and return PricingData
}
```
**PriceTier model** stays the same; move out of bloc file into a shared models file.
**Files affected:**
- `client_app/lib/core/chat/chat_opening_bloc.dart``chat_opening_notifier.dart` + `.g.dart`
- `client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart`
#### 1.3.3 `client_app` SessionClosureBloc → SessionClosureNotifier
**Source file:** `client_app/lib/core/chat/session_closure_bloc.dart`
**Current:** BLoC with 4 events (RequestExtension, DeclineExtension, ResetClosure, SubmitGoodbye) and 6 states.
**Target:** `@riverpod` Notifier (synchronous state, async methods).
```dart
@riverpod
class SessionClosure extends _$SessionClosure {
// State: SessionClosureData sealed class
// (Initial, ExtendingWaitingMitra, ShowGoodbye, Submitting, Complete, Error)
// build(): returns SessionClosureData.initial
// requestExtension(sessionId, durationMinutes, price)
// declineExtension()
// reset()
// submitGoodbye(sessionId, message)
}
```
**Files affected:**
- `client_app/lib/core/chat/session_closure_bloc.dart``session_closure_notifier.dart` + `.g.dart`
- `client_app/lib/features/chat/screens/chat_screen.dart`
#### 1.3.4 `client_app` PairingBloc → PairingNotifier
**Source file:** `client_app/lib/core/pairing/pairing_bloc.dart`
**Current:** BLoC with WebSocket connection, 60s timeout timer, pairing request flow. 6 public events + 3 private events, 7 state classes.
**Target:** `@Riverpod(keepAlive: true)` Notifier with internal WebSocket and timer management.
```dart
@Riverpod(keepAlive: true)
class Pairing extends _$Pairing {
WebSocketChannel? _channel;
Timer? _timeoutTimer;
StreamSubscription? _wsSubscription;
// State: PairingData sealed class
// (Initial, Searching, BestieFound, Active, NoBestie, Cancelled, Error)
// build(): returns PairingData.initial
// requestPairing()
// requestPairingWithTier({durationMinutes, price, isFreeTrial})
// cancelPairing()
// Internal: _connectWebSocket(), _onStatusUpdate(), _cleanup()
}
```
**Key difference from BLoC:** Private events (`_PairingStatusUpdate`, `_PairingTimeout`, `_ConnectionError`) become direct method calls within the notifier since Riverpod notifiers can call `state = ...` from callbacks without needing to route through an event system.
**Files affected:**
- `client_app/lib/core/pairing/pairing_bloc.dart``pairing_notifier.dart` + `.g.dart`
- `client_app/lib/features/home/home_screen.dart`
- `client_app/lib/features/chat/screens/searching_screen.dart`
- `client_app/lib/features/chat/screens/no_bestie_screen.dart`
- `client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart`
#### 1.3.5 `client_app` ChatBloc → ChatNotifier
**Source file:** `client_app/lib/core/chat/chat_bloc.dart`
**Current:** Most complex BLoC. Manages WebSocket connection, message list, typing indicators, session timer, message delivery/read status. 8 events, 4 state classes. `ChatConnected` has `copyWith` for granular updates.
**Target:** `@riverpod` Notifier with internal WebSocket management.
```dart
@riverpod
class Chat extends _$Chat {
WebSocketChannel? _channel;
StreamSubscription? _wsSubscription;
Timer? _typingTimer;
// State: ChatData sealed class
// (Initial, Connecting, Connected, Error)
// ChatDataConnected holds: messages, isOtherTyping, remainingSeconds,
// sessionExpired, sessionPaused, sessionClosing, extensionResponse
// build(): returns ChatData.initial
// connect(sessionId), disconnect()
// sendMessage(content), sendTyping()
// markDelivered(messageIds), markRead(messageIds)
}
```
**Files affected:**
- `client_app/lib/core/chat/chat_bloc.dart``chat_notifier.dart` + `.g.dart`
- `client_app/lib/features/chat/screens/chat_screen.dart`
- `client_app/lib/features/chat/screens/bestie_found_screen.dart`
### 1.4 Migration Per Bloc — Mitra App
Migration order: AuthBloc (fix bug here) → StatusBloc (timer management) → ExtensionBloc (simple) → ChatRequestBloc (WebSocket) → MitraChatBloc (most complex).
#### 1.4.1 `mitra_app` AuthBloc → AuthNotifier (BUG FIX)
**Source file:** `mitra_app/lib/core/auth/auth_bloc.dart`
**Bug:** Lines 65-68 — `_onAppStarted` only calls `_verifyAndEmit` when `_auth.currentUser != null`, but does NOT emit `AuthInitial` when `currentUser` is null. This leaves the app stuck in `AuthLoading`. The client_app's version correctly has `else { emit(AuthInitial()); }`.
**Fix during migration:** The `build()` method of the new AsyncNotifier must return `AuthDataInitial` when `currentUser` is null.
**Target:** `@Riverpod(keepAlive: true)` AsyncNotifier.
```dart
@Riverpod(keepAlive: true)
class Auth extends _$Auth {
ConfirmationResult? _webConfirmationResult;
@override
FutureOr<MitraAuthData> build() async {
final currentUser = FirebaseAuth.instance.currentUser;
if (currentUser != null) {
return await _verifyAndReturn(); // returns MitraAuthData.authenticated(profile)
}
return const MitraAuthData.initial(); // FIX: explicitly return initial state
}
// Methods: requestOtp(phone), verifyOtp(verificationId, smsCode), logout()
}
```
**Files affected:**
- `mitra_app/lib/core/auth/auth_bloc.dart``auth_notifier.dart` + `.g.dart`
- `mitra_app/lib/features/auth/screens/login_screen.dart`
- `mitra_app/lib/features/auth/screens/otp_screen.dart`
- `mitra_app/lib/features/home/home_screen.dart`
- `mitra_app/lib/router.dart`
- `mitra_app/lib/main.dart`
#### 1.4.2 `mitra_app` StatusBloc → StatusNotifier
**Source file:** `mitra_app/lib/core/status/status_bloc.dart`
**Current:** BLoC with 6 events, heartbeat timer management, 4 states.
**Target:** `@Riverpod(keepAlive: true)` Notifier (keepAlive because status persists across screens).
```dart
@Riverpod(keepAlive: true)
class OnlineStatus extends _$OnlineStatus {
Timer? _heartbeatTimer;
// State: OnlineStatusData (Initial, Loaded{isOnline}, Loading, Error)
// build(): returns OnlineStatusData.initial
// load(), toggleOnline(), toggleOffline(), onAppPaused(), onAppResumed()
// Private: _startHeartbeat(), _stopHeartbeat(), _heartbeatTick()
}
```
**Files affected:**
- `mitra_app/lib/core/status/status_bloc.dart``online_status_notifier.dart` + `.g.dart`
- `mitra_app/lib/features/home/home_screen.dart`
- `mitra_app/lib/main.dart` (lifecycle handling)
#### 1.4.3 `mitra_app` ExtensionBloc → ExtensionNotifier
**Source file:** `mitra_app/lib/core/chat/extension_bloc.dart`
**Current:** Simple BLoC with 2 events, 6 states.
**Target:** `@riverpod` Notifier.
```dart
@riverpod
class Extension extends _$Extension {
// State: ExtensionData (Idle, Responding, ShowGoodbye, Submitting, Complete, Error)
// build(): returns ExtensionData.idle
// respond(sessionId, extensionId, accepted)
// submitGoodbye(sessionId, message)
}
```
**Files affected:**
- `mitra_app/lib/core/chat/extension_bloc.dart``extension_notifier.dart` + `.g.dart`
- `mitra_app/lib/features/chat/screens/mitra_chat_screen.dart`
#### 1.4.4 `mitra_app` ChatRequestBloc → ChatRequestNotifier
**Source file:** `mitra_app/lib/core/chat/chat_request_bloc.dart`
**Current:** BLoC with WebSocket connection for incoming chat requests. 6 events, 6 states.
**Target:** `@Riverpod(keepAlive: true)` Notifier.
```dart
@Riverpod(keepAlive: true)
class ChatRequest extends _$ChatRequest {
WebSocketChannel? _channel;
StreamSubscription? _wsSubscription;
// State: ChatRequestData
// (Idle, Listening, Incoming{sessionId}, Accepting, Accepted{session}, Error)
// build(): returns ChatRequestData.idle
// startListening(), stopListening(), accept(sessionId), decline(sessionId)
// Private: _connectWebSocket(), _onRequestReceived(), _closeWebSocket()
}
```
**Files affected:**
- `mitra_app/lib/core/chat/chat_request_bloc.dart``chat_request_notifier.dart` + `.g.dart`
- `mitra_app/lib/features/home/home_screen.dart`
- `mitra_app/lib/features/chat/widgets/incoming_request_sheet.dart`
#### 1.4.5 `mitra_app` MitraChatBloc → MitraChatNotifier
**Source file:** `mitra_app/lib/core/chat/mitra_chat_bloc.dart`
**Current:** Mirrors client ChatBloc closely. WebSocket + message list + typing + session events. 8 events, 4 states.
**Target:** `@riverpod` Notifier.
```dart
@riverpod
class MitraChat extends _$MitraChat {
WebSocketChannel? _channel;
StreamSubscription? _wsSubscription;
Timer? _typingTimer;
// State: MitraChatData
// (Initial, Connecting, Connected{messages, isOtherTyping, ...}, Error)
// build(): returns MitraChatData.initial
// connect(sessionId), disconnect(), sendMessage(content), sendTyping(),
// markDelivered(ids), markRead(ids)
}
```
**Files affected:**
- `mitra_app/lib/core/chat/mitra_chat_bloc.dart``mitra_chat_notifier.dart` + `.g.dart`
- `mitra_app/lib/features/chat/screens/mitra_chat_screen.dart`
- `mitra_app/lib/features/chat/screens/active_sessions_screen.dart`
### 1.5 Router Changes
Both apps use a `_BlocRefreshNotifier` that listens to the AuthBloc stream to trigger GoRouter redirects. Replace with a Riverpod-based approach:
```dart
class RouterNotifier extends ChangeNotifier {
RouterNotifier(this._ref) {
_ref.listen(authProvider, (_, __) => notifyListeners());
}
final Ref _ref;
}
```
Pass as `refreshListenable` to GoRouter. The redirect function reads auth state via `_ref.read(authProvider)`.
**Files changed:**
- `client_app/lib/router.dart`
- `mitra_app/lib/router.dart`
### 1.6 Code Generation
After each migration step, run:
```bash
dart run build_runner build --delete-conflicting-outputs
```
### 1.7 Final Cleanup
After all Blocs are migrated and verified:
1. Remove `flutter_bloc` and `equatable` from both `pubspec.yaml` files
2. Delete old Bloc files
3. Run `flutter pub get` to verify no remaining references
4. Global search for any remaining `BlocProvider`, `BlocBuilder`, `BlocListener`, `context.read<`, `context.watch<` — replace any stragglers
### 1.8 Testing Checklist — Riverpod Migration
| Test | App | What to verify |
|---|---|---|
| Auth flow | client_app | Anonymous login, Google login, Apple login, OTP login, account linking, logout |
| Auth flow | mitra_app | OTP login, logout, **verify stuck-loading bug is fixed** |
| Router redirect | Both | Unauthenticated → login screen; authenticated → home; splash transitions correctly |
| Pricing dialog | client_app | Pricing tiers load, free trial shows when eligible, tier selection triggers pairing |
| Pairing flow | client_app | Request pairing, searching state, bestie found transition, cancel pairing, timeout |
| Chat connect/send | client_app | WebSocket connects, messages send/receive, typing indicator, delivery/read status |
| Session closure | client_app | Extension request, decline extension → goodbye, submit goodbye |
| Status toggle | mitra_app | Go online, go offline, heartbeat fires every 15s, app lifecycle pause/resume |
| Chat requests | mitra_app | Start listening when online, incoming request sheet, accept, decline |
| Mitra chat | mitra_app | Connect to session, send/receive messages, typing, extension request handling |
| Extension | mitra_app | Accept/reject extension, goodbye message submission |
| App lifecycle | mitra_app | Backgrounding stops heartbeat, foregrounding resumes if online |
| FCM token | Both | Token registers after auth, token re-registers on app relaunch |
---
## Work Stream 2: FCM Fallback for Chat Engine
### 2.1 Database Changes
#### New `app_config` keys
| Key | Default Value (JSONB) | Purpose |
|---|---|---|
| `require_mitra_ping` | `{ "value": true }` | Whether mitra must heartbeat to stay online |
| `mitra_ping_interval_seconds` | `{ "value": 15 }` | How often mitra must ping (configurable) |
**Migration addition to `backend/src/db/migrate.js`:**
```sql
INSERT INTO app_config (key, value) VALUES ('require_mitra_ping', '{"value": true}') ON CONFLICT (key) DO NOTHING;
INSERT INTO app_config (key, value) VALUES ('mitra_ping_interval_seconds', '{"value": 15}') ON CONFLICT (key) DO NOTHING;
```
No new tables needed. Existing `chat_messages` table with `status` and `read_at` columns is sufficient for unread counts.
### 2.2 Backend Changes
#### 2.2.1 Config Service Updates
**File:** `backend/src/services/config.service.js`
Add two new functions:
- `getMitraPingConfig()` — returns `{ require_ping, ping_interval_seconds }`
- `setMitraPingConfig({ require_ping, ping_interval_seconds })` — upserts both keys
#### 2.2.2 Internal Config Routes
**File:** `backend/src/routes/internal/config.routes.js`
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/internal/config/mitra-ping` | Get require_ping + interval |
| `PATCH` | `/internal/config/mitra-ping` | Update require_ping and/or interval |
#### 2.2.3 Mitra Status Service Updates
**File:** `backend/src/services/mitra-status.service.js`
- Modify `autoOfflineStaleMitras`: if `require_ping` is `false`, skip the auto-offline sweep entirely; if `true`, use `ping_interval_seconds * 3` as the staleness threshold
- Modify `heartbeat`: if `require_ping` is `false`, return early (no-op)
- Add ping config to status GET response so mitra app knows the expected interval
#### 2.2.4 Mitra Status Routes Update
**File:** `backend/src/routes/public/mitra.status.routes.js`
Update `GET /api/mitra/status` response to include:
```json
{
"success": true,
"data": {
"is_online": true,
"require_ping": true,
"ping_interval_seconds": 15
}
}
```
#### 2.2.5 Pairing Service FCM Fallback Enhancement
**File:** `backend/src/services/pairing.service.js`
The existing `notifyMitra` already has FCM fallback. Enhancements needed:
1. FCM payload includes `session_id` for deep-linking
2. FCM notification shows confirmation that mitra must tap to accept
3. No auto-accept path from FCM — mitra must open app and manually accept
**Updated FCM payload:**
```javascript
await sendPushNotification(UserType.MITRA, mitraId, {
title: 'Permintaan Chat Baru',
body: 'Ada pelanggan yang ingin curhat! Ketuk untuk menerima.',
data: {
type: WsMessage.CHAT_REQUEST,
session_id: data.session_id,
action: 'open_accept',
},
})
```
#### 2.2.6 Closure Service FCM Fallback
**File:** `backend/src/services/closure.service.js`
In `initiateEarlyEnd` and `completeSession`, after sending WebSocket closure signals, add FCM fallback:
```javascript
if (!isUserOnlineWs(UserType.CUSTOMER, session.customer_id)) {
await sendPushNotification(UserType.CUSTOMER, session.customer_id, {
title: 'Sesi Berakhir',
body: 'Sesi curhat kamu telah berakhir. Ketuk untuk menulis pesan penutup.',
data: { type: WsMessage.SESSION_CLOSING, session_id: sessionId },
})
}
// Same for mitra
```
**File:** `backend/src/services/session-timer.service.js`
Same fix in `onSessionExpired` — add FCM fallback for both parties after `SESSION_EXPIRED` and `SESSION_CLOSING` WebSocket messages.
#### 2.2.7 Unread Count API
**New endpoints:**
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/mitra/chat-requests/sessions/active-with-unread` | Active sessions + unread count per session |
| `GET` | `/api/client/chat/session/active-with-unread` | Active session + unread count |
**File:** `backend/src/services/session.service.js`
Add `getActiveSessionsByMitraWithUnread(mitraId)` — joins `chat_sessions` with a subquery counting unread messages (where `sender_type = 'customer'` and `status IN ('sent', 'delivered')`).
Add `getActiveSessionByCustomerWithUnread(customerId)` — same pattern for customer side.
### 2.3 Flutter Changes — Mitra App
#### 2.3.1 Status Notifier Updates (Ping Config)
**File:** `mitra_app/lib/core/status/online_status_notifier.dart`
1. Fetch `require_ping` and `ping_interval_seconds` from status API response
2. If `require_ping` is `false`, do NOT start heartbeat timer
3. If `require_ping` is `true`, use `ping_interval_seconds` from config (not hardcoded 15s)
4. On `AppPaused`: if `require_ping` is false, do nothing; if true, stop heartbeat as before
#### 2.3.2 Unread Badge Provider
**New file:** `mitra_app/lib/core/chat/unread_notifier.dart`
```dart
@Riverpod(keepAlive: true)
class UnreadSessions extends _$UnreadSessions {
// Returns Map<String, int> — { sessionId: unreadCount }
// Polls every 10-30s or updates via WebSocket
// totalUnread getter: sum of all values
// markSessionRead(sessionId): optimistic update sets count to 0
}
```
#### 2.3.3 Home Screen Badge
**File:** `mitra_app/lib/features/home/home_screen.dart`
Add `Badge` widget wrapping the active sessions button icon, showing `totalUnread` count.
#### 2.3.4 Active Sessions Screen Badge
**File:** `mitra_app/lib/features/chat/screens/active_sessions_screen.dart`
Show `Badge` on each session's `ListTile` with per-session unread count. Badge clears when user enters the session (mark-read via WebSocket).
#### 2.3.5 Notification Service Updates
**File:** `mitra_app/lib/core/notifications/notification_service.dart`
Handle FCM-delivered messages:
- `type: chat_request` → navigate to home screen, show incoming request bottom sheet
- `type: session_closing` → navigate to the chat session closure screen
### 2.4 Flutter Changes — Client App
#### 2.4.1 Unread Badge Provider
**New file:** `client_app/lib/core/chat/unread_notifier.dart`
```dart
@Riverpod(keepAlive: true)
class UnreadCount extends _$UnreadCount {
// Returns int — total unread count for active session
// markRead(): sets to 0
}
```
#### 2.4.2 Home Screen Badge
**File:** `client_app/lib/features/home/home_screen.dart`
Add `Badge` widget on `_ActiveSessionCard`'s `CircleAvatar`, showing unread count.
#### 2.4.3 Notification Service Update
**File:** `client_app/lib/core/notifications/notification_service.dart`
Handle closure FCM: `type: session_closing` → navigate to chat session screen (shows closure UI).
### 2.5 Control Center Changes
**File:** `control_center/src/pages/settings/SettingsPage.jsx`
Add new section for mitra ping configuration:
- Checkbox: "Wajibkan Mitra Ping (Heartbeat)" — toggle `require_mitra_ping`
- Number input: "Interval Ping" — sets `mitra_ping_interval_seconds`
- Helper text explaining that disabling ping means QC is responsible for mitra quality
---
## 3. Implementation Order
| Step | What | Apps Affected | Dependencies |
|---|---|---|---|
| **Work Stream 1: Riverpod Migration** | | | |
| 1 | Add Riverpod dependencies to both pubspec.yaml | client_app, mitra_app | None |
| 2 | Wrap app root with ProviderScope, create ApiClient provider | client_app, mitra_app | Step 1 |
| 3 | Migrate client_app AuthBloc → AuthNotifier | client_app | Step 2 |
| 4 | Update client_app router to use Riverpod auth state | client_app | Step 3 |
| 5 | Migrate client_app ChatOpeningBloc → ChatOpeningNotifier | client_app | Step 3 |
| 6 | Migrate client_app SessionClosureBloc → SessionClosureNotifier | client_app | Step 3 |
| 7 | Migrate client_app PairingBloc → PairingNotifier | client_app | Step 3 |
| 8 | Migrate client_app ChatBloc → ChatNotifier | client_app | Step 3, 6, 7 |
| 9 | E2E test client_app (all flows) | client_app | Steps 38 |
| 10 | Migrate mitra_app AuthBloc → AuthNotifier (**fix stuck-loading bug**) | mitra_app | Step 2 |
| 11 | Update mitra_app router to use Riverpod auth state | mitra_app | Step 10 |
| 12 | Migrate mitra_app StatusBloc → StatusNotifier | mitra_app | Step 10 |
| 13 | Migrate mitra_app ExtensionBloc → ExtensionNotifier | mitra_app | Step 10 |
| 14 | Migrate mitra_app ChatRequestBloc → ChatRequestNotifier | mitra_app | Step 10, 12 |
| 15 | Migrate mitra_app MitraChatBloc → MitraChatNotifier | mitra_app | Step 10, 13 |
| 16 | E2E test mitra_app (all flows + verify bug fix) | mitra_app | Steps 1015 |
| 17 | Remove flutter_bloc + equatable from both apps | client_app, mitra_app | Steps 9, 16 |
| **Work Stream 2: FCM Fallback** | | | |
| 18 | DB migration: add `require_mitra_ping` + `mitra_ping_interval_seconds` config | Backend | None |
| 19 | Config service: add get/set for mitra ping config | Backend | Step 18 |
| 20 | Internal config routes: add GET/PATCH `/internal/config/mitra-ping` | Backend | Step 19 |
| 21 | Control center: add mitra ping config section to Settings | Control center | Step 20 |
| 22 | Mitra status service: honor `require_mitra_ping` in auto-offline + heartbeat | Backend | Step 19 |
| 23 | Mitra status routes: include ping config in GET response | Backend | Step 22 |
| 24 | Mitra app StatusNotifier: use dynamic ping config from API | mitra_app | Step 23, 12 |
| 25 | Pairing service: enhance FCM payload for chat request | Backend | Existing |
| 26 | Mitra app NotificationService: handle FCM chat requests | mitra_app | Step 25, 14 |
| 27 | Closure service: add FCM fallback for session_closing signal | Backend | Existing |
| 28 | Session timer service: add FCM fallback for session_expired signal | Backend | Existing |
| 29 | Client/mitra app NotificationService: handle closure FCM | Both apps | Steps 2728 |
| 30 | Unread count API: add session service functions + routes | Backend | Existing |
| 31 | Mitra app: UnreadSessions provider + badges | mitra_app | Step 30 |
| 32 | Client app: UnreadCount provider + badge | client_app | Step 30 |
| 33 | E2E test: mitra ping config + non-ping mode + pairing via FCM | All | Steps 2126 |
| 34 | E2E test: closure FCM fallback + unread badges | All | Steps 2732 |
---
## 4. New Dependencies
| App | Package | Purpose |
|---|---|---|
| client_app | `flutter_riverpod` | Core Riverpod |
| client_app | `hooks_riverpod` | Riverpod + Hooks integration |
| client_app | `riverpod_annotation` | `@riverpod` annotations |
| client_app | `flutter_hooks` | Hook utilities |
| client_app (dev) | `riverpod_generator` | Code generation |
| client_app (dev) | `build_runner` | Code generation runner |
| client_app (dev) | `custom_lint` | Required for riverpod_lint |
| client_app (dev) | `riverpod_lint` | Lint rules |
| mitra_app | Same as client_app | Same |
| mitra_app (dev) | Same as client_app | Same |
**Removed after migration:**
| App | Package | Reason |
|---|---|---|
| client_app | `flutter_bloc` | Replaced by Riverpod |
| client_app | `equatable` | No longer needed |
| mitra_app | `flutter_bloc` | Replaced by Riverpod |
| mitra_app | `equatable` | No longer needed |
No new backend or control_center dependencies.
---
## 5. Risks & Mitigations
| Risk | Mitigation |
|---|---|
| Riverpod migration breaks auth redirect logic | Test router redirects thoroughly after step 4/11; keep old bloc files until verified |
| WebSocket lifecycle differs between BLoC and Notifier | BLoC `close()` auto-called on `BlocProvider` dispose; Riverpod notifiers with `keepAlive: true` persist. Ensure `ref.onDispose()` cleans up WebSocket/timers |
| Code generation conflicts | Run `build_runner build --delete-conflicting-outputs` after each migration step |
| FCM notifications not received when app is killed | Already handled by `firebase_messaging` background handler; verify on both iOS and Android |
| Non-ping mode mitras go stale in database | When `require_ping` is false, auto-offline sweep is completely skipped; only manual offline or Control Center action changes status |
| Unread count polling creates excessive API calls | Use 10-30s polling interval; WebSocket-based real-time update can be added later |

84
requirement/phase3.1.md Normal file
View File

@@ -0,0 +1,84 @@
# PRD: Phase 3 Stabilization & State Management Migration
# Overview
**Goal:** Stabilize Phase 3 (Chat Engine) through end-to-end testing and migrate Flutter state management from BLoC to Riverpod + flutter_hooks
**Success looks like:** All Phase 3 features are verified working end-to-end across client_app, mitra_app, and control_center. Both Flutter apps use Riverpod as their sole state management solution.
## Background
- Phase 3 (Chat Engine) is fully scaffolded but has not been end-to-end tested
- Current Flutter apps use BLoC pattern; Riverpod is preferred for maintainability and reduced boilerplate
- Migration should happen before Phase 4 to avoid compounding tech debt
## FCM fallback for Chat Engine
### Mitra Pairing
- Add configuration on Control center to configure Mitra's app require to ping or not.
- When Control Center allow non ping, application or backend will not force mitra to ping and allow them to keep online even when the app is closed or in backround
- Modify Mitra Pairing confirmation to send notification through FCM when websocket to Mitra is closed
### Bi-Directional Chat (WebSocket + FCM)
#### Mitra App
- When there is new unread message, mitra app must shows badge on active session
- When there is new unread message, mitra app must shows badge on the chat active session inside active session page
- Unread badge on each active session will be cleared when the message has been read
- Unread badge on active session button on main page will be cleared when the message has been read
#### Customer App
- When there is new unread message, Customer app must shows badge on active session
- Unread badge will be cleared when unread message has been cleared
### Chat Closure & Extension
- When chat closure called, backend will send closure signal to both Mitra and Customer
- Backend will use FCM if the websocket connection is down
### Control Center
- Control center shows configuration for ping from mitra
## Riverpod Migration
### Scope
- Migrate all BLoC classes in `client_app` and `mitra_app` to Riverpod annotation-based providers
- Replace `flutter_bloc` with `flutter_riverpod`, `riverpod_annotation`, `flutter_hooks`, and `hooks_riverpod`
- Add `riverpod_generator` + `build_runner` as dev dependencies for code generation
- No backend or control_center changes
### Migration Strategy
- [ ] Add Riverpod dependencies (`flutter_riverpod`, `hooks_riverpod`, `riverpod_annotation`) and dev dependencies (`riverpod_generator`, `build_runner`, `custom_lint`, `riverpod_lint`)
- [ ] Wrap app root with `ProviderScope`
- [ ] Migrate one Bloc at a time, starting with the simplest (e.g. AuthBloc)
- [ ] For each migrated Bloc:
1. Replace `Bloc`/`Cubit` class with `@riverpod` annotated `Notifier` or `AsyncNotifier` (extending `_$ClassName`)
2. Replace `BlocEvent` + `emit()` pattern with notifier methods that update `state` directly
3. Run `dart run build_runner build` to generate `.g.dart` files
4. Replace `BlocProvider` with generated provider (e.g. `authProvider`)
5. Replace `BlocBuilder` widgets with `ConsumerWidget` + `ref.watch()`
6. Replace `BlocListener` with `ref.listen()` inside widget or provider
7. Use `HookConsumerWidget` where flutter_hooks are needed (e.g. `useTextEditingController`, `useEffect`)
- [ ] Run E2E verification after each migration to catch regressions
- [ ] Remove `flutter_bloc` dependency only after all Blocs are migrated
### Affected Blocs
- [ ] `client_app` — AuthBloc, PairingBloc, ChatBloc, ChatOpeningBloc, SessionClosureBloc
- [ ] `mitra_app` — AuthBloc, OnlineStatusBloc, MitraChatBloc, ExtensionBloc
# Non-Functional Requirement
- [ ] WebSocket reconnects gracefully after network interruption (within 5s on stable network)
- [ ] Use FCM to send command or message when websocket is down
- [ ] No message loss during brief disconnects — undelivered messages sync on reconnect
- [ ] Chat screen maintains scroll position and input draft on app lifecycle events (background/foreground)
- [ ] Riverpod migration introduces zero new UI bugs — feature parity with BLoC implementation
# Tech Stack
- State management: Riverpod + flutter_hooks (replacing flutter_bloc)
- No backend changes expected — migration is Flutter-only

View File

@@ -0,0 +1,258 @@
# Phase 3.2 Implementation Plan: Incoming Chat Request Overlay & Mitra Request Activity Log
## Summary of Clarified Requirements
| Topic | Decision |
|---|---|
| Work stream order | Overlay (mitra_app) and Activity Log (backend + CC) can be parallelized |
| Overlay approach | `Stack` wrapping `MaterialApp.router` in `main.dart` + `AnimatedPositioned` |
| Overlay state source | Watches `chatRequestProvider` exclusively — no per-page listeners |
| Multiple requests | Queued in notifier. Show one at a time. Next appears when current is resolved. |
| Swipe down behavior | Dismiss = ignore (no reject sent to backend) |
| Stale request messages | Must show specific reason: cancelled, accepted by other, expired |
| Stale auto-dismiss | No auto-dismiss — mitra must acknowledge by tapping OK or swiping |
| Background dimming | Partially dimmed to get mitra's attention |
| `missed` vs `ignored` | Backend must distinguish: `missed` = another mitra accepted; `ignored` = 60s timeout |
| `active_session_count` | Recorded at notification creation time (snapshot of mitra load) |
| `response_time_seconds` | Calculated column, not stored (`responded_at - notified_at`) |
| Control center page | New `/mitra-activity` page with acceptance rate, avg response time, filters |
| Mitra QC auto-flag | Out of scope for this phase (tracked in memory for future) |
---
## Work Stream 1: Incoming Chat Request Overlay (mitra_app)
### 1.1 Backend: Add `reason` Field to `chat_request_closed` WebSocket Message
The current `chat_request_closed` message is sent identically for three different scenarios. The overlay must show a specific message for each case.
**File:** `backend/src/services/pairing.service.js`
**Change 1 — `acceptPairingRequest`:** When notifying other mitras, add `reason: 'accepted_by_other'`
**Change 2 — `cancelPairingRequest`:** Add `reason: 'cancelled_by_customer'`
**Change 3 — `expirePairingRequest`:** Add `reason: 'expired'`
### 1.2 Backend: Include Session Metadata in `chat_request` WS Message
**File:** `backend/src/services/pairing.service.js`
Add `duration_minutes` and `is_free_trial` to the `notifyMitra` call in `createPairingRequest`.
### 1.3 ChatRequestNotifier: Queue Support and Stale Reason
**File:** `mitra_app/lib/core/chat/chat_request_notifier.dart`
#### New State Classes
```dart
class ChatRequestIncomingData extends ChatRequestData {
final String sessionId;
final int? durationMinutes;
final DateTime? createdAt;
const ChatRequestIncomingData(this.sessionId, {this.durationMinutes, this.createdAt});
}
class ChatRequestStaleData extends ChatRequestData {
final String sessionId;
final StaleReason reason;
const ChatRequestStaleData(this.sessionId, this.reason);
}
enum StaleReason {
cancelledByCustomer, // "Permintaan dibatalkan oleh customer"
acceptedByOther, // "Permintaan diterima oleh Bestie lain"
expired, // "Permintaan kedaluwarsa"
}
```
#### Queue Implementation
Add `List<String> _pendingQueue` field:
- New request arrives while showing another → add to queue
- `chat_request_closed` for queued request → remove silently from queue
- `chat_request_closed` for displayed request → transition to `ChatRequestStaleData`
#### New Methods
- `ignore()` — swipe down on active request, advance queue
- `acknowledgeStale()` — OK/swipe on stale message, advance queue
### 1.4 Overlay Widget: `ChatRequestOverlay`
**New file:** `mitra_app/lib/core/chat/widgets/chat_request_overlay.dart`
A `ConsumerStatefulWidget` wrapping the app that manages the overlay:
- **Layout:** `Stack` → app child + positioned overlay at bottom + semi-transparent dim layer
- **Animation:** `SlideTransition` from `Offset(0, 1)` to `Offset(0, 0)` for bottom-up slide
- **Swipe-to-dismiss:** `GestureDetector` with `onVerticalDragEnd` detecting downward swipe
- **State listening:** `ref.listen(chatRequestProvider)` to show/hide:
- `ChatRequestIncomingData` → show overlay with accept/reject/swipe
- `ChatRequestStaleData` → show overlay with reason message + OK button
- `ChatRequestAcceptedData` → hide overlay, navigate to chat
- Any other state → hide overlay
**Active request content:**
```
[Chat icon]
"Ada permintaan chat baru!"
Durasi: 30 Menit
[Tolak] [Terima]
(swipe down to close)
```
**Stale request content:**
```
[Info icon]
"Permintaan dibatalkan oleh customer"
[OK]
```
**Navigation:** Uses `ref.read(routerProvider)` for `GoRouter` access (overlay sits above the router).
### 1.5 App Root Changes
**File:** `mitra_app/lib/main.dart`
Wrap `MaterialApp.router` with `ChatRequestOverlay`:
```dart
return ChatRequestOverlay(
child: MaterialApp.router(
title: 'Halo Bestie Mitra',
routerConfig: router,
),
);
```
### 1.6 Cleanup Old Bottom Sheet Code
**File:** `mitra_app/lib/features/home/home_screen.dart`
- Remove `_showIncomingRequest` method
- Remove `didChangeAppLifecycleState` incoming request check
- Remove `ref.listen(chatRequestProvider, ...)` block for bottom sheet + navigation
- Remove `IncomingRequestSheet` import
- Convert from `ConsumerStatefulWidget` to `ConsumerWidget` (no lifecycle observer needed)
**Delete:** `mitra_app/lib/features/chat/widgets/incoming_request_sheet.dart`
---
## Work Stream 2: Mitra Request Activity Log (Backend + CC)
### 2.1 Database Migration
**File:** `backend/src/db/migrate.js`
```sql
ALTER TABLE chat_request_notifications
ADD COLUMN IF NOT EXISTS active_session_count INT NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_chat_request_notifications_mitra_notified
ON chat_request_notifications (mitra_id, notified_at);
```
### 2.2 Constants: Add `MISSED` Response Type
**File:** `backend/src/constants.js`
Add `MISSED: 'missed'` to `NotificationResponse`.
### 2.3 Pairing Service Updates
**File:** `backend/src/services/pairing.service.js`
- `createPairingRequest`: record `active_session_count` when creating notification rows
- `acceptPairingRequest`: change `IGNORED``MISSED` when marking other mitras' notifications
- `expirePairingRequest`: keep `IGNORED` (correct — 60s timeout)
### 2.4 New Service: Mitra Activity
**New file:** `backend/src/services/mitra-activity.service.js`
- `getMitraActivityLog({ mitra_id, date_from, date_to, page, limit })` — paginated detail log
- `getMitraActivitySummary({ mitra_id, date_from, date_to })` — per-mitra aggregates: total, accepted, rejected, missed, ignored, acceptance rate %, avg response time
### 2.5 New Internal Routes
**New file:** `backend/src/routes/internal/mitra-activity.routes.js`
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/internal/mitra-activity/log` | Paginated detail log |
| `GET` | `/internal/mitra-activity/summary` | Per-mitra summary stats |
Register in `backend/src/app.internal.js`.
### 2.6 Control Center: Mitra Activity Page
**New file:** `control_center/src/pages/mitra-activity/MitraActivityPage.jsx`
**Filters:** Date range (from/to), mitra dropdown (optional)
**Summary table columns:**
| Mitra | Total | Accepted | Rejected | Missed | Ignored | Rate (%) | Avg Response (s) |
**Detail log table columns:**
| Mitra | Session | Response | Response Time | Active Sessions | Notified At | Responded At |
Response values color-coded: `accepted` green, `rejected` red, `missed` orange, `ignored` grey.
Register route in `App.jsx`, add nav link "Aktivitas Mitra" in `Layout.jsx`.
---
## 3. Implementation Order
| Step | What | Apps Affected | Dependencies |
|---|---|---|---|
| **Work Stream 1: Overlay** | | | |
| 1 | Backend: add `reason` to `chat_request_closed` WS messages | Backend | None |
| 2 | Backend: include `duration_minutes` in `chat_request` WS message | Backend | None |
| 3 | ChatRequestNotifier: add `ChatRequestStaleData`, `StaleReason`, queue, `ignore()`, `acknowledgeStale()` | mitra_app | Steps 12 |
| 4 | Create `ChatRequestOverlay` widget | mitra_app | Step 3 |
| 5 | Integrate overlay into `main.dart` | mitra_app | Step 4 |
| 6 | Cleanup: remove bottom sheet code from home screen, delete `IncomingRequestSheet` | mitra_app | Step 5 |
| 7 | E2E test overlay flows | mitra_app | Step 6 |
| **Work Stream 2: Activity Log** | | | |
| 8 | DB migration: `active_session_count` column + index | Backend | None |
| 9 | Constants: add `MISSED` to `NotificationResponse` | Backend | None |
| 10 | Pairing service: record `active_session_count`, use `MISSED` | Backend | Steps 89 |
| 11 | New `mitra-activity.service.js` | Backend | Step 10 |
| 12 | New `mitra-activity.routes.js` + register | Backend | Step 11 |
| 13 | Control center: `MitraActivityPage.jsx` | Control center | Step 12 |
| 14 | Control center: register route + nav link | Control center | Step 13 |
| 15 | E2E test activity log + summary | All | Step 14 |
---
## 4. New Files
| File | Purpose |
|---|---|
| `mitra_app/lib/core/chat/widgets/chat_request_overlay.dart` | App-wide overlay for incoming chat requests |
| `backend/src/services/mitra-activity.service.js` | Mitra activity log query functions |
| `backend/src/routes/internal/mitra-activity.routes.js` | Internal API routes for activity data |
| `control_center/src/pages/mitra-activity/MitraActivityPage.jsx` | CC mitra activity page |
## 5. Deleted Files
| File | Reason |
|---|---|
| `mitra_app/lib/features/chat/widgets/incoming_request_sheet.dart` | Replaced by `ChatRequestOverlay` |
---
## 6. Risks & Mitigations
| Risk | Mitigation |
|---|---|
| Overlay wrapping `MaterialApp.router` can't use `GoRouter.of(context)` | Use `ref.read(routerProvider)` directly |
| Swipe gesture conflicts with overlay content | Overlay content is short (non-scrollable); wrap only drag-handle area |
| Multiple rapid WS messages cause queue issues | Queue ops are synchronous in Dart's single-threaded event loop |
| `chat_request_closed` arrives during slide-up animation | Transition to stale state immediately; animation handles it smoothly |
| Old `ignored` values in DB now ambiguous | Only new requests post-deploy get correct `missed` values; document in CC |

137
requirement/phase3.2.md Normal file
View File

@@ -0,0 +1,137 @@
# PRD: Mitra Chat Request Overlay & Pairing UX
# Overview
**Goal:** Reliable incoming chat request experience for Mitra — always visible, non-blocking, works in all app states (foreground, background, killed)
**Success looks like:** When a customer requests a chat, the mitra is always notified and can accept/reject without leaving their current screen. The notification works regardless of whether the app is in foreground, background, or opened from a push notification.
## Background
- Current bottom sheet approach (`showModalBottomSheet`) fails silently when the app is backgrounded
- Flutter UI cannot render when the app is not in foreground
- Push notifications work in background but the in-app response (bottom sheet) doesn't show reliably when the user taps the notification
- The state change from WebSocket gets "consumed" by listeners while backgrounded, so returning to foreground shows stale data
- Need a solution that is non-blocking (doesn't interrupt active chat sessions) and works on any page
# Functional Requirement
## Incoming Chat Request Overlay
### Trigger
- Overlay watches `chatRequestProvider` state — it does NOT depend on any specific page or lifecycle event
- Three sources can trigger the overlay:
1. **WebSocket** — real-time delivery when app is in foreground
2. **FCM notification tap** — user taps push notification, app opens, state is set from notification payload
3. **App resume** — app returns to foreground, validates pending request with backend
### Appearance
- Slides up from the bottom of the screen, like a bottom sheet
- Rounded top corners
- Page behind is partially dimmed to get mitra's attention
- Shows on top of ANY page (home, chat, history, settings, etc.)
### Content
- Session information (duration, etc.)
- Accept button
- Reject button
- Swipe down to close (= ignore, NOT reject)
### Behavior
- **Accept** → overlay dismisses, navigate to chat session with customer
- **Reject** → overlay dismisses, mitra continues what they were doing
- **Swipe down / close** → overlay dismisses, request is ignored (no explicit reject sent to backend)
- **Request cancelled by customer** → overlay updates to show "Permintaan dibatalkan oleh customer"
- **Request accepted by other mitra** → overlay updates to show "Permintaan diterima oleh Bestie lain"
- **Request expired (60s timeout)** → overlay updates to show "Permintaan kedaluwarsa"
- Stale messages do NOT auto-dismiss — mitra must acknowledge by tapping OK or swiping down
### Multiple Requests
- Show one request at a time
- Requests are queued — when current request is resolved (accepted/rejected/ignored/expired), next queued request appears
- Future enhancement: dedicated `/chat-requests` page with full list
## Push Notification (Background/Killed)
### When App is Backgrounded
- Local notification with sound + vibration (already implemented via WebSocket listener)
- FCM push notification as fallback when WebSocket is disconnected
### When Notification is Tapped
- App opens → `chatRequestProvider` state is set from notification payload (`session_id`)
- Overlay appears with the request detail
- Backend validation confirms request is still pending before showing accept/reject
### Stale Notification Handling
- If user taps a notification for an already-resolved request, overlay shows appropriate message ("dibatalkan" / "diterima Bestie lain" / "kedaluwarsa") then auto-dismisses
## Mitra Request Activity Log
### Goal
Track every mitra's response to incoming chat requests for QC measurement — how many accepted, rejected, ignored (expired without action), and how many active sessions they had at the time.
### Logged Data
For each incoming request delivered to a mitra, log:
- `mitra_id` — which mitra received the request
- `session_id` — which chat request
- `response``accepted`, `rejected`, `ignored` (expired without action), `missed` (taken by other mitra before response)
- `response_time_seconds` — how long it took the mitra to respond (null if ignored)
- `active_session_count` — how many active sessions the mitra had at the time of the request
- `notified_at` — when the request was delivered to the mitra
- `responded_at` — when the mitra responded (null if ignored)
### When to Log
All logging is **backend-side and event-driven** — no polling or constant monitoring needed. The frontend only triggers `accepted` and `rejected` through existing API calls.
| Response | Triggered by | Frontend action? |
|---|---|---|
| `accepted` | Backend — when mitra calls `POST /:sessionId/accept` | Yes — mitra taps Accept |
| `rejected` | Backend — when mitra calls `POST /:sessionId/decline` | Yes — mitra taps Reject |
| `missed` | Backend — when `acceptPairingRequest` marks other mitras' notifications (another mitra accepted first) | No — fully backend |
| `ignored` | Backend — when `expirePairingRequest` fires after 60s timeout with no response | No — fully backend |
### Existing Infrastructure
The `chat_request_notifications` table already tracks `notified_at`, `response`, `responded_at`. Current changes needed:
- Distinguish `missed` from `ignored` (currently both stored as `ignored`)
- Add `active_session_count` column — recorded when the notification is created
- `response_time_seconds` can be calculated from `responded_at - notified_at` (no new column needed)
### Control Center
- New dedicated page: Mitra Request Activity
- Dashboard: mitra acceptance rate, average response time, rejection count, ignore count per mitra
- Filter by date range and mitra
- Auto-flagging mitras with high rejection/ignore rate is **out of scope** for this phase (planned for future)
## Technical Implementation
### Overlay Component
- Single `OverlayEntry` managed from `main.dart`
- Wrapped around `MaterialApp.router` in a `Stack`
- Watches `chatRequestProvider` — shows/hides based on state
- Uses `AnimatedPositioned` or `SlideTransition` for bottom-up animation
- `GestureDetector` for swipe-to-dismiss
### State Flow
```
WebSocket ──→
FCM tap ──→ chatRequestProvider ──→ Overlay shows/hides
App resume ──→
```
### No Changes Required
- Backend pairing service (already sends WS + FCM)
- Push notification payload (already contains session_id + type)
- Chat request notifier (already manages state from WS + FCM)
### Cleanup from Phase 3.1
- Remove `showModalBottomSheet` for incoming requests from home screen
- Remove `IncomingRequestSheet` widget
- Remove `didChangeAppLifecycleState` incoming request check from home screen
# Tech Stack
- Flutter `Overlay` / `OverlayEntry` or `Stack` with `AnimatedPositioned`
- Existing Riverpod `chatRequestProvider`
- No backend changes