Compare commits
14 Commits
b0502ac92b
...
bb0346a843
| Author | SHA1 | Date | |
|---|---|---|---|
| bb0346a843 | |||
| 4c6130aa04 | |||
| b9c4841eb1 | |||
| 4158fb9432 | |||
| e3da863f3c | |||
| 212e1e8ac6 | |||
| 2e80434e9b | |||
| 1b249e34b0 | |||
| 229f889551 | |||
| ed765d230c | |||
| fa8c963d92 | |||
| 35d470b851 | |||
| bc66bbf50a | |||
| d15b2f05fc |
@@ -6,6 +6,7 @@ import { rolesRoutes } from './routes/internal/roles.routes.js'
|
||||
import { internalAuthRoutes } from './routes/internal/auth.routes.js'
|
||||
import { internalConfigRoutes } from './routes/internal/config.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'
|
||||
|
||||
export const buildInternalApp = async () => {
|
||||
@@ -20,6 +21,7 @@ export const buildInternalApp = async () => {
|
||||
app.register(rolesRoutes, { prefix: '/internal/roles' })
|
||||
app.register(internalConfigRoutes, { prefix: '/internal/config' })
|
||||
app.register(sessionManagementRoutes, { prefix: '/internal/sessions' })
|
||||
app.register(mitraActivityRoutes, { prefix: '/internal/mitra-activity' })
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export const MessageType = Object.freeze({
|
||||
export const NotificationResponse = Object.freeze({
|
||||
ACCEPTED: 'accepted',
|
||||
DECLINED: 'declined',
|
||||
MISSED: 'missed',
|
||||
IGNORED: 'ignored',
|
||||
})
|
||||
|
||||
|
||||
@@ -274,6 +274,32 @@ const migrate = async () => {
|
||||
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.')
|
||||
await sql.end()
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getFreeTrialConfig, setFreeTrialConfig,
|
||||
getExtensionTimeoutConfig, setExtensionTimeoutConfig,
|
||||
getEarlyEndConfig, setEarlyEndConfig,
|
||||
getMitraPingConfig, setMitraPingConfig,
|
||||
} from '../../services/config.service.js'
|
||||
|
||||
const attachCcUser = async (request, reply) => {
|
||||
@@ -102,6 +103,28 @@ export const internalConfigRoutes = async (app) => {
|
||||
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 ---
|
||||
app.get('/price-tiers', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
|
||||
|
||||
33
backend/src/routes/internal/mitra-activity.routes.js
Normal file
33
backend/src/routes/internal/mitra-activity.routes.js
Normal 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 })
|
||||
})
|
||||
}
|
||||
@@ -1,8 +1,18 @@
|
||||
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) => {
|
||||
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)
|
||||
if (!customer) {
|
||||
return reply.code(404).send({
|
||||
@@ -10,6 +20,14 @@ export const clientAuthRoutes = async (app) => {
|
||||
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 })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { authenticate } from '../../plugins/auth.js'
|
||||
import { getCustomerByFirebaseUid } from '../../services/customer.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 { requestExtension } from '../../services/extension.service.js'
|
||||
import { EndedBy } from '../../constants.js'
|
||||
@@ -73,6 +73,11 @@ export const clientChatRoutes = async (app) => {
|
||||
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) => {
|
||||
const session = await endSession(request.params.sessionId, EndedBy.CUSTOMER, request.customer.id)
|
||||
return reply.send({ success: true, data: session })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { authenticate } from '../../plugins/auth.js'
|
||||
import { getMitraByFirebaseUid } from '../../services/mitra.service.js'
|
||||
import { acceptPairingRequest, declinePairingRequest } from '../../services/pairing.service.js'
|
||||
import { getActiveSessionsByMitra, endSession, getMitraHistory } from '../../services/session.service.js'
|
||||
import { acceptPairingRequest, declinePairingRequest, getSessionStatus } from '../../services/pairing.service.js'
|
||||
import { getActiveSessionsByMitra, getActiveSessionsByMitraWithUnread, endSession, getMitraHistory } from '../../services/session.service.js'
|
||||
import { respondToExtension } from '../../services/extension.service.js'
|
||||
import { EndedBy } from '../../constants.js'
|
||||
|
||||
@@ -23,6 +23,15 @@ const resolveMitra = async (request, reply) => {
|
||||
}
|
||||
|
||||
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) => {
|
||||
const session = await acceptPairingRequest(request.params.sessionId, request.mitra.id)
|
||||
return reply.send({ success: true, data: session })
|
||||
@@ -38,6 +47,11 @@ export const mitraChatRoutes = async (app) => {
|
||||
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) => {
|
||||
const session = await endSession(request.params.sessionId, EndedBy.MITRA, request.mitra.id)
|
||||
return reply.send({ success: true, data: session })
|
||||
|
||||
@@ -26,7 +26,7 @@ const start = async () => {
|
||||
// Auto-offline mitras with stale heartbeat (every 30s)
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const count = await autoOfflineStaleMitras(45)
|
||||
const count = await autoOfflineStaleMitras()
|
||||
if (count > 0) console.log(`Auto-offlined ${count} stale mitra(s)`)
|
||||
} catch (err) {
|
||||
console.error('Auto-offline check failed:', err)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getDb } from '../db/client.js'
|
||||
import { publish } from '../plugins/valkey.js'
|
||||
import { clearSessionTimer, clearClosureGraceTimer } from './session-timer.service.js'
|
||||
import { sendToSessionParticipant } from '../plugins/websocket.js'
|
||||
import { sendPushNotification } from './notification.service.js'
|
||||
import { UserType, SessionStatus, EndedBy, WsMessage } from '../constants.js'
|
||||
|
||||
const sql = getDb()
|
||||
@@ -53,10 +54,25 @@ export const completeSession = async (sessionId) => {
|
||||
`
|
||||
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 }
|
||||
sendToSessionParticipant(sessionId, UserType.CUSTOMER, data)
|
||||
sendToSessionParticipant(sessionId, UserType.MITRA, data)
|
||||
const customerSent = sendToSessionParticipant(sessionId, UserType.CUSTOMER, 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 })
|
||||
|
||||
@@ -90,10 +106,25 @@ export const initiateEarlyEnd = async (sessionId, userType) => {
|
||||
|
||||
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 }
|
||||
sendToSessionParticipant(sessionId, UserType.CUSTOMER, data)
|
||||
sendToSessionParticipant(sessionId, UserType.MITRA, data)
|
||||
const customerSent = sendToSessionParticipant(sessionId, UserType.CUSTOMER, 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
|
||||
}
|
||||
|
||||
@@ -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 }) => {
|
||||
if (mitra_enabled !== undefined) {
|
||||
await sql`
|
||||
|
||||
@@ -48,3 +48,40 @@ export const getCustomerByFirebaseUid = async (firebase_uid) => {
|
||||
`
|
||||
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
|
||||
}
|
||||
|
||||
75
backend/src/services/mitra-activity.service.js
Normal file
75
backend/src/services/mitra-activity.service.js
Normal 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
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getDb } from '../db/client.js'
|
||||
import { SessionStatus } from '../constants.js'
|
||||
import { getMitraPingConfig } from './config.service.js'
|
||||
|
||||
const sql = getDb()
|
||||
|
||||
@@ -58,7 +59,12 @@ export const getStatus = async (mitraId) => {
|
||||
FROM mitra_online_status
|
||||
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 () => {
|
||||
@@ -89,7 +95,13 @@ export const getOnlineLogs = async (mitraId, { page = 1, limit = 50 } = {}) => {
|
||||
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`
|
||||
UPDATE mitra_online_status
|
||||
SET is_online = false, last_offline_at = NOW(), updated_at = NOW()
|
||||
|
||||
@@ -19,8 +19,8 @@ const notifyMitra = async (mitraId, data) => {
|
||||
if (data.type === WsMessage.CHAT_REQUEST) {
|
||||
await sendPushNotification(UserType.MITRA, mitraId, {
|
||||
title: 'Permintaan Chat Baru',
|
||||
body: 'Ada pelanggan yang ingin curhat!',
|
||||
data: { type: WsMessage.CHAT_REQUEST, session_id: data.session_id },
|
||||
body: 'Ada pelanggan yang ingin curhat! Ketuk untuk menerima.',
|
||||
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
|
||||
const notifyCustomer = async (customerId, data) => {
|
||||
const sent = sendToUser(UserType.CUSTOMER, customerId, data)
|
||||
console.log(`[notifyCustomer] customerId=${customerId} type=${data.type} ws_sent=${sent}`)
|
||||
if (!sent) {
|
||||
if (data.type === WsMessage.PAIRED) {
|
||||
await sendPushNotification(UserType.CUSTOMER, customerId, {
|
||||
@@ -91,15 +92,22 @@ export const createPairingRequest = async (customerId, { duration_minutes, price
|
||||
|
||||
// Create notifications for all available mitras
|
||||
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`
|
||||
INSERT INTO chat_request_notifications (session_id, mitra_id)
|
||||
VALUES (${session.id}, ${mitra.id})
|
||||
INSERT INTO chat_request_notifications (session_id, mitra_id, active_session_count)
|
||||
VALUES (${session.id}, ${mitra.id}, ${activeCount})
|
||||
`
|
||||
// Notify mitra via WebSocket (FCM fallback if offline)
|
||||
await notifyMitra(mitra.id, {
|
||||
type: WsMessage.CHAT_REQUEST,
|
||||
session_id: session.id,
|
||||
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}
|
||||
`
|
||||
|
||||
// Mark other mitras' notifications as ignored
|
||||
// Mark other mitras' notifications as missed (another mitra accepted)
|
||||
await sql`
|
||||
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
|
||||
`
|
||||
|
||||
@@ -201,6 +209,7 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
|
||||
await notifyMitra(n.mitra_id, {
|
||||
type: WsMessage.CHAT_REQUEST_CLOSED,
|
||||
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
|
||||
`
|
||||
|
||||
// Notify mitras to dismiss
|
||||
// Notify mitras to dismiss (customer cancelled)
|
||||
const notifications = await sql`
|
||||
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, {
|
||||
type: WsMessage.CHAT_REQUEST_CLOSED,
|
||||
session_id: sessionId,
|
||||
reason: 'cancelled_by_customer',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -282,7 +292,7 @@ export const expirePairingRequest = async (sessionId) => {
|
||||
session_id: sessionId,
|
||||
})
|
||||
|
||||
// Notify mitras to dismiss
|
||||
// Notify mitras to dismiss (request expired)
|
||||
const notifications = await sql`
|
||||
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, {
|
||||
type: WsMessage.CHAT_REQUEST_CLOSED,
|
||||
session_id: sessionId,
|
||||
reason: 'expired',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getDb } from '../db/client.js'
|
||||
import { publish } from '../plugins/valkey.js'
|
||||
import { sendToSessionParticipant } from '../plugins/websocket.js'
|
||||
import { sendPushNotification } from './notification.service.js'
|
||||
import { UserType, SessionStatus, WsMessage } from '../constants.js'
|
||||
|
||||
const sql = getDb()
|
||||
@@ -85,15 +86,29 @@ const onSessionExpired = async (sessionId) => {
|
||||
`
|
||||
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 }
|
||||
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)
|
||||
sendToSessionParticipant(sessionId, UserType.MITRA, expiredData)
|
||||
const mitraSent = sendToSessionParticipant(sessionId, UserType.MITRA, expiredData)
|
||||
sendToSessionParticipant(sessionId, UserType.MITRA, {
|
||||
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
|
||||
await publish(`session:${sessionId}:status`, expiredData)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getDb } from '../db/client.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()
|
||||
|
||||
@@ -155,6 +155,42 @@ export const getSessionById = async (sessionId) => {
|
||||
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 } = {}) => {
|
||||
const offset = (page - 1) * limit
|
||||
const items = await sql`
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
<application
|
||||
android:label="client_app"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
@@ -32,4 +32,9 @@ class ApiClient {
|
||||
final response = await _dio.get(path, queryParameters: queryParameters);
|
||||
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>;
|
||||
}
|
||||
}
|
||||
|
||||
8
client_app/lib/core/api/api_client_provider.dart
Normal file
8
client_app/lib/core/api/api_client_provider.dart
Normal 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();
|
||||
26
client_app/lib/core/api/api_client_provider.g.dart
Normal file
26
client_app/lib/core/api/api_client_provider.g.dart
Normal 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
|
||||
@@ -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.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
226
client_app/lib/core/auth/auth_notifier.dart
Normal file
226
client_app/lib/core/auth/auth_notifier.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
24
client_app/lib/core/auth/auth_notifier.g.dart
Normal file
24
client_app/lib/core/auth/auth_notifier.g.dart
Normal 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
|
||||
@@ -1,69 +1,28 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:equatable/equatable.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 '../api/api_client.dart';
|
||||
import '../api/api_client_provider.dart';
|
||||
import '../constants.dart';
|
||||
|
||||
// Events
|
||||
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];
|
||||
}
|
||||
part 'chat_notifier.g.dart';
|
||||
|
||||
// States
|
||||
abstract class ChatState extends Equatable {
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
sealed class ChatData {
|
||||
const ChatData();
|
||||
}
|
||||
|
||||
class ChatInitial extends ChatState {}
|
||||
class ChatConnecting extends ChatState {}
|
||||
class ChatInitialData extends ChatData {
|
||||
const ChatInitialData();
|
||||
}
|
||||
|
||||
class ChatConnected extends ChatState {
|
||||
class ChatConnectingData extends ChatData {
|
||||
const ChatConnectingData();
|
||||
}
|
||||
|
||||
class ChatConnectedData extends ChatData {
|
||||
final List<ChatMessage> messages;
|
||||
final bool isOtherTyping;
|
||||
final int? remainingSeconds;
|
||||
@@ -72,7 +31,7 @@ class ChatConnected extends ChatState {
|
||||
final bool sessionClosing;
|
||||
final Map<String, dynamic>? extensionResponse;
|
||||
|
||||
ChatConnected({
|
||||
const ChatConnectedData({
|
||||
required this.messages,
|
||||
this.isOtherTyping = false,
|
||||
this.remainingSeconds,
|
||||
@@ -82,7 +41,7 @@ class ChatConnected extends ChatState {
|
||||
this.extensionResponse,
|
||||
});
|
||||
|
||||
ChatConnected copyWith({
|
||||
ChatConnectedData copyWith({
|
||||
List<ChatMessage>? messages,
|
||||
bool? isOtherTyping,
|
||||
int? remainingSeconds,
|
||||
@@ -91,7 +50,7 @@ class ChatConnected extends ChatState {
|
||||
bool? sessionClosing,
|
||||
Map<String, dynamic>? extensionResponse,
|
||||
}) {
|
||||
return ChatConnected(
|
||||
return ChatConnectedData(
|
||||
messages: messages ?? this.messages,
|
||||
isOtherTyping: isOtherTyping ?? this.isOtherTyping,
|
||||
remainingSeconds: remainingSeconds ?? this.remainingSeconds,
|
||||
@@ -101,16 +60,11 @@ class ChatConnected extends ChatState {
|
||||
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;
|
||||
ChatError(this.message);
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
const ChatErrorData(this.message);
|
||||
}
|
||||
|
||||
// Message model
|
||||
@@ -119,10 +73,10 @@ class ChatMessage {
|
||||
final String senderType;
|
||||
final String content;
|
||||
final String type;
|
||||
final String status; // sending, sent, delivered, read
|
||||
final String status;
|
||||
final DateTime createdAt;
|
||||
|
||||
ChatMessage({
|
||||
const ChatMessage({
|
||||
required this.id,
|
||||
required this.senderType,
|
||||
required this.content,
|
||||
@@ -143,45 +97,34 @@ class ChatMessage {
|
||||
}
|
||||
}
|
||||
|
||||
// Bloc
|
||||
class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
final ApiClient apiClient;
|
||||
@Riverpod(keepAlive: true)
|
||||
class Chat extends _$Chat {
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription? _wsSubscription;
|
||||
Timer? _typingTimer;
|
||||
|
||||
ChatBloc({required this.apiClient}) : super(ChatInitial()) {
|
||||
on<ConnectChat>(_onConnect);
|
||||
on<DisconnectChat>(_onDisconnect);
|
||||
on<SendMessage>(_onSendMessage);
|
||||
on<SendTyping>(_onSendTyping);
|
||||
on<_MessageReceived>(_onMessageReceived);
|
||||
on<_ConnectionError>(_onConnectionError);
|
||||
on<MarkMessagesDelivered>(_onMarkDelivered);
|
||||
on<MarkMessagesRead>(_onMarkRead);
|
||||
}
|
||||
ApiClient get _apiClient => ref.read(apiClientProvider);
|
||||
|
||||
Future<void> _onConnect(ConnectChat event, Emitter<ChatState> emit) async {
|
||||
emit(ChatConnecting());
|
||||
@override
|
||||
ChatData build() => const ChatInitialData();
|
||||
|
||||
Future<void> connect(String sessionId) async {
|
||||
state = const ChatConnectingData();
|
||||
|
||||
try {
|
||||
// Check session status before connecting
|
||||
final sessionInfo = await apiClient.get('/api/shared/chat/${event.sessionId}/info');
|
||||
final sessionInfo = await _apiClient.get('/api/shared/chat/$sessionId/info');
|
||||
final sessionData = sessionInfo['data'] as Map<String, dynamic>?;
|
||||
final sessionStatus = sessionData?['status'] as String?;
|
||||
if (sessionStatus == SessionStatus.completed ||
|
||||
sessionStatus == SessionStatus.cancelled ||
|
||||
sessionStatus == SessionStatus.expired) {
|
||||
emit(ChatError('Sesi sudah berakhir.'));
|
||||
state = const ChatErrorData('Sesi sudah berakhir.');
|
||||
return;
|
||||
}
|
||||
|
||||
final isClosing = sessionStatus == SessionStatus.closing;
|
||||
|
||||
// Load existing messages from API
|
||||
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 messages = messagesData.map((m) => ChatMessage(
|
||||
id: m['id'] as String,
|
||||
@@ -192,7 +135,6 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
createdAt: DateTime.parse(m['created_at'] as String),
|
||||
)).toList();
|
||||
|
||||
// Connect WebSocket
|
||||
final user = FirebaseAuth.instance.currentUser;
|
||||
final token = await user?.getIdToken();
|
||||
final wsUrl = ApiClient.baseUrl
|
||||
@@ -203,86 +145,82 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
_wsSubscription = _channel!.stream.listen(
|
||||
(raw) {
|
||||
final data = jsonDecode(raw as String) as Map<String, dynamic>;
|
||||
add(_MessageReceived(data));
|
||||
_onMessageReceived(data);
|
||||
},
|
||||
onError: (_) => add(_ConnectionError()),
|
||||
onDone: () => add(_ConnectionError()),
|
||||
onError: (_) {},
|
||||
onDone: () {},
|
||||
);
|
||||
|
||||
// Send auth message
|
||||
_channel!.sink.add(jsonEncode({
|
||||
'type': WsMessage.auth,
|
||||
'token': token,
|
||||
'session_id': event.sessionId,
|
||||
'session_id': sessionId,
|
||||
}));
|
||||
|
||||
emit(ChatConnected(
|
||||
state = ChatConnectedData(
|
||||
messages: messages,
|
||||
sessionClosing: isClosing,
|
||||
));
|
||||
);
|
||||
} 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();
|
||||
emit(ChatInitial());
|
||||
state = const ChatInitialData();
|
||||
}
|
||||
|
||||
void _onSendMessage(SendMessage event, Emitter<ChatState> emit) {
|
||||
if (state is! ChatConnected || _channel == null) return;
|
||||
final current = state as ChatConnected;
|
||||
void sendMessage(String content) {
|
||||
if (state is! ChatConnectedData || _channel == null) return;
|
||||
final current = state as ChatConnectedData;
|
||||
|
||||
// Add message locally with 'sending' status
|
||||
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
|
||||
final msg = ChatMessage(
|
||||
id: tempId,
|
||||
senderType: UserType.customer,
|
||||
content: event.content,
|
||||
content: content,
|
||||
status: 'sending',
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
emit(current.copyWith(messages: [...current.messages, msg]));
|
||||
state = current.copyWith(messages: [...current.messages, msg]);
|
||||
|
||||
_channel!.sink.add(jsonEncode({
|
||||
'type': WsMessage.message,
|
||||
'content': event.content,
|
||||
'content': content,
|
||||
'_temp_id': tempId,
|
||||
}));
|
||||
}
|
||||
|
||||
void _onSendTyping(SendTyping event, Emitter<ChatState> emit) {
|
||||
void sendTyping() {
|
||||
if (_channel == null) return;
|
||||
_channel!.sink.add(jsonEncode({'type': WsMessage.typing}));
|
||||
}
|
||||
|
||||
void _onMarkDelivered(MarkMessagesDelivered event, Emitter<ChatState> emit) {
|
||||
void markDelivered(List<String> messageIds) {
|
||||
if (_channel == null) return;
|
||||
_channel!.sink.add(jsonEncode({
|
||||
'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;
|
||||
_channel!.sink.add(jsonEncode({
|
||||
'type': WsMessage.read,
|
||||
'message_ids': event.messageIds,
|
||||
'message_ids': messageIds,
|
||||
}));
|
||||
}
|
||||
|
||||
void _onMessageReceived(_MessageReceived event, Emitter<ChatState> emit) {
|
||||
if (state is! ChatConnected) return;
|
||||
final current = state as ChatConnected;
|
||||
final data = event.data;
|
||||
void _onMessageReceived(Map<String, dynamic> data) {
|
||||
if (state is! ChatConnectedData) return;
|
||||
final current = state as ChatConnectedData;
|
||||
final type = data['type'] as String?;
|
||||
|
||||
switch (type) {
|
||||
case WsMessage.authOk:
|
||||
// Already connected
|
||||
break;
|
||||
|
||||
case WsMessage.message:
|
||||
@@ -294,9 +232,8 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
status: MessageStatus.sent,
|
||||
createdAt: DateTime.parse(data['created_at'] as String),
|
||||
);
|
||||
emit(current.copyWith(messages: [...current.messages, msg]));
|
||||
// Auto-acknowledge delivery
|
||||
add(MarkMessagesDelivered([msg.id]));
|
||||
state = current.copyWith(messages: [...current.messages, msg]);
|
||||
markDelivered([msg.id]);
|
||||
break;
|
||||
|
||||
case WsMessage.messageAck:
|
||||
@@ -308,7 +245,6 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
}
|
||||
return m;
|
||||
}).toList();
|
||||
// Replace temp ID with real ID
|
||||
final idx = updatedMessages.indexWhere((m) => m.status == status && m.senderType == UserType.customer);
|
||||
if (idx >= 0) {
|
||||
final old = updatedMessages[idx];
|
||||
@@ -321,7 +257,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
createdAt: old.createdAt,
|
||||
);
|
||||
}
|
||||
emit(current.copyWith(messages: updatedMessages));
|
||||
state = current.copyWith(messages: updatedMessages);
|
||||
break;
|
||||
|
||||
case WsMessage.messageStatus:
|
||||
@@ -333,47 +269,47 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
}
|
||||
return m;
|
||||
}).toList();
|
||||
emit(current.copyWith(messages: updatedMessages));
|
||||
state = current.copyWith(messages: updatedMessages);
|
||||
break;
|
||||
|
||||
case WsMessage.typing:
|
||||
emit(current.copyWith(isOtherTyping: true));
|
||||
state = current.copyWith(isOtherTyping: true);
|
||||
_typingTimer?.cancel();
|
||||
_typingTimer = Timer(const Duration(seconds: 3), () {
|
||||
if (state is ChatConnected) {
|
||||
emit((state as ChatConnected).copyWith(isOtherTyping: false));
|
||||
if (state is ChatConnectedData) {
|
||||
state = (state as ChatConnectedData).copyWith(isOtherTyping: false);
|
||||
}
|
||||
});
|
||||
break;
|
||||
|
||||
case WsMessage.sessionTimer:
|
||||
final remaining = data['remaining_seconds'] as int?;
|
||||
emit(current.copyWith(remainingSeconds: remaining));
|
||||
state = current.copyWith(remainingSeconds: remaining);
|
||||
break;
|
||||
|
||||
case WsMessage.sessionExpired:
|
||||
emit(current.copyWith(sessionExpired: true));
|
||||
state = current.copyWith(sessionExpired: true);
|
||||
break;
|
||||
|
||||
case WsMessage.sessionPaused:
|
||||
emit(current.copyWith(sessionPaused: true));
|
||||
state = current.copyWith(sessionPaused: true);
|
||||
break;
|
||||
|
||||
case WsMessage.sessionResumed:
|
||||
emit(current.copyWith(sessionPaused: false, sessionExpired: false, sessionClosing: false));
|
||||
state = current.copyWith(sessionPaused: false, sessionExpired: false, sessionClosing: false);
|
||||
break;
|
||||
|
||||
case WsMessage.sessionClosing:
|
||||
emit(current.copyWith(sessionClosing: true));
|
||||
state = current.copyWith(sessionClosing: true);
|
||||
break;
|
||||
|
||||
case WsMessage.extensionResponse:
|
||||
final accepted = data['accepted'] as bool? ?? false;
|
||||
emit(current.copyWith(
|
||||
state = current.copyWith(
|
||||
extensionResponse: data,
|
||||
sessionPaused: accepted ? false : current.sessionPaused,
|
||||
sessionExpired: accepted ? false : current.sessionExpired,
|
||||
));
|
||||
);
|
||||
break;
|
||||
|
||||
case WsMessage.sessionCompleted:
|
||||
@@ -381,15 +317,10 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
break;
|
||||
|
||||
case WsMessage.error:
|
||||
// Keep connected but show error
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _onConnectionError(_ConnectionError event, Emitter<ChatState> emit) {
|
||||
// Could implement reconnection logic here
|
||||
}
|
||||
|
||||
void _cleanup() {
|
||||
_wsSubscription?.cancel();
|
||||
_wsSubscription = null;
|
||||
@@ -398,10 +329,4 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
_typingTimer?.cancel();
|
||||
_typingTimer = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_cleanup();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
24
client_app/lib/core/chat/chat_notifier.g.dart
Normal file
24
client_app/lib/core/chat/chat_notifier.g.dart
Normal 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
|
||||
@@ -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.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
49
client_app/lib/core/chat/chat_opening_provider.dart
Normal file
49
client_app/lib/core/chat/chat_opening_provider.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
26
client_app/lib/core/chat/chat_opening_provider.g.dart
Normal file
26
client_app/lib/core/chat/chat_opening_provider.g.dart
Normal 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
|
||||
@@ -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.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
74
client_app/lib/core/chat/session_closure_notifier.dart
Normal file
74
client_app/lib/core/chat/session_closure_notifier.dart
Normal 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
26
client_app/lib/core/chat/session_closure_notifier.g.dart
Normal file
26
client_app/lib/core/chat/session_closure_notifier.g.dart
Normal 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
|
||||
50
client_app/lib/core/chat/unread_notifier.dart
Normal file
50
client_app/lib/core/chat/unread_notifier.dart
Normal 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();
|
||||
}
|
||||
24
client_app/lib/core/chat/unread_notifier.g.dart
Normal file
24
client_app/lib/core/chat/unread_notifier.g.dart
Normal 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
|
||||
@@ -84,12 +84,17 @@ class NotificationService {
|
||||
}
|
||||
|
||||
static void _navigateFromMessage(Map<String, dynamic> data) {
|
||||
if (_router == null) return;
|
||||
final sessionId = data['session_id'] as String?;
|
||||
if (sessionId == null || _router == null) return;
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
183
client_app/lib/core/pairing/pairing_notifier.dart
Normal file
183
client_app/lib/core/pairing/pairing_notifier.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
24
client_app/lib/core/pairing/pairing_notifier.g.dart
Normal file
24
client_app/lib/core/pairing/pairing_notifier.g.dart
Normal 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
|
||||
@@ -1,15 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/auth/auth_bloc.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/auth/auth_notifier.dart';
|
||||
|
||||
class DisplayNameScreen extends StatefulWidget {
|
||||
class DisplayNameScreen extends ConsumerStatefulWidget {
|
||||
const DisplayNameScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DisplayNameScreen> createState() => _DisplayNameScreenState();
|
||||
ConsumerState<DisplayNameScreen> createState() => _DisplayNameScreenState();
|
||||
}
|
||||
|
||||
class _DisplayNameScreenState extends State<DisplayNameScreen> {
|
||||
class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> {
|
||||
final _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
@@ -21,18 +21,21 @@ class _DisplayNameScreenState extends State<DisplayNameScreen> {
|
||||
void _submit() {
|
||||
final name = _controller.text.trim();
|
||||
if (name.isEmpty) return;
|
||||
context.read<AuthBloc>().add(AnonymousLoginRequested(name));
|
||||
ref.read(authProvider.notifier).loginAnonymous(name);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<AuthBloc, AuthState>(
|
||||
listener: (context, state) {
|
||||
if (state is AuthError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message)));
|
||||
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())));
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Siapa namamu?')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
@@ -51,18 +54,15 @@ class _DisplayNameScreenState extends State<DisplayNameScreen> {
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) => ElevatedButton(
|
||||
onPressed: state is AuthLoading ? null : _submit,
|
||||
child: state is AuthLoading
|
||||
ElevatedButton(
|
||||
onPressed: isLoading ? null : _submit,
|
||||
child: isLoading
|
||||
? const CircularProgressIndicator()
|
||||
: const Text('Lanjut'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
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/auth/auth_bloc.dart';
|
||||
import '../../../core/auth/auth_notifier.dart';
|
||||
|
||||
/// Shown when anonymity is disabled by admin.
|
||||
/// User must link their account. Display name is pre-filled.
|
||||
class ForceRegisterScreen extends StatefulWidget {
|
||||
class ForceRegisterScreen extends ConsumerStatefulWidget {
|
||||
const ForceRegisterScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ForceRegisterScreen> createState() => _ForceRegisterScreenState();
|
||||
ConsumerState<ForceRegisterScreen> createState() => _ForceRegisterScreenState();
|
||||
}
|
||||
|
||||
class _ForceRegisterScreenState extends State<ForceRegisterScreen> {
|
||||
class _ForceRegisterScreenState extends ConsumerState<ForceRegisterScreen> {
|
||||
final _phoneController = TextEditingController();
|
||||
|
||||
@override
|
||||
@@ -23,20 +23,24 @@ class _ForceRegisterScreenState extends State<ForceRegisterScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<AuthBloc, AuthState>(
|
||||
listener: (context, state) {
|
||||
if (state is AuthOtpSent) {
|
||||
final authState = ref.watch(authProvider);
|
||||
final isLoading = authState is AsyncLoading;
|
||||
|
||||
ref.listen(authProvider, (prev, next) {
|
||||
final data = next.valueOrNull;
|
||||
if (data is AuthOtpSentData) {
|
||||
context.push('/auth/otp', extra: _phoneController.text.trim());
|
||||
}
|
||||
if (state is AuthAuthenticated) {
|
||||
// After linking, link account to existing anonymous record
|
||||
context.read<AuthBloc>().add(LinkAccountRequested());
|
||||
if (data is AuthAuthenticatedData) {
|
||||
// After social login succeeds, link account to existing anonymous record
|
||||
ref.read(authProvider.notifier).linkAccount();
|
||||
}
|
||||
if (state is AuthError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message)));
|
||||
if (next is AsyncError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Verifikasi Akun')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
@@ -48,23 +52,19 @@ class _ForceRegisterScreenState extends State<ForceRegisterScreen> {
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) => ElevatedButton.icon(
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.g_mobiledata),
|
||||
onPressed: state is AuthLoading ? null
|
||||
: () => context.read<AuthBloc>().add(GoogleLoginRequested()),
|
||||
onPressed: isLoading ? null
|
||||
: () => ref.read(authProvider.notifier).loginGoogle(),
|
||||
label: const Text('Lanjut dengan Google'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) => ElevatedButton.icon(
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.apple),
|
||||
onPressed: state is AuthLoading ? null
|
||||
: () => context.read<AuthBloc>().add(AppleLoginRequested()),
|
||||
onPressed: isLoading ? null
|
||||
: () => ref.read(authProvider.notifier).loginApple(),
|
||||
label: const Text('Lanjut dengan Apple'),
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Row(children: [
|
||||
@@ -83,22 +83,19 @@ class _ForceRegisterScreenState extends State<ForceRegisterScreen> {
|
||||
keyboardType: TextInputType.phone,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) => ElevatedButton(
|
||||
onPressed: state is AuthLoading ? null : () {
|
||||
ElevatedButton(
|
||||
onPressed: isLoading ? null : () {
|
||||
final phone = _phoneController.text.trim();
|
||||
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 Text('Kirim OTP'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/auth/auth_bloc.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/auth/auth_notifier.dart';
|
||||
|
||||
class OtpScreen extends StatefulWidget {
|
||||
class OtpScreen extends ConsumerStatefulWidget {
|
||||
final String phone;
|
||||
const OtpScreen({super.key, required this.phone});
|
||||
|
||||
@override
|
||||
State<OtpScreen> createState() => _OtpScreenState();
|
||||
ConsumerState<OtpScreen> createState() => _OtpScreenState();
|
||||
}
|
||||
|
||||
class _OtpScreenState extends State<OtpScreen> {
|
||||
class _OtpScreenState extends ConsumerState<OtpScreen> {
|
||||
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
|
||||
void dispose() {
|
||||
@@ -21,13 +32,22 @@ class _OtpScreenState extends State<OtpScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<AuthBloc, AuthState>(
|
||||
listener: (context, state) {
|
||||
if (state is AuthError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message)));
|
||||
final authState = ref.watch(authProvider);
|
||||
final isLoading = authState is AsyncLoading;
|
||||
|
||||
// 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')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
@@ -46,23 +66,19 @@ class _OtpScreenState extends State<OtpScreen> {
|
||||
maxLength: 6,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) => ElevatedButton(
|
||||
onPressed: state is AuthLoading ? null : () {
|
||||
ElevatedButton(
|
||||
onPressed: isLoading ? null : () {
|
||||
final otp = _otpController.text.trim();
|
||||
if (otp.length != 6) return;
|
||||
final verificationId = (state is AuthOtpSent) ? state.verificationId : '';
|
||||
context.read<AuthBloc>().add(OtpVerified(verificationId, otp));
|
||||
if (otp.length != 6 || _verificationId == null) return;
|
||||
ref.read(authProvider.notifier).verifyOtp(_verificationId!, otp);
|
||||
},
|
||||
child: state is AuthLoading
|
||||
child: isLoading
|
||||
? const CircularProgressIndicator()
|
||||
: const Text('Verifikasi'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
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/auth/auth_bloc.dart';
|
||||
import '../../../core/auth/auth_notifier.dart';
|
||||
|
||||
class RegisterScreen extends StatefulWidget {
|
||||
class RegisterScreen extends ConsumerStatefulWidget {
|
||||
const RegisterScreen({super.key});
|
||||
|
||||
@override
|
||||
State<RegisterScreen> createState() => _RegisterScreenState();
|
||||
ConsumerState<RegisterScreen> createState() => _RegisterScreenState();
|
||||
}
|
||||
|
||||
class _RegisterScreenState extends State<RegisterScreen> {
|
||||
class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
||||
final _phoneController = TextEditingController();
|
||||
|
||||
@override
|
||||
@@ -21,39 +21,39 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<AuthBloc, AuthState>(
|
||||
listener: (context, state) {
|
||||
if (state is AuthOtpSent) {
|
||||
final authState = ref.watch(authProvider);
|
||||
final isLoading = authState is AsyncLoading;
|
||||
|
||||
ref.listen(authProvider, (prev, next) {
|
||||
final data = next.valueOrNull;
|
||||
if (data is AuthOtpSentData) {
|
||||
context.push('/auth/otp', extra: _phoneController.text.trim());
|
||||
}
|
||||
if (state is AuthError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message)));
|
||||
if (next is AsyncError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Masuk / Daftar')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) => ElevatedButton.icon(
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.g_mobiledata),
|
||||
onPressed: state is AuthLoading ? null
|
||||
: () => context.read<AuthBloc>().add(GoogleLoginRequested()),
|
||||
onPressed: isLoading ? null
|
||||
: () => ref.read(authProvider.notifier).loginGoogle(),
|
||||
label: const Text('Lanjut dengan Google'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) => ElevatedButton.icon(
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.apple),
|
||||
onPressed: state is AuthLoading ? null
|
||||
: () => context.read<AuthBloc>().add(AppleLoginRequested()),
|
||||
onPressed: isLoading ? null
|
||||
: () => ref.read(authProvider.notifier).loginApple(),
|
||||
label: const Text('Lanjut dengan Apple'),
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Row(children: [
|
||||
@@ -72,22 +72,19 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
||||
keyboardType: TextInputType.phone,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) => ElevatedButton(
|
||||
onPressed: state is AuthLoading ? null : () {
|
||||
ElevatedButton(
|
||||
onPressed: isLoading ? null : () {
|
||||
final phone = _phoneController.text.trim();
|
||||
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 Text('Kirim OTP'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
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/pairing/pairing_bloc.dart';
|
||||
import '../../../core/pairing/pairing_notifier.dart';
|
||||
|
||||
class BestieFoundScreen extends StatelessWidget {
|
||||
class BestieFoundScreen extends ConsumerWidget {
|
||||
final String sessionId;
|
||||
final String mitraName;
|
||||
|
||||
@@ -14,14 +14,14 @@ class BestieFoundScreen extends StatelessWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<PairingBloc, PairingState>(
|
||||
listener: (context, state) {
|
||||
if (state is PairingActive) {
|
||||
context.go('/chat/session/${state.sessionId}', extra: state.mitraName);
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ref.listen(pairingProvider, (prev, next) {
|
||||
if (next is PairingActiveData) {
|
||||
context.go('/chat/session/${next.sessionId}', extra: next.mitraName);
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
@@ -46,7 +46,6 @@ class BestieFoundScreen extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
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.dart';
|
||||
import '../../../core/api/api_client_provider.dart';
|
||||
|
||||
class ChatHistoryScreen extends StatefulWidget {
|
||||
class ChatHistoryScreen extends ConsumerStatefulWidget {
|
||||
const ChatHistoryScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ChatHistoryScreen> createState() => _ChatHistoryScreenState();
|
||||
ConsumerState<ChatHistoryScreen> createState() => _ChatHistoryScreenState();
|
||||
}
|
||||
|
||||
class _ChatHistoryScreenState extends State<ChatHistoryScreen> {
|
||||
class _ChatHistoryScreenState extends ConsumerState<ChatHistoryScreen> {
|
||||
List<Map<String, dynamic>> _sessions = [];
|
||||
bool _loading = true;
|
||||
|
||||
@@ -22,7 +22,7 @@ class _ChatHistoryScreenState extends State<ChatHistoryScreen> {
|
||||
|
||||
Future<void> _loadHistory() async {
|
||||
try {
|
||||
final api = context.read<ApiClient>();
|
||||
final api = ref.read(apiClientProvider);
|
||||
final response = await api.get('/api/client/chat/history');
|
||||
final items = (response['data']['items'] as List<dynamic>).cast<Map<String, dynamic>>();
|
||||
setState(() {
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import 'dart:async';
|
||||
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/chat/chat_bloc.dart';
|
||||
import '../../../core/chat/session_closure_bloc.dart';
|
||||
import '../../../core/chat/chat_notifier.dart';
|
||||
import '../../../core/chat/session_closure_notifier.dart';
|
||||
import '../../../core/constants.dart';
|
||||
import '../widgets/pricing_bottom_sheet.dart';
|
||||
|
||||
class ChatScreen extends StatefulWidget {
|
||||
class ChatScreen extends ConsumerStatefulWidget {
|
||||
final String sessionId;
|
||||
final String mitraName;
|
||||
|
||||
const ChatScreen({super.key, required this.sessionId, required this.mitraName});
|
||||
|
||||
@override
|
||||
State<ChatScreen> createState() => _ChatScreenState();
|
||||
ConsumerState<ChatScreen> createState() => _ChatScreenState();
|
||||
}
|
||||
|
||||
class _ChatScreenState extends State<ChatScreen> {
|
||||
class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
final _messageController = TextEditingController();
|
||||
final _scrollController = ScrollController();
|
||||
Timer? _typingThrottle;
|
||||
@@ -25,12 +25,12 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
context.read<ChatBloc>().add(ConnectChat(widget.sessionId));
|
||||
ref.read(chatProvider.notifier).connect(widget.sessionId);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
context.read<ChatBloc>().add(DisconnectChat());
|
||||
ref.read(chatProvider.notifier).disconnect();
|
||||
_messageController.dispose();
|
||||
_scrollController.dispose();
|
||||
_typingThrottle?.cancel();
|
||||
@@ -51,122 +51,100 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
|
||||
void _onTextChanged(String text) {
|
||||
if (_typingThrottle?.isActive ?? false) return;
|
||||
context.read<ChatBloc>().add(SendTyping());
|
||||
ref.read(chatProvider.notifier).sendTyping();
|
||||
_typingThrottle = Timer(const Duration(seconds: 2), () {});
|
||||
}
|
||||
|
||||
void _sendMessage() {
|
||||
final text = _messageController.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
context.read<ChatBloc>().add(SendMessage(text));
|
||||
ref.read(chatProvider.notifier).sendMessage(text);
|
||||
_messageController.clear();
|
||||
_scrollToBottom();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocListener(
|
||||
listeners: [
|
||||
BlocListener<ChatBloc, ChatState>(
|
||||
listenWhen: (prev, curr) {
|
||||
if (prev is ChatConnected && curr is ChatConnected) {
|
||||
return prev.sessionExpired != curr.sessionExpired ||
|
||||
prev.sessionClosing != curr.sessionClosing ||
|
||||
prev.sessionPaused != curr.sessionPaused ||
|
||||
prev.messages.length != curr.messages.length;
|
||||
final chatState = ref.watch(chatProvider);
|
||||
final closureState = ref.watch(sessionClosureProvider);
|
||||
|
||||
// Listen for closure complete to navigate home
|
||||
ref.listen(sessionClosureProvider, (prev, next) {
|
||||
if (next is ClosureCompleteData) {
|
||||
context.go('/home');
|
||||
}
|
||||
return true;
|
||||
},
|
||||
listener: (context, state) {
|
||||
if (state is ChatConnected) {
|
||||
// Only trigger goodbye if closing AND not expired (expired shows extend dialog first)
|
||||
if (state.sessionClosing && !state.sessionExpired) {
|
||||
final closureState = context.read<SessionClosureBloc>().state;
|
||||
if (closureState is ClosureInitial) {
|
||||
context.read<SessionClosureBloc>().add(DeclineExtension());
|
||||
});
|
||||
|
||||
// Listen for chat state changes to manage closure state
|
||||
ref.listen(chatProvider, (prev, next) {
|
||||
if (next is ChatConnectedData) {
|
||||
if (next.sessionClosing && !next.sessionExpired) {
|
||||
final closure = ref.read(sessionClosureProvider);
|
||||
if (closure is ClosureInitialData) {
|
||||
ref.read(sessionClosureProvider.notifier).declineExtension();
|
||||
}
|
||||
}
|
||||
// Extension accepted — reset closure bloc to go back to chat
|
||||
if (!state.sessionPaused && !state.sessionExpired && !state.sessionClosing) {
|
||||
final closureState = context.read<SessionClosureBloc>().state;
|
||||
if (closureState is! ClosureInitial) {
|
||||
context.read<SessionClosureBloc>().add(ResetClosure());
|
||||
if (!next.sessionPaused && !next.sessionExpired && !next.sessionClosing) {
|
||||
final closure = ref.read(sessionClosureProvider);
|
||||
if (closure is! ClosureInitialData) {
|
||||
ref.read(sessionClosureProvider.notifier).reset();
|
||||
}
|
||||
}
|
||||
_scrollToBottom();
|
||||
// Auto-mark received messages as read
|
||||
final unread = state.messages
|
||||
final unread = next.messages
|
||||
.where((m) => m.senderType == UserType.mitra && m.status != MessageStatus.read)
|
||||
.map((m) => m.id)
|
||||
.toList();
|
||||
if (unread.isNotEmpty) {
|
||||
context.read<ChatBloc>().add(MarkMessagesRead(unread));
|
||||
ref.read(chatProvider.notifier).markRead(unread);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
BlocListener<SessionClosureBloc, SessionClosureState>(
|
||||
listener: (context, state) {
|
||||
if (state is ClosureComplete) {
|
||||
context.go('/home');
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
child: Scaffold(
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.mitraName),
|
||||
automaticallyImplyLeading: false,
|
||||
actions: [
|
||||
BlocBuilder<ChatBloc, ChatState>(
|
||||
builder: (context, state) {
|
||||
if (state is ChatConnected && state.remainingSeconds != null) {
|
||||
return Padding(
|
||||
if (chatState is ChatConnectedData && chatState.remainingSeconds != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${state.remainingSeconds}s',
|
||||
'${chatState.remainingSeconds}s',
|
||||
style: TextStyle(
|
||||
color: state.remainingSeconds! < 30 ? Colors.red : null,
|
||||
color: chatState.remainingSeconds! < 30 ? Colors.red : null,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: BlocBuilder<ChatBloc, ChatState>(
|
||||
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();
|
||||
},
|
||||
),
|
||||
),
|
||||
body: _buildBody(chatState, closureState),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChatBody(BuildContext context, ChatConnected state) {
|
||||
// Show goodbye input (takes priority — user already decided to close)
|
||||
final closureState = context.watch<SessionClosureBloc>().state;
|
||||
if (closureState is ClosureShowGoodbye || closureState is ClosureSubmitting) {
|
||||
return _buildGoodbyeView(context, closureState);
|
||||
Widget _buildBody(ChatData chatState, SessionClosureData closureState) {
|
||||
if (chatState is ChatConnectingData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
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) {
|
||||
return _buildExpiredView(context);
|
||||
return _buildExpiredView();
|
||||
}
|
||||
|
||||
if (state.sessionPaused) {
|
||||
@@ -195,7 +173,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
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(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
@@ -280,7 +258,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildExpiredView(BuildContext context) {
|
||||
Widget _buildExpiredView() {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
@@ -289,9 +267,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
duration: const Duration(seconds: 300),
|
||||
builder: (context, remaining, _) {
|
||||
if (remaining <= 0) {
|
||||
// Auto-decline when countdown reaches 0
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<SessionClosureBloc>().add(DeclineExtension());
|
||||
ref.read(sessionClosureProvider.notifier).declineExtension();
|
||||
});
|
||||
}
|
||||
final minutes = remaining ~/ 60;
|
||||
@@ -320,7 +297,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextButton(
|
||||
onPressed: () => context.read<SessionClosureBloc>().add(DeclineExtension()),
|
||||
onPressed: () => ref.read(sessionClosureProvider.notifier).declineExtension(),
|
||||
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();
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(32),
|
||||
@@ -354,17 +331,17 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: closureState is ClosureSubmitting
|
||||
onPressed: closureState is ClosureSubmittingData
|
||||
? null
|
||||
: () {
|
||||
final text = controller.text.trim();
|
||||
if (text.isNotEmpty) {
|
||||
context.read<SessionClosureBloc>().add(
|
||||
SubmitGoodbye(sessionId: widget.sessionId, message: text),
|
||||
ref.read(sessionClosureProvider.notifier).submitGoodbye(
|
||||
widget.sessionId, text,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: closureState is ClosureSubmitting
|
||||
child: closureState is ClosureSubmittingData
|
||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Text('Kirim & Selesai'),
|
||||
),
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/api/api_client.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/api/api_client_provider.dart';
|
||||
import '../../../core/constants.dart';
|
||||
|
||||
class ChatTranscriptScreen extends StatefulWidget {
|
||||
class ChatTranscriptScreen extends ConsumerStatefulWidget {
|
||||
final String sessionId;
|
||||
|
||||
const ChatTranscriptScreen({super.key, required this.sessionId});
|
||||
|
||||
@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>> _closures = [];
|
||||
bool _loading = true;
|
||||
@@ -25,7 +25,7 @@ class _ChatTranscriptScreenState extends State<ChatTranscriptScreen> {
|
||||
|
||||
Future<void> _loadTranscript() async {
|
||||
try {
|
||||
final api = context.read<ApiClient>();
|
||||
final api = ref.read(apiClientProvider);
|
||||
final response = await api.get('/api/shared/chat/${widget.sessionId}/transcript');
|
||||
final data = response['data'] as Map<String, dynamic>;
|
||||
setState(() {
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
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/pairing/pairing_bloc.dart';
|
||||
import '../../../core/pairing/pairing_notifier.dart';
|
||||
|
||||
class SearchingScreen extends StatelessWidget {
|
||||
class SearchingScreen extends ConsumerWidget {
|
||||
const SearchingScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<PairingBloc, PairingState>(
|
||||
listener: (context, state) {
|
||||
if (state is PairingBestieFound) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ref.listen(pairingProvider, (prev, next) {
|
||||
if (next is PairingBestieFoundData) {
|
||||
context.go('/chat/found', extra: {
|
||||
'sessionId': state.sessionId,
|
||||
'mitraName': state.mitraName,
|
||||
'sessionId': next.sessionId,
|
||||
'mitraName': next.mitraName,
|
||||
});
|
||||
} else if (state is PairingNoBestie) {
|
||||
} else if (next is PairingNoBestieData) {
|
||||
context.go('/chat/no-bestie');
|
||||
} else if (state is PairingCancelled) {
|
||||
} else if (next is PairingCancelledData) {
|
||||
context.go('/home');
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
@@ -42,14 +42,13 @@ class SearchingScreen extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
OutlinedButton(
|
||||
onPressed: () => context.read<PairingBloc>().add(CancelPairing()),
|
||||
onPressed: () => ref.read(pairingProvider.notifier).cancelPairing(),
|
||||
child: const Text('Batalkan'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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.dart';
|
||||
import '../../../core/api/api_client_provider.dart';
|
||||
|
||||
class SessionActiveScreen extends StatelessWidget {
|
||||
class SessionActiveScreen extends ConsumerWidget {
|
||||
final String sessionId;
|
||||
final String mitraName;
|
||||
|
||||
@@ -14,7 +14,7 @@ class SessionActiveScreen extends StatelessWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Sesi Aktif'),
|
||||
@@ -42,7 +42,7 @@ class SessionActiveScreen extends StatelessWidget {
|
||||
const SizedBox(height: 48),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
||||
onPressed: () => _endSession(context),
|
||||
onPressed: () => _endSession(context, ref),
|
||||
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>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
@@ -67,7 +67,7 @@ class SessionActiveScreen extends StatelessWidget {
|
||||
|
||||
if (confirmed == true && context.mounted) {
|
||||
try {
|
||||
final apiClient = context.read<ApiClient>();
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
await apiClient.post('/api/client/chat/session/$sessionId/end');
|
||||
if (context.mounted) context.go('/home');
|
||||
} catch (_) {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/api/api_client.dart';
|
||||
import '../../../core/chat/chat_opening_bloc.dart';
|
||||
import '../../../core/chat/session_closure_bloc.dart';
|
||||
import '../../../core/pairing/pairing_bloc.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/chat/chat_opening_provider.dart';
|
||||
import '../../../core/chat/session_closure_notifier.dart';
|
||||
import '../../../core/pairing/pairing_notifier.dart';
|
||||
|
||||
class PricingBottomSheet extends StatelessWidget {
|
||||
class PricingBottomSheet extends ConsumerWidget {
|
||||
/// If set, the bottom sheet is in "extension" mode — selecting a tier extends the session.
|
||||
final String? extensionSessionId;
|
||||
|
||||
@@ -16,15 +15,7 @@ class PricingBottomSheet extends StatelessWidget {
|
||||
return showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => BlocProvider(
|
||||
create: (ctx) => ChatOpeningBloc(apiClient: ctx.read<ApiClient>())..add(LoadPricing()),
|
||||
child: MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: context.read<PairingBloc>()),
|
||||
],
|
||||
child: const PricingBottomSheet(),
|
||||
),
|
||||
),
|
||||
builder: (_) => const PricingBottomSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,15 +24,7 @@ class PricingBottomSheet extends StatelessWidget {
|
||||
return showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => BlocProvider(
|
||||
create: (ctx) => ChatOpeningBloc(apiClient: ctx.read<ApiClient>())..add(LoadPricing()),
|
||||
child: MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: context.read<SessionClosureBloc>()),
|
||||
],
|
||||
child: PricingBottomSheet(extensionSessionId: sessionId),
|
||||
),
|
||||
),
|
||||
builder: (_) => PricingBottomSheet(extensionSessionId: sessionId),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,27 +39,20 @@ class PricingBottomSheet extends StatelessWidget {
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isExtension = extensionSessionId != null;
|
||||
final pricingAsync = ref.watch(chatPricingProvider);
|
||||
|
||||
return BlocBuilder<ChatOpeningBloc, ChatOpeningState>(
|
||||
builder: (context, state) {
|
||||
if (state is PricingLoading || state is PricingInitial) {
|
||||
return const SizedBox(
|
||||
return pricingAsync.when(
|
||||
loading: () => const SizedBox(
|
||||
height: 200,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is PricingError) {
|
||||
return SizedBox(
|
||||
),
|
||||
error: (error, _) => SizedBox(
|
||||
height: 200,
|
||||
child: Center(child: Text(state.message)),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is PricingLoaded) {
|
||||
return DraggableScrollableSheet(
|
||||
child: Center(child: Text('Gagal memuat harga. Coba lagi.')),
|
||||
),
|
||||
data: (pricing) => DraggableScrollableSheet(
|
||||
initialChildSize: 0.6,
|
||||
minChildSize: 0.4,
|
||||
maxChildSize: 0.8,
|
||||
@@ -93,23 +69,23 @@ class PricingBottomSheet extends StatelessWidget {
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (!isExtension && state.freeTrialEligible) ...[
|
||||
if (!isExtension && pricing.freeTrialEligible) ...[
|
||||
Card(
|
||||
color: Colors.green.shade50,
|
||||
child: ListTile(
|
||||
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!'),
|
||||
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
_startPairing(context, isFreeTrial: true);
|
||||
_startPairing(ref, isFreeTrial: true);
|
||||
},
|
||||
),
|
||||
),
|
||||
const Divider(height: 24),
|
||||
],
|
||||
...state.tiers.map((tier) => Card(
|
||||
...pricing.tiers.map((tier) => Card(
|
||||
child: ListTile(
|
||||
title: Text(tier.label),
|
||||
trailing: Text(
|
||||
@@ -120,14 +96,14 @@ class PricingBottomSheet extends StatelessWidget {
|
||||
Navigator.of(context).pop();
|
||||
if (isExtension) {
|
||||
_requestExtension(
|
||||
context,
|
||||
ref,
|
||||
sessionId: extensionSessionId!,
|
||||
durationMinutes: tier.durationMinutes,
|
||||
price: tier.price,
|
||||
);
|
||||
} else {
|
||||
_startPairing(
|
||||
context,
|
||||
ref,
|
||||
durationMinutes: tier.durationMinutes,
|
||||
price: tier.price,
|
||||
);
|
||||
@@ -139,27 +115,23 @@ class PricingBottomSheet extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _startPairing(BuildContext context, {bool isFreeTrial = false, int? durationMinutes, int? price}) {
|
||||
context.read<PairingBloc>().add(RequestPairingWithTier(
|
||||
void _startPairing(WidgetRef ref, {bool isFreeTrial = false, int? durationMinutes, int? price}) {
|
||||
ref.read(pairingProvider.notifier).requestPairingWithTier(
|
||||
durationMinutes: durationMinutes,
|
||||
price: price,
|
||||
isFreeTrial: isFreeTrial,
|
||||
));
|
||||
);
|
||||
}
|
||||
|
||||
void _requestExtension(BuildContext context, {required String sessionId, required int durationMinutes, required int price}) {
|
||||
context.read<SessionClosureBloc>().add(RequestExtension(
|
||||
sessionId: sessionId,
|
||||
void _requestExtension(WidgetRef ref, {required String sessionId, required int durationMinutes, required int price}) {
|
||||
ref.read(sessionClosureProvider.notifier).requestExtension(
|
||||
sessionId,
|
||||
durationMinutes: durationMinutes,
|
||||
price: price,
|
||||
));
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
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/auth/auth_bloc.dart';
|
||||
import '../../core/api/api_client.dart';
|
||||
import '../../core/pairing/pairing_bloc.dart';
|
||||
import '../../core/auth/auth_notifier.dart';
|
||||
import '../../core/api/api_client_provider.dart';
|
||||
import '../../core/chat/unread_notifier.dart';
|
||||
import '../../core/pairing/pairing_notifier.dart';
|
||||
import '../chat/widgets/pricing_bottom_sheet.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
class HomeScreen extends ConsumerStatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@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;
|
||||
bool _loadingSession = true;
|
||||
|
||||
@@ -40,13 +41,12 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
// Re-check when navigating back to this screen
|
||||
_checkActiveSession();
|
||||
}
|
||||
|
||||
Future<void> _checkActiveSession() async {
|
||||
try {
|
||||
final apiClient = context.read<ApiClient>();
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
final response = await apiClient.get('/api/client/chat/session/active');
|
||||
final data = response['data'];
|
||||
if (mounted) {
|
||||
@@ -62,25 +62,26 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<PairingBloc, PairingState>(
|
||||
listener: (context, state) {
|
||||
if (state is PairingSearching) {
|
||||
final authState = ref.watch(authProvider);
|
||||
final authData = authState.valueOrNull;
|
||||
|
||||
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');
|
||||
} else if (state is PairingNoBestie) {
|
||||
} else if (next is PairingNoBestieData) {
|
||||
context.go('/chat/no-bestie');
|
||||
} else if (state is PairingError) {
|
||||
} else if (next is PairingErrorData) {
|
||||
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(
|
||||
appBar: AppBar(
|
||||
@@ -92,7 +93,7 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
|
||||
),
|
||||
IconButton(
|
||||
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 VoidCallback onTap;
|
||||
|
||||
const _ActiveSessionCard({required this.session, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final mitraName = session['mitra_display_name'] as String? ?? 'Bestie';
|
||||
final unreadCount = ref.watch(unreadCountProvider);
|
||||
|
||||
return Card(
|
||||
elevation: 2,
|
||||
@@ -155,10 +154,14 @@ class _ActiveSessionCard extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Row(
|
||||
children: [
|
||||
const CircleAvatar(
|
||||
Badge(
|
||||
isLabelVisible: unreadCount > 0,
|
||||
label: Text('$unreadCount'),
|
||||
child: const CircleAvatar(
|
||||
backgroundColor: Colors.green,
|
||||
child: Icon(Icons.chat, color: Colors.white),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'core/api/api_client.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 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'core/api/api_client_provider.dart';
|
||||
import 'core/auth/auth_notifier.dart';
|
||||
import 'core/notifications/notification_service.dart';
|
||||
import 'firebase_options.dart';
|
||||
import 'router.dart';
|
||||
@@ -16,69 +12,53 @@ void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||
|
||||
// Request notification permission
|
||||
final messaging = FirebaseMessaging.instance;
|
||||
await messaging.requestPermission();
|
||||
|
||||
runApp(const App());
|
||||
runApp(const ProviderScope(child: App()));
|
||||
}
|
||||
|
||||
class App extends StatefulWidget {
|
||||
class App extends ConsumerStatefulWidget {
|
||||
const App({super.key});
|
||||
|
||||
@override
|
||||
State<App> createState() => _AppState();
|
||||
ConsumerState<App> createState() => _AppState();
|
||||
}
|
||||
|
||||
class _AppState extends State<App> {
|
||||
final _apiClient = ApiClient();
|
||||
late final AuthBloc _authBloc;
|
||||
late final GoRouter _router;
|
||||
class _AppState extends ConsumerState<App> {
|
||||
bool _fcmRegistered = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_authBloc = AuthBloc(apiClient: _apiClient)..add(AppStarted());
|
||||
_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) {
|
||||
void _registerFcmToken() {
|
||||
if (_fcmRegistered) return;
|
||||
_fcmRegistered = true;
|
||||
Future(() async {
|
||||
try {
|
||||
final token = await FirebaseMessaging.instance.getToken();
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: _authBloc),
|
||||
BlocProvider(create: (_) => PairingBloc(apiClient: _apiClient)),
|
||||
BlocProvider(create: (_) => ChatBloc(apiClient: _apiClient)),
|
||||
BlocProvider(create: (_) => SessionClosureBloc(apiClient: _apiClient)),
|
||||
RepositoryProvider.value(value: _apiClient),
|
||||
],
|
||||
child: MaterialApp.router(
|
||||
ref.listen(authProvider, (prev, next) {
|
||||
final data = next.valueOrNull;
|
||||
if (data is AuthAuthenticatedData || data is AuthAnonymousData) {
|
||||
_registerFcmToken();
|
||||
}
|
||||
});
|
||||
|
||||
final router = ref.watch(routerProvider);
|
||||
|
||||
NotificationService.initialize(router);
|
||||
|
||||
return MaterialApp.router(
|
||||
title: 'Halo Bestie',
|
||||
routerConfig: _router,
|
||||
),
|
||||
routerConfig: router,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.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/display_name_screen.dart';
|
||||
import 'features/auth/screens/register_screen.dart';
|
||||
import 'features/auth/screens/otp_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/home/home_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_transcript_screen.dart';
|
||||
|
||||
/// Converts a BLoC stream into a ChangeNotifier for GoRouter's refreshListenable.
|
||||
class _BlocRefreshNotifier extends ChangeNotifier {
|
||||
late final StreamSubscription _subscription;
|
||||
class RouterNotifier extends ChangeNotifier {
|
||||
final Ref _ref;
|
||||
|
||||
_BlocRefreshNotifier(AuthBloc bloc) {
|
||||
_subscription = bloc.stream.listen((_) => notifyListeners());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subscription.cancel();
|
||||
super.dispose();
|
||||
RouterNotifier(this._ref) {
|
||||
_ref.listen(authProvider, (_, __) => notifyListeners());
|
||||
}
|
||||
}
|
||||
|
||||
GoRouter buildRouter(AuthBloc authBloc) {
|
||||
final routerProvider = Provider<GoRouter>((ref) => buildRouter(ref));
|
||||
|
||||
GoRouter buildRouter(Ref ref) {
|
||||
final notifier = RouterNotifier(ref);
|
||||
|
||||
return GoRouter(
|
||||
initialLocation: '/splash',
|
||||
refreshListenable: _BlocRefreshNotifier(authBloc),
|
||||
refreshListenable: notifier,
|
||||
redirect: (context, state) {
|
||||
final authState = authBloc.state;
|
||||
final authState = ref.read(authProvider);
|
||||
final isSplash = state.matchedLocation == '/splash';
|
||||
final isAuthRoute = state.matchedLocation.startsWith('/auth') ||
|
||||
state.matchedLocation == '/welcome';
|
||||
|
||||
// Show splash while loading
|
||||
if (authState is AuthLoading) return isSplash ? null : '/splash';
|
||||
// Show splash only during initial load — don't redirect away from auth routes
|
||||
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;
|
||||
}
|
||||
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 (isSplash) return '/welcome';
|
||||
return null;
|
||||
@@ -58,6 +68,7 @@ GoRouter buildRouter(AuthBloc authBloc) {
|
||||
GoRoute(path: '/auth/display-name', builder: (_, __) => const DisplayNameScreen()),
|
||||
GoRoute(path: '/auth/register', builder: (_, __) => const RegisterScreen()),
|
||||
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: '/home', builder: (_, __) => const HomeScreen()),
|
||||
GoRoute(path: '/chat/searching', builder: (_, __) => const SearchingScreen()),
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -9,6 +17,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -25,14 +49,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
bloc:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bloc
|
||||
sha256: "106842ad6569f0b60297619e9e0b1885c2fb9bf84812935490e6c5275777804e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.1.4"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -41,6 +57,70 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -49,6 +129,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -57,6 +161,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -65,6 +177,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
convert:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: convert
|
||||
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -73,6 +193,46 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -97,14 +257,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -201,19 +353,27 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.8.7"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_bloc:
|
||||
flutter_hooks:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_bloc
|
||||
sha256: b594505eac31a0518bdcb4b5b79573b8d9117b193cc80cc12e17d639b10aa27a
|
||||
name: flutter_hooks
|
||||
sha256: cde36b12f7188c85286fba9b38cc5a902e7279f36dd676967106c041dc9dde70
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.1.6"
|
||||
version: "0.20.5"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -254,6 +414,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -264,6 +432,30 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -320,6 +512,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -328,6 +544,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -336,6 +560,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -408,14 +656,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
nested:
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: nested
|
||||
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
|
||||
name: package_config
|
||||
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
version: "2.2.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -472,14 +720,78 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
provider:
|
||||
pool:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: provider
|
||||
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
|
||||
name: pool
|
||||
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
|
||||
url: "https://pub.dev"
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -536,6 +848,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -565,6 +893,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -581,6 +917,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -589,6 +933,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -621,6 +973,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.0"
|
||||
timing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: timing
|
||||
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -629,6 +989,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: uuid
|
||||
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.3"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -645,6 +1013,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.0.2"
|
||||
watcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: watcher
|
||||
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -677,6 +1053,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.6.1"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: yaml
|
||||
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.10.0 <4.0.0"
|
||||
flutter: ">=3.38.1"
|
||||
|
||||
@@ -25,8 +25,10 @@ dependencies:
|
||||
web_socket_channel: ^2.4.5
|
||||
|
||||
# State management
|
||||
flutter_bloc: ^8.1.5
|
||||
equatable: ^2.0.5
|
||||
flutter_riverpod: ^2.6.1
|
||||
hooks_riverpod: ^2.6.1
|
||||
riverpod_annotation: ^2.6.1
|
||||
flutter_hooks: ^0.20.5
|
||||
|
||||
# Storage
|
||||
shared_preferences: ^2.2.3
|
||||
@@ -39,6 +41,10 @@ dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
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:
|
||||
uses-material-design: true
|
||||
|
||||
@@ -6,6 +6,7 @@ import MitrasPage from './pages/mitras/MitrasPage'
|
||||
import SessionsPage from './pages/sessions/SessionsPage'
|
||||
import UsersPage from './pages/users/UsersPage'
|
||||
import SettingsPage from './pages/settings/SettingsPage'
|
||||
import MitraActivityPage from './pages/mitra-activity/MitraActivityPage'
|
||||
import Layout from './components/Layout'
|
||||
|
||||
const ProtectedRoute = ({ children }) => {
|
||||
@@ -25,6 +26,7 @@ export default function App() {
|
||||
<Route path="sessions" element={<SessionsPage />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="mitra-activity" element={<MitraActivityPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ export default function Layout() {
|
||||
<li><NavLink to="/mitras">Mitra</NavLink></li>
|
||||
<li><NavLink to="/sessions">Sesi</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>
|
||||
</ul>
|
||||
<div style={{ marginTop: 'auto', paddingTop: 16 }}>
|
||||
|
||||
177
control_center/src/pages/mitra-activity/MitraActivityPage.jsx
Normal file
177
control_center/src/pages/mitra-activity/MitraActivityPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -42,6 +42,17 @@ const updateExtensionTimeoutConfig = async (extension_timeout_seconds) => {
|
||||
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 res = await apiClient.get('/internal/config/early-end')
|
||||
return res.data.data
|
||||
@@ -101,7 +112,17 @@ export default function SettingsPage() {
|
||||
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 (
|
||||
<div>
|
||||
@@ -215,6 +236,39 @@ export default function SettingsPage() {
|
||||
</label>
|
||||
{eeMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
<application
|
||||
android:label="mitra_app"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
8
mitra_app/lib/core/api/api_client_provider.dart
Normal file
8
mitra_app/lib/core/api/api_client_provider.dart
Normal 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();
|
||||
26
mitra_app/lib/core/api/api_client_provider.g.dart
Normal file
26
mitra_app/lib/core/api/api_client_provider.g.dart
Normal 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
|
||||
@@ -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.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
119
mitra_app/lib/core/auth/auth_notifier.dart
Normal file
119
mitra_app/lib/core/auth/auth_notifier.dart
Normal 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
25
mitra_app/lib/core/auth/auth_notifier.g.dart
Normal file
25
mitra_app/lib/core/auth/auth_notifier.g.dart
Normal 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
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
276
mitra_app/lib/core/chat/chat_request_notifier.dart
Normal file
276
mitra_app/lib/core/chat/chat_request_notifier.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
25
mitra_app/lib/core/chat/chat_request_notifier.g.dart
Normal file
25
mitra_app/lib/core/chat/chat_request_notifier.g.dart
Normal 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
|
||||
@@ -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.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
79
mitra_app/lib/core/chat/extension_notifier.dart
Normal file
79
mitra_app/lib/core/chat/extension_notifier.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
26
mitra_app/lib/core/chat/extension_notifier.g.dart
Normal file
26
mitra_app/lib/core/chat/extension_notifier.g.dart
Normal 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
|
||||
@@ -1,77 +1,36 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:equatable/equatable.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 '../api/api_client.dart';
|
||||
import '../api/api_client_provider.dart';
|
||||
import '../constants.dart';
|
||||
|
||||
// Events
|
||||
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];
|
||||
}
|
||||
part 'mitra_chat_notifier.g.dart';
|
||||
|
||||
// States
|
||||
abstract class MitraChatState extends Equatable {
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
sealed class MitraChatData {
|
||||
const MitraChatData();
|
||||
}
|
||||
|
||||
class ChatInitial extends MitraChatState {}
|
||||
class ChatConnecting extends MitraChatState {}
|
||||
class MitraChatInitialData extends MitraChatData {
|
||||
const MitraChatInitialData();
|
||||
}
|
||||
|
||||
class ChatConnected extends MitraChatState {
|
||||
final List<ChatMessage> messages;
|
||||
class MitraChatConnectingData extends MitraChatData {
|
||||
const MitraChatConnectingData();
|
||||
}
|
||||
|
||||
class MitraChatConnectedData extends MitraChatData {
|
||||
final List<MitraChatMessage> messages;
|
||||
final bool isOtherTyping;
|
||||
final int? remainingSeconds;
|
||||
final bool sessionExpired;
|
||||
final bool sessionClosing;
|
||||
final Map<String, dynamic>? extensionRequest;
|
||||
|
||||
ChatConnected({
|
||||
const MitraChatConnectedData({
|
||||
required this.messages,
|
||||
this.isOtherTyping = false,
|
||||
this.remainingSeconds,
|
||||
@@ -80,8 +39,8 @@ class ChatConnected extends MitraChatState {
|
||||
this.extensionRequest,
|
||||
});
|
||||
|
||||
ChatConnected copyWith({
|
||||
List<ChatMessage>? messages,
|
||||
MitraChatConnectedData copyWith({
|
||||
List<MitraChatMessage>? messages,
|
||||
bool? isOtherTyping,
|
||||
int? remainingSeconds,
|
||||
bool? sessionExpired,
|
||||
@@ -89,7 +48,7 @@ class ChatConnected extends MitraChatState {
|
||||
Map<String, dynamic>? extensionRequest,
|
||||
bool clearExtensionRequest = false,
|
||||
}) {
|
||||
return ChatConnected(
|
||||
return MitraChatConnectedData(
|
||||
messages: messages ?? this.messages,
|
||||
isOtherTyping: isOtherTyping ?? this.isOtherTyping,
|
||||
remainingSeconds: remainingSeconds ?? this.remainingSeconds,
|
||||
@@ -98,20 +57,15 @@ class ChatConnected extends MitraChatState {
|
||||
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;
|
||||
ChatError(this.message);
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
const MitraChatErrorData(this.message);
|
||||
}
|
||||
|
||||
// Message model
|
||||
class ChatMessage {
|
||||
class MitraChatMessage {
|
||||
final String id;
|
||||
final String senderType;
|
||||
final String content;
|
||||
@@ -119,7 +73,7 @@ class ChatMessage {
|
||||
final String status;
|
||||
final DateTime createdAt;
|
||||
|
||||
ChatMessage({
|
||||
const MitraChatMessage({
|
||||
required this.id,
|
||||
required this.senderType,
|
||||
required this.content,
|
||||
@@ -128,8 +82,8 @@ class ChatMessage {
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
ChatMessage copyWith({String? status}) {
|
||||
return ChatMessage(
|
||||
MitraChatMessage copyWith({String? status}) {
|
||||
return MitraChatMessage(
|
||||
id: id,
|
||||
senderType: senderType,
|
||||
content: content,
|
||||
@@ -140,44 +94,35 @@ class ChatMessage {
|
||||
}
|
||||
}
|
||||
|
||||
// Bloc
|
||||
class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
||||
final ApiClient apiClient;
|
||||
@Riverpod(keepAlive: true)
|
||||
class MitraChat extends _$MitraChat {
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription? _wsSubscription;
|
||||
Timer? _typingTimer;
|
||||
|
||||
MitraChatBloc({required this.apiClient}) : super(ChatInitial()) {
|
||||
on<ConnectChat>(_onConnect);
|
||||
on<DisconnectChat>(_onDisconnect);
|
||||
on<SendMessage>(_onSendMessage);
|
||||
on<SendTyping>(_onSendTyping);
|
||||
on<_MessageReceived>(_onMessageReceived);
|
||||
on<_ConnectionError>(_onConnectionError);
|
||||
on<MarkMessagesDelivered>(_onMarkDelivered);
|
||||
on<MarkMessagesRead>(_onMarkRead);
|
||||
}
|
||||
ApiClient get _apiClient => ref.read(apiClientProvider);
|
||||
|
||||
Future<void> _onConnect(ConnectChat event, Emitter<MitraChatState> emit) async {
|
||||
emit(ChatConnecting());
|
||||
@override
|
||||
MitraChatData build() => const MitraChatInitialData();
|
||||
|
||||
Future<void> connect(String sessionId) async {
|
||||
state = const MitraChatConnectingData();
|
||||
try {
|
||||
// Check session status before connecting
|
||||
final sessionInfo = await apiClient.get('/api/shared/chat/${event.sessionId}/info');
|
||||
final sessionInfo = await _apiClient.get('/api/shared/chat/$sessionId/info');
|
||||
final sessionData = sessionInfo['data'] as Map<String, dynamic>?;
|
||||
final sessionStatus = sessionData?['status'] as String?;
|
||||
if (sessionStatus == SessionStatus.completed ||
|
||||
sessionStatus == SessionStatus.cancelled ||
|
||||
sessionStatus == SessionStatus.expired) {
|
||||
emit(ChatError('Sesi sudah berakhir.'));
|
||||
state = const MitraChatErrorData('Sesi sudah berakhir.');
|
||||
return;
|
||||
}
|
||||
|
||||
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 messages = messagesData.map((m) => ChatMessage(
|
||||
final messages = messagesData.map((m) => MitraChatMessage(
|
||||
id: m['id'] as String,
|
||||
senderType: m['sender_type'] as String,
|
||||
content: m['content'] as String,
|
||||
@@ -196,73 +141,69 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
||||
_wsSubscription = _channel!.stream.listen(
|
||||
(raw) {
|
||||
final data = jsonDecode(raw as String) as Map<String, dynamic>;
|
||||
add(_MessageReceived(data));
|
||||
_onMessageReceived(data);
|
||||
},
|
||||
onError: (_) => add(_ConnectionError()),
|
||||
onDone: () => add(_ConnectionError()),
|
||||
onError: (_) {},
|
||||
onDone: () {},
|
||||
);
|
||||
|
||||
_channel!.sink.add(jsonEncode({
|
||||
'type': WsMessage.auth,
|
||||
'token': token,
|
||||
'session_id': event.sessionId,
|
||||
'session_id': sessionId,
|
||||
}));
|
||||
|
||||
emit(ChatConnected(
|
||||
messages: messages,
|
||||
sessionClosing: isClosing,
|
||||
));
|
||||
state = MitraChatConnectedData(messages: messages, sessionClosing: isClosing);
|
||||
} 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();
|
||||
emit(ChatInitial());
|
||||
state = const MitraChatInitialData();
|
||||
}
|
||||
|
||||
void _onSendMessage(SendMessage event, Emitter<MitraChatState> emit) {
|
||||
if (state is! ChatConnected || _channel == null) return;
|
||||
final current = state as ChatConnected;
|
||||
void sendMessage(String content) {
|
||||
if (state is! MitraChatConnectedData || _channel == null) return;
|
||||
final current = state as MitraChatConnectedData;
|
||||
|
||||
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
|
||||
final msg = ChatMessage(
|
||||
final msg = MitraChatMessage(
|
||||
id: tempId,
|
||||
senderType: UserType.mitra,
|
||||
content: event.content,
|
||||
content: content,
|
||||
status: 'sending',
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
emit(current.copyWith(messages: [...current.messages, msg]));
|
||||
state = current.copyWith(messages: [...current.messages, msg]);
|
||||
|
||||
_channel!.sink.add(jsonEncode({
|
||||
'type': WsMessage.message,
|
||||
'content': event.content,
|
||||
'content': content,
|
||||
'_temp_id': tempId,
|
||||
}));
|
||||
}
|
||||
|
||||
void _onSendTyping(SendTyping event, Emitter<MitraChatState> emit) {
|
||||
void sendTyping() {
|
||||
if (_channel == null) return;
|
||||
_channel!.sink.add(jsonEncode({'type': WsMessage.typing}));
|
||||
}
|
||||
|
||||
void _onMarkDelivered(MarkMessagesDelivered event, Emitter<MitraChatState> emit) {
|
||||
void markDelivered(List<String> messageIds) {
|
||||
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;
|
||||
_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) {
|
||||
if (state is! ChatConnected) return;
|
||||
final current = state as ChatConnected;
|
||||
final data = event.data;
|
||||
void _onMessageReceived(Map<String, dynamic> data) {
|
||||
if (state is! MitraChatConnectedData) return;
|
||||
final current = state as MitraChatConnectedData;
|
||||
final type = data['type'] as String?;
|
||||
|
||||
switch (type) {
|
||||
@@ -270,7 +211,7 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
||||
break;
|
||||
|
||||
case WsMessage.message:
|
||||
final msg = ChatMessage(
|
||||
final msg = MitraChatMessage(
|
||||
id: data['message_id'] as String,
|
||||
senderType: data['sender_type'] as String,
|
||||
content: data['content'] as String,
|
||||
@@ -278,8 +219,8 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
||||
status: MessageStatus.sent,
|
||||
createdAt: DateTime.parse(data['created_at'] as String),
|
||||
);
|
||||
emit(current.copyWith(messages: [...current.messages, msg]));
|
||||
add(MarkMessagesDelivered([msg.id]));
|
||||
state = current.copyWith(messages: [...current.messages, msg]);
|
||||
markDelivered([msg.id]);
|
||||
break;
|
||||
|
||||
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);
|
||||
if (idx >= 0) {
|
||||
final old = updatedMessages[idx];
|
||||
updatedMessages[idx] = ChatMessage(
|
||||
updatedMessages[idx] = MitraChatMessage(
|
||||
id: messageId,
|
||||
senderType: old.senderType,
|
||||
content: old.content,
|
||||
@@ -301,7 +242,7 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
||||
createdAt: old.createdAt,
|
||||
);
|
||||
}
|
||||
emit(current.copyWith(messages: updatedMessages));
|
||||
state = current.copyWith(messages: updatedMessages);
|
||||
break;
|
||||
|
||||
case WsMessage.messageStatus:
|
||||
@@ -311,37 +252,37 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
||||
if (messageIds.contains(m.id)) return m.copyWith(status: status);
|
||||
return m;
|
||||
}).toList();
|
||||
emit(current.copyWith(messages: updatedMessages));
|
||||
state = current.copyWith(messages: updatedMessages);
|
||||
break;
|
||||
|
||||
case WsMessage.typing:
|
||||
emit(current.copyWith(isOtherTyping: true));
|
||||
state = current.copyWith(isOtherTyping: true);
|
||||
_typingTimer?.cancel();
|
||||
_typingTimer = Timer(const Duration(seconds: 3), () {
|
||||
if (state is ChatConnected) {
|
||||
emit((state as ChatConnected).copyWith(isOtherTyping: false));
|
||||
if (state is MitraChatConnectedData) {
|
||||
state = (state as MitraChatConnectedData).copyWith(isOtherTyping: false);
|
||||
}
|
||||
});
|
||||
break;
|
||||
|
||||
case WsMessage.sessionTimer:
|
||||
emit(current.copyWith(remainingSeconds: data['remaining_seconds'] as int?));
|
||||
state = current.copyWith(remainingSeconds: data['remaining_seconds'] as int?);
|
||||
break;
|
||||
|
||||
case WsMessage.sessionExpired:
|
||||
emit(current.copyWith(sessionExpired: true));
|
||||
state = current.copyWith(sessionExpired: true);
|
||||
break;
|
||||
|
||||
case WsMessage.extensionRequest:
|
||||
emit(current.copyWith(extensionRequest: data));
|
||||
state = current.copyWith(extensionRequest: data);
|
||||
break;
|
||||
|
||||
case WsMessage.sessionResumed:
|
||||
emit(current.copyWith(sessionExpired: false, sessionClosing: false, clearExtensionRequest: true));
|
||||
state = current.copyWith(sessionExpired: false, sessionClosing: false, clearExtensionRequest: true);
|
||||
break;
|
||||
|
||||
case WsMessage.sessionClosing:
|
||||
emit(current.copyWith(sessionClosing: true, clearExtensionRequest: true));
|
||||
state = current.copyWith(sessionClosing: true, clearExtensionRequest: true);
|
||||
break;
|
||||
|
||||
case WsMessage.sessionCompleted:
|
||||
@@ -350,8 +291,6 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
||||
}
|
||||
}
|
||||
|
||||
void _onConnectionError(_ConnectionError event, Emitter<MitraChatState> emit) {}
|
||||
|
||||
void _cleanup() {
|
||||
_wsSubscription?.cancel();
|
||||
_wsSubscription = null;
|
||||
@@ -360,10 +299,4 @@ class MitraChatBloc extends Bloc<MitraChatEvent, MitraChatState> {
|
||||
_typingTimer?.cancel();
|
||||
_typingTimer = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_cleanup();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
24
mitra_app/lib/core/chat/mitra_chat_notifier.g.dart
Normal file
24
mitra_app/lib/core/chat/mitra_chat_notifier.g.dart
Normal 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
|
||||
54
mitra_app/lib/core/chat/unread_notifier.dart
Normal file
54
mitra_app/lib/core/chat/unread_notifier.dart
Normal 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();
|
||||
}
|
||||
26
mitra_app/lib/core/chat/unread_notifier.g.dart
Normal file
26
mitra_app/lib/core/chat/unread_notifier.g.dart
Normal 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
|
||||
269
mitra_app/lib/core/chat/widgets/chat_request_overlay.dart
Normal file
269
mitra_app/lib/core/chat/widgets/chat_request_overlay.dart
Normal 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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,10 @@ class NotificationService {
|
||||
static final _localNotifications = FlutterLocalNotificationsPlugin();
|
||||
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(
|
||||
'chat_messages',
|
||||
'Chat Messages',
|
||||
@@ -83,13 +87,53 @@ class NotificationService {
|
||||
_navigateFromMessage(message.data);
|
||||
}
|
||||
|
||||
static void _navigateFromMessage(Map<String, dynamic> data) {
|
||||
final sessionId = data['session_id'] as String?;
|
||||
if (sessionId == null || _router == null) return;
|
||||
/// Show a local notification programmatically (e.g. from WebSocket while backgrounded)
|
||||
static Future<void> showLocalNotification({
|
||||
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?;
|
||||
if (type == 'chat_message' || type == 'chat_request') {
|
||||
_router!.push('/chat/session/$sessionId');
|
||||
final action = data['action'] as String?;
|
||||
|
||||
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'});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
101
mitra_app/lib/core/status/status_notifier.dart
Normal file
101
mitra_app/lib/core/status/status_notifier.dart
Normal 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 (_) {}
|
||||
}
|
||||
}
|
||||
25
mitra_app/lib/core/status/status_notifier.g.dart
Normal file
25
mitra_app/lib/core/status/status_notifier.g.dart
Normal 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
|
||||
@@ -1,16 +1,16 @@
|
||||
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/auth/auth_bloc.dart';
|
||||
import '../../../core/auth/auth_notifier.dart';
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
class LoginScreen extends ConsumerStatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
ConsumerState<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
final _phoneController = TextEditingController();
|
||||
|
||||
@override
|
||||
@@ -21,16 +21,20 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<AuthBloc, AuthState>(
|
||||
listener: (context, state) {
|
||||
if (state is AuthOtpSent) {
|
||||
final authState = ref.watch(mitraAuthProvider);
|
||||
final isLoading = authState is AsyncLoading;
|
||||
|
||||
ref.listen(mitraAuthProvider, (prev, next) {
|
||||
final data = next.valueOrNull;
|
||||
if (data is MitraAuthOtpSentData) {
|
||||
context.push('/otp', extra: _phoneController.text.trim());
|
||||
}
|
||||
if (state is AuthError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message)));
|
||||
if (next is AsyncError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
@@ -54,23 +58,20 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
keyboardType: TextInputType.phone,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) => ElevatedButton(
|
||||
onPressed: state is AuthLoading ? null : () {
|
||||
ElevatedButton(
|
||||
onPressed: isLoading ? null : () {
|
||||
final phone = _phoneController.text.trim();
|
||||
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 Text('Kirim OTP'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/auth/auth_bloc.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/auth/auth_notifier.dart';
|
||||
|
||||
class OtpScreen extends StatefulWidget {
|
||||
class OtpScreen extends ConsumerStatefulWidget {
|
||||
final String phone;
|
||||
const OtpScreen({super.key, required this.phone});
|
||||
|
||||
@override
|
||||
State<OtpScreen> createState() => _OtpScreenState();
|
||||
ConsumerState<OtpScreen> createState() => _OtpScreenState();
|
||||
}
|
||||
|
||||
class _OtpScreenState extends State<OtpScreen> {
|
||||
class _OtpScreenState extends ConsumerState<OtpScreen> {
|
||||
final List<TextEditingController> _controllers =
|
||||
List.generate(6, (_) => TextEditingController());
|
||||
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
|
||||
void dispose() {
|
||||
@@ -50,28 +60,32 @@ class _OtpScreenState extends State<OtpScreen> {
|
||||
|
||||
void _submit() {
|
||||
final otp = _otp;
|
||||
if (otp.length != 6) return;
|
||||
final state = context.read<AuthBloc>().state;
|
||||
final verificationId = state is AuthOtpSent ? state.verificationId : '';
|
||||
context.read<AuthBloc>().add(OtpVerified(verificationId, otp));
|
||||
if (otp.length != 6 || _verificationId == null) return;
|
||||
ref.read(mitraAuthProvider.notifier).verifyOtp(_verificationId!, otp);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<AuthBloc, AuthState>(
|
||||
listener: (context, state) {
|
||||
if (state is AuthError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(state.message)),
|
||||
);
|
||||
// Clear fields on error
|
||||
final authState = ref.watch(mitraAuthProvider);
|
||||
final isLoading = authState is AsyncLoading;
|
||||
|
||||
// Update verification ID if state changes
|
||||
final data = authState.valueOrNull;
|
||||
if (data is MitraAuthOtpSentData) {
|
||||
_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) {
|
||||
c.clear();
|
||||
}
|
||||
_focusNodes[0].requestFocus();
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Masukkan OTP')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
@@ -116,18 +130,15 @@ class _OtpScreenState extends State<OtpScreen> {
|
||||
}),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) => ElevatedButton(
|
||||
onPressed: state is AuthLoading ? null : _submit,
|
||||
child: state is AuthLoading
|
||||
ElevatedButton(
|
||||
onPressed: isLoading ? null : _submit,
|
||||
child: isLoading
|
||||
? const CircularProgressIndicator()
|
||||
: const Text('Verifikasi'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
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.dart';
|
||||
import '../../../core/api/api_client_provider.dart';
|
||||
|
||||
class ActiveSessionsScreen extends StatefulWidget {
|
||||
class ActiveSessionsScreen extends ConsumerStatefulWidget {
|
||||
const ActiveSessionsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ActiveSessionsScreen> createState() => _ActiveSessionsScreenState();
|
||||
ConsumerState<ActiveSessionsScreen> createState() => _ActiveSessionsScreenState();
|
||||
}
|
||||
|
||||
class _ActiveSessionsScreenState extends State<ActiveSessionsScreen> {
|
||||
class _ActiveSessionsScreenState extends ConsumerState<ActiveSessionsScreen> {
|
||||
List<Map<String, dynamic>> _sessions = [];
|
||||
bool _loading = true;
|
||||
|
||||
@@ -22,7 +22,7 @@ class _ActiveSessionsScreenState extends State<ActiveSessionsScreen> {
|
||||
|
||||
Future<void> _loadSessions() async {
|
||||
try {
|
||||
final apiClient = context.read<ApiClient>();
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
final response = await apiClient.get('/api/mitra/chat-requests/sessions/active');
|
||||
setState(() {
|
||||
_sessions = List<Map<String, dynamic>>.from(response['data'] as List);
|
||||
@@ -48,7 +48,7 @@ class _ActiveSessionsScreenState extends State<ActiveSessionsScreen> {
|
||||
|
||||
if (confirmed == true) {
|
||||
try {
|
||||
final apiClient = context.read<ApiClient>();
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
await apiClient.post('/api/mitra/chat-requests/sessions/$sessionId/end');
|
||||
_loadSessions();
|
||||
} catch (_) {
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
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.dart';
|
||||
import '../../../core/api/api_client_provider.dart';
|
||||
|
||||
class MitraChatHistoryScreen extends StatefulWidget {
|
||||
class MitraChatHistoryScreen extends ConsumerStatefulWidget {
|
||||
const MitraChatHistoryScreen({super.key});
|
||||
|
||||
@override
|
||||
State<MitraChatHistoryScreen> createState() => _MitraChatHistoryScreenState();
|
||||
ConsumerState<MitraChatHistoryScreen> createState() => _MitraChatHistoryScreenState();
|
||||
}
|
||||
|
||||
class _MitraChatHistoryScreenState extends State<MitraChatHistoryScreen> {
|
||||
class _MitraChatHistoryScreenState extends ConsumerState<MitraChatHistoryScreen> {
|
||||
List<Map<String, dynamic>> _sessions = [];
|
||||
bool _loading = true;
|
||||
|
||||
@@ -22,7 +22,7 @@ class _MitraChatHistoryScreenState extends State<MitraChatHistoryScreen> {
|
||||
|
||||
Future<void> _loadHistory() async {
|
||||
try {
|
||||
final api = context.read<ApiClient>();
|
||||
final api = ref.read(apiClientProvider);
|
||||
final response = await api.get('/api/mitra/chat-requests/history');
|
||||
final items = (response['data']['items'] as List<dynamic>).cast<Map<String, dynamic>>();
|
||||
setState(() {
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/api/api_client.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/api/api_client_provider.dart';
|
||||
import '../../../core/constants.dart';
|
||||
|
||||
class MitraChatTranscriptScreen extends StatefulWidget {
|
||||
class MitraChatTranscriptScreen extends ConsumerStatefulWidget {
|
||||
final String sessionId;
|
||||
|
||||
const MitraChatTranscriptScreen({super.key, required this.sessionId});
|
||||
|
||||
@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>> _closures = [];
|
||||
bool _loading = true;
|
||||
@@ -25,7 +25,7 @@ class _MitraChatTranscriptScreenState extends State<MitraChatTranscriptScreen> {
|
||||
|
||||
Future<void> _loadTranscript() async {
|
||||
try {
|
||||
final api = context.read<ApiClient>();
|
||||
final api = ref.read(apiClientProvider);
|
||||
final response = await api.get('/api/shared/chat/${widget.sessionId}/transcript');
|
||||
final data = response['data'] as Map<String, dynamic>;
|
||||
setState(() {
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import 'dart:async';
|
||||
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/chat/mitra_chat_bloc.dart';
|
||||
import '../../../core/chat/extension_bloc.dart';
|
||||
import '../../../core/chat/mitra_chat_notifier.dart';
|
||||
import '../../../core/chat/extension_notifier.dart';
|
||||
import '../../../core/constants.dart';
|
||||
|
||||
class MitraChatScreen extends StatefulWidget {
|
||||
class MitraChatScreen extends ConsumerStatefulWidget {
|
||||
final String sessionId;
|
||||
final String customerName;
|
||||
|
||||
const MitraChatScreen({super.key, required this.sessionId, required this.customerName});
|
||||
|
||||
@override
|
||||
State<MitraChatScreen> createState() => _MitraChatScreenState();
|
||||
ConsumerState<MitraChatScreen> createState() => _MitraChatScreenState();
|
||||
}
|
||||
|
||||
class _MitraChatScreenState extends State<MitraChatScreen> {
|
||||
class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
|
||||
final _messageController = TextEditingController();
|
||||
final _scrollController = ScrollController();
|
||||
Timer? _typingThrottle;
|
||||
@@ -24,12 +24,12 @@ class _MitraChatScreenState extends State<MitraChatScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
context.read<MitraChatBloc>().add(ConnectChat(widget.sessionId));
|
||||
ref.read(mitraChatProvider.notifier).connect(widget.sessionId);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
context.read<MitraChatBloc>().add(DisconnectChat());
|
||||
ref.read(mitraChatProvider.notifier).disconnect();
|
||||
_messageController.dispose();
|
||||
_scrollController.dispose();
|
||||
_typingThrottle?.cancel();
|
||||
@@ -50,100 +50,89 @@ class _MitraChatScreenState extends State<MitraChatScreen> {
|
||||
|
||||
void _onTextChanged(String text) {
|
||||
if (_typingThrottle?.isActive ?? false) return;
|
||||
context.read<MitraChatBloc>().add(SendTyping());
|
||||
ref.read(mitraChatProvider.notifier).sendTyping();
|
||||
_typingThrottle = Timer(const Duration(seconds: 2), () {});
|
||||
}
|
||||
|
||||
void _sendMessage() {
|
||||
final text = _messageController.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
context.read<MitraChatBloc>().add(SendMessage(text));
|
||||
ref.read(mitraChatProvider.notifier).sendMessage(text);
|
||||
_messageController.clear();
|
||||
_scrollToBottom();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocListener(
|
||||
listeners: [
|
||||
BlocListener<MitraChatBloc, MitraChatState>(
|
||||
listener: (context, state) {
|
||||
if (state is ChatConnected) {
|
||||
final chatState = ref.watch(mitraChatProvider);
|
||||
final extState = ref.watch(mitraExtensionProvider);
|
||||
|
||||
// Listen for extension complete → navigate home
|
||||
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();
|
||||
final unread = state.messages
|
||||
final unread = next.messages
|
||||
.where((m) => m.senderType == UserType.customer && m.status != MessageStatus.read)
|
||||
.map((m) => m.id)
|
||||
.toList();
|
||||
if (unread.isNotEmpty) {
|
||||
context.read<MitraChatBloc>().add(MarkMessagesRead(unread));
|
||||
}
|
||||
if (state.sessionClosing) {
|
||||
// Trigger goodbye view
|
||||
ref.read(mitraChatProvider.notifier).markRead(unread);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
BlocListener<ExtensionBloc, ExtensionState>(
|
||||
listener: (context, state) {
|
||||
if (state is ExtensionComplete) {
|
||||
context.go('/home');
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
child: Scaffold(
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.customerName),
|
||||
actions: [
|
||||
BlocBuilder<MitraChatBloc, MitraChatState>(
|
||||
builder: (context, state) {
|
||||
if (state is ChatConnected && state.remainingSeconds != null) {
|
||||
return Padding(
|
||||
if (chatState is MitraChatConnectedData && chatState.remainingSeconds != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${state.remainingSeconds}s',
|
||||
'${chatState.remainingSeconds}s',
|
||||
style: TextStyle(
|
||||
color: state.remainingSeconds! < 30 ? Colors.red : null,
|
||||
color: chatState.remainingSeconds! < 30 ? Colors.red : null,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: BlocBuilder<MitraChatBloc, MitraChatState>(
|
||||
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();
|
||||
},
|
||||
),
|
||||
),
|
||||
body: _buildBody(chatState, extState),
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
if (state.extensionRequest != null) {
|
||||
return _buildExtensionView(context, state.extensionRequest!);
|
||||
return _buildExtensionView(state.extensionRequest!, extState);
|
||||
}
|
||||
|
||||
// Goodbye view
|
||||
final extState = context.watch<ExtensionBloc>().state;
|
||||
if (state.sessionClosing || extState is ExtensionShowGoodbye || extState is ExtensionSubmitting) {
|
||||
return _buildGoodbyeView(context, extState);
|
||||
if (state.sessionClosing || extState is ExtensionShowGoodbyeData || extState is ExtensionSubmittingData) {
|
||||
return _buildGoodbyeView(extState);
|
||||
}
|
||||
|
||||
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(
|
||||
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
|
||||
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 extensionId = request['extension_id'] as String?;
|
||||
|
||||
return BlocBuilder<ExtensionBloc, ExtensionState>(
|
||||
builder: (context, extState) {
|
||||
final isResponding = extState is ExtensionResponding;
|
||||
final isResponding = extState is ExtensionRespondingData;
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
@@ -281,21 +267,21 @@ class _MitraChatScreenState extends State<MitraChatScreen> {
|
||||
children: [
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.green),
|
||||
onPressed: extensionId == null ? null : () => context.read<ExtensionBloc>().add(RespondToExtension(
|
||||
sessionId: widget.sessionId,
|
||||
onPressed: extensionId == null ? null : () => ref.read(mitraExtensionProvider.notifier).respond(
|
||||
widget.sessionId,
|
||||
extensionId: extensionId,
|
||||
accepted: true,
|
||||
)),
|
||||
),
|
||||
child: const Text('Terima', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
||||
onPressed: extensionId == null ? null : () => context.read<ExtensionBloc>().add(RespondToExtension(
|
||||
sessionId: widget.sessionId,
|
||||
onPressed: extensionId == null ? null : () => ref.read(mitraExtensionProvider.notifier).respond(
|
||||
widget.sessionId,
|
||||
extensionId: extensionId,
|
||||
accepted: false,
|
||||
)),
|
||||
),
|
||||
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();
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(32),
|
||||
@@ -331,17 +315,17 @@ class _MitraChatScreenState extends State<MitraChatScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: extState is ExtensionSubmitting
|
||||
onPressed: extState is ExtensionSubmittingData
|
||||
? null
|
||||
: () {
|
||||
final text = controller.text.trim();
|
||||
if (text.isNotEmpty) {
|
||||
context.read<ExtensionBloc>().add(
|
||||
SubmitGoodbye(sessionId: widget.sessionId, message: text),
|
||||
ref.read(mitraExtensionProvider.notifier).submitGoodbye(
|
||||
widget.sessionId, text,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: extState is ExtensionSubmitting
|
||||
child: extState is ExtensionSubmittingData
|
||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Text('Kirim & Selesai'),
|
||||
),
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,93 +1,38 @@
|
||||
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/auth/auth_bloc.dart';
|
||||
import '../../core/status/status_bloc.dart';
|
||||
import '../../core/chat/chat_request_bloc.dart';
|
||||
import '../chat/widgets/incoming_request_sheet.dart';
|
||||
import '../../core/auth/auth_notifier.dart';
|
||||
import '../../core/status/status_notifier.dart';
|
||||
import '../../core/chat/chat_request_notifier.dart';
|
||||
import '../../core/chat/unread_notifier.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
class HomeScreen extends ConsumerWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
|
||||
@override
|
||||
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
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final authState = ref.watch(mitraAuthProvider);
|
||||
final authData = authState.valueOrNull;
|
||||
final displayName = authData is MitraAuthAuthenticatedData
|
||||
? authData.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(
|
||||
appBar: AppBar(
|
||||
title: const Text('Halo Bestie Mitra'),
|
||||
actions: [
|
||||
IconButton(
|
||||
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: [
|
||||
Text('Halo, $displayName!', style: const TextStyle(fontSize: 24)),
|
||||
const SizedBox(height: 32),
|
||||
_StatusToggle(),
|
||||
const _StatusToggle(),
|
||||
const SizedBox(height: 16),
|
||||
_ActiveSessionsButton(),
|
||||
const _ActiveSessionsButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusToggle extends StatelessWidget {
|
||||
class _StatusToggle extends ConsumerWidget {
|
||||
const _StatusToggle();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<StatusBloc, StatusState>(
|
||||
builder: (context, state) {
|
||||
final isOnline = state is StatusLoaded && state.isOnline;
|
||||
final isLoading = state is StatusLoading;
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final statusState = ref.watch(onlineStatusProvider);
|
||||
final isOnline = statusState is StatusLoadedData && statusState.isOnline;
|
||||
final isLoading = statusState is StatusLoadingData;
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
@@ -153,11 +96,11 @@ class _StatusToggle extends StatelessWidget {
|
||||
value: isOnline,
|
||||
activeColor: Colors.green,
|
||||
onChanged: (_) {
|
||||
final bloc = context.read<StatusBloc>();
|
||||
final notifier = ref.read(onlineStatusProvider.notifier);
|
||||
if (isOnline) {
|
||||
bloc.add(ToggleOffline());
|
||||
notifier.toggleOffline();
|
||||
} 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
|
||||
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(
|
||||
children: [
|
||||
Card(
|
||||
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'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => context.push('/sessions'),
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'core/api/api_client.dart';
|
||||
import 'core/auth/auth_bloc.dart';
|
||||
import 'core/status/status_bloc.dart';
|
||||
import 'core/chat/chat_request_bloc.dart';
|
||||
import 'core/chat/mitra_chat_bloc.dart';
|
||||
import 'core/chat/extension_bloc.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'core/api/api_client_provider.dart';
|
||||
import 'core/auth/auth_notifier.dart';
|
||||
import 'core/status/status_notifier.dart';
|
||||
import 'core/chat/chat_request_notifier.dart';
|
||||
import 'core/chat/widgets/chat_request_overlay.dart';
|
||||
import 'core/notifications/notification_service.dart';
|
||||
import 'firebase_options.dart';
|
||||
import 'router.dart';
|
||||
@@ -20,87 +18,76 @@ void main() async {
|
||||
final messaging = FirebaseMessaging.instance;
|
||||
await messaging.requestPermission();
|
||||
|
||||
runApp(const App());
|
||||
runApp(const ProviderScope(child: App()));
|
||||
}
|
||||
|
||||
class App extends StatefulWidget {
|
||||
class App extends ConsumerStatefulWidget {
|
||||
const App({super.key});
|
||||
|
||||
@override
|
||||
State<App> createState() => _AppState();
|
||||
ConsumerState<App> createState() => _AppState();
|
||||
}
|
||||
|
||||
class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
late final ApiClient _apiClient;
|
||||
late final AuthBloc _authBloc;
|
||||
late final GoRouter _router;
|
||||
late final StatusBloc _statusBloc;
|
||||
late final ChatRequestBloc _chatRequestBloc;
|
||||
class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
|
||||
bool _fcmRegistered = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
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
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_authBloc.close();
|
||||
_router.dispose();
|
||||
_statusBloc.close();
|
||||
_chatRequestBloc.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.paused || state == AppLifecycleState.detached) {
|
||||
_statusBloc.add(AppPaused());
|
||||
ref.read(onlineStatusProvider.notifier).onAppPaused();
|
||||
} 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
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: _authBloc),
|
||||
BlocProvider.value(value: _statusBloc),
|
||||
BlocProvider.value(value: _chatRequestBloc),
|
||||
BlocProvider(create: (_) => MitraChatBloc(apiClient: _apiClient)),
|
||||
BlocProvider(create: (_) => ExtensionBloc(apiClient: _apiClient)),
|
||||
RepositoryProvider.value(value: _apiClient),
|
||||
],
|
||||
child: BlocListener<AuthBloc, AuthState>(
|
||||
listener: (context, state) {
|
||||
if (state is AuthAuthenticated) {
|
||||
_statusBloc.add(StatusLoadRequested());
|
||||
// Listen for auth changes to load status and register FCM
|
||||
ref.listen(mitraAuthProvider, (prev, next) {
|
||||
final data = next.valueOrNull;
|
||||
if (data is MitraAuthAuthenticatedData) {
|
||||
ref.read(onlineStatusProvider.notifier).load();
|
||||
_registerFcmToken();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
final router = ref.watch(routerProvider);
|
||||
NotificationService.initialize(router);
|
||||
NotificationService.onChatRequestTapped = (sessionId) {
|
||||
ref.read(chatRequestProvider.notifier).setIncomingFromNotification(sessionId);
|
||||
};
|
||||
|
||||
return ChatRequestOverlay(
|
||||
child: MaterialApp.router(
|
||||
title: 'Halo Bestie Mitra',
|
||||
routerConfig: _router,
|
||||
),
|
||||
routerConfig: router,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.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/auth/screens/login_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_transcript_screen.dart';
|
||||
|
||||
class _BlocRefreshNotifier extends ChangeNotifier {
|
||||
late final StreamSubscription _subscription;
|
||||
class RouterNotifier extends ChangeNotifier {
|
||||
final Ref _ref;
|
||||
|
||||
_BlocRefreshNotifier(AuthBloc bloc) {
|
||||
_subscription = bloc.stream.listen((_) => notifyListeners());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subscription.cancel();
|
||||
super.dispose();
|
||||
RouterNotifier(this._ref) {
|
||||
_ref.listen(mitraAuthProvider, (_, __) => notifyListeners());
|
||||
}
|
||||
}
|
||||
|
||||
GoRouter buildRouter(AuthBloc authBloc) {
|
||||
final routerProvider = Provider<GoRouter>((ref) => buildRouter(ref));
|
||||
|
||||
GoRouter buildRouter(Ref ref) {
|
||||
final notifier = RouterNotifier(ref);
|
||||
|
||||
return GoRouter(
|
||||
initialLocation: '/splash',
|
||||
refreshListenable: _BlocRefreshNotifier(authBloc),
|
||||
refreshListenable: notifier,
|
||||
redirect: (context, state) {
|
||||
final authState = authBloc.state;
|
||||
final authState = ref.read(mitraAuthProvider);
|
||||
final isSplash = state.matchedLocation == '/splash';
|
||||
final isAuthRoute = state.matchedLocation.startsWith('/login') ||
|
||||
state.matchedLocation.startsWith('/otp');
|
||||
|
||||
// Show splash while loading
|
||||
if (authState is AuthLoading) return isSplash ? null : '/splash';
|
||||
// Show splash only during initial load — don't redirect away from auth routes
|
||||
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;
|
||||
}
|
||||
if (!isAuthRoute && !isSplash) return '/login';
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -9,6 +17,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -25,14 +49,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
bloc:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bloc
|
||||
sha256: "106842ad6569f0b60297619e9e0b1885c2fb9bf84812935490e6c5275777804e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.1.4"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -41,6 +57,70 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -49,6 +129,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -57,6 +161,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -65,6 +177,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
convert:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: convert
|
||||
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -73,6 +193,46 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -97,14 +257,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -121,6 +273,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
firebase_auth:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -193,19 +353,27 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.8.7"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_bloc:
|
||||
flutter_hooks:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_bloc
|
||||
sha256: b594505eac31a0518bdcb4b5b79573b8d9117b193cc80cc12e17d639b10aa27a
|
||||
name: flutter_hooks
|
||||
sha256: cde36b12f7188c85286fba9b38cc5a902e7279f36dd676967106c041dc9dde70
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.1.6"
|
||||
version: "0.20.5"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -246,6 +414,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -256,6 +432,30 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -264,6 +464,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -272,6 +496,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -280,6 +512,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -352,14 +608,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
nested:
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: nested
|
||||
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
|
||||
name: package_config
|
||||
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
version: "2.2.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -384,19 +640,107 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
provider:
|
||||
pool:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: provider
|
||||
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
|
||||
name: pool
|
||||
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
|
||||
url: "https://pub.dev"
|
||||
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:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -413,6 +757,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -421,6 +773,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -453,6 +813,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.0"
|
||||
timing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: timing
|
||||
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -461,6 +829,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: uuid
|
||||
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.3"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -477,6 +853,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.0.2"
|
||||
watcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: watcher
|
||||
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -509,6 +893,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.6.1"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: yaml
|
||||
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.10.0 <4.0.0"
|
||||
flutter: ">=3.38.1"
|
||||
|
||||
@@ -21,8 +21,10 @@ dependencies:
|
||||
web_socket_channel: ^2.4.5
|
||||
|
||||
# State management
|
||||
flutter_bloc: ^8.1.5
|
||||
equatable: ^2.0.5
|
||||
flutter_riverpod: ^2.6.1
|
||||
hooks_riverpod: ^2.6.1
|
||||
riverpod_annotation: ^2.6.1
|
||||
flutter_hooks: ^0.20.5
|
||||
|
||||
# Navigation
|
||||
go_router: ^13.2.1
|
||||
@@ -32,6 +34,10 @@ dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
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:
|
||||
uses-material-design: true
|
||||
|
||||
735
requirement/phase3.1-plan.md
Normal file
735
requirement/phase3.1-plan.md
Normal 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 3–8 |
|
||||
| 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 10–15 |
|
||||
| 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 27–28 |
|
||||
| 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 21–26 |
|
||||
| 34 | E2E test: closure FCM fallback + unread badges | All | Steps 27–32 |
|
||||
|
||||
---
|
||||
|
||||
## 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
84
requirement/phase3.1.md
Normal 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
|
||||
258
requirement/phase3.2-plan.md
Normal file
258
requirement/phase3.2-plan.md
Normal 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 1–2 |
|
||||
| 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 8–9 |
|
||||
| 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
137
requirement/phase3.2.md
Normal 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
|
||||
Reference in New Issue
Block a user