Initial import of f7cloud-mobile native Android app.
Kotlin/Compose client for F7cloud (mail, files, talk, calendar, contacts, tasks, support). Current version: 0.5.113 (build 121).
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest />
|
||||
+1016
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
||||
package ru.forbion.f7cloud.feature.f7support
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.FileProvider
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
data class SupportPendingFile(
|
||||
val localId: String = UUID.randomUUID().toString(),
|
||||
val fileName: String,
|
||||
val bytes: ByteArray,
|
||||
val mimeType: String,
|
||||
)
|
||||
|
||||
internal object SupportFileIO {
|
||||
fun readUris(context: Context, uris: List<Uri>): List<SupportPendingFile> {
|
||||
return uris.mapNotNull { uri ->
|
||||
runCatching { readUri(context, uri) }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
fun readUri(
|
||||
context: Context,
|
||||
uri: Uri,
|
||||
fallbackName: String = "file.bin",
|
||||
): SupportPendingFile {
|
||||
val resolver = context.contentResolver
|
||||
var name = fallbackName
|
||||
resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (idx >= 0) {
|
||||
name = cursor.getString(idx)?.takeIf { it.isNotBlank() } ?: name
|
||||
}
|
||||
}
|
||||
}
|
||||
val mime = resolver.getType(uri) ?: "application/octet-stream"
|
||||
val bytes = resolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
?: error("Не удалось прочитать файл")
|
||||
return SupportPendingFile(fileName = name, bytes = bytes, mimeType = mime)
|
||||
}
|
||||
|
||||
fun openBytes(context: Context, fileName: String, bytes: ByteArray, mimeType: String) {
|
||||
val safeName = fileName.replace(Regex("[\\\\/:*?\"<>|]"), "_").ifBlank { "file" }
|
||||
val cacheDir = File(context.cacheDir, "f7support").apply { mkdirs() }
|
||||
val file = File(cacheDir, safeName)
|
||||
file.writeBytes(bytes)
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
file,
|
||||
)
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, mimeType.ifBlank { "application/octet-stream" })
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
try {
|
||||
context.startActivity(Intent.createChooser(intent, "Открыть с помощью"))
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
Toast.makeText(context, "Нет приложения для открытия этого файла", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
+420
@@ -0,0 +1,420 @@
|
||||
package ru.forbion.f7cloud.feature.f7support
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import ru.forbion.f7cloud.core.network.NetworkFactory
|
||||
import ru.forbion.f7cloud.core.network.UnauthorizedException
|
||||
import ru.forbion.f7cloud.core.network.applyOcsJson
|
||||
import ru.forbion.f7cloud.core.network.ocsData
|
||||
import ru.forbion.f7cloud.core.network.ocsMeta
|
||||
import ru.forbion.f7cloud.core.network.parseJsonArray
|
||||
import ru.forbion.f7cloud.core.network.parseJsonObject
|
||||
import java.net.URI
|
||||
|
||||
class SupportRepository {
|
||||
suspend fun loadConfig(session: AuthSession): SupportConfig {
|
||||
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
|
||||
val request = Request.Builder()
|
||||
.url("${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/f7support/api/v1/mobile-config?format=json")
|
||||
.applyOcsJson()
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
if (!response.isSuccessful || response.body == null) {
|
||||
error("Support config HTTP ${response.code}")
|
||||
}
|
||||
val data = parseJsonObject(response.body!!.string(), "конфигурация поддержки").ocsData()
|
||||
val apiBase = data?.optString("supportApiBase").orEmpty().ifBlank {
|
||||
"https://support.f7cloud.ru"
|
||||
}
|
||||
val serverAddress = data?.optString("serverAddress").orEmpty().ifBlank {
|
||||
hostFromUrl(session.serverUrl)
|
||||
}
|
||||
return SupportConfig(
|
||||
supportApiBase = apiBase.trimEnd('/'),
|
||||
serverAddress = serverAddress,
|
||||
clientReadReceipts = data?.optBoolean("clientReadReceipts") == true,
|
||||
isSupportAdmin = data?.optBoolean("isSupportAdmin") == true,
|
||||
username = session.username,
|
||||
serverUrl = session.serverUrl.trimEnd('/'),
|
||||
appPassword = session.appPassword,
|
||||
trustAllCerts = session.trustAllCerts,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun listTickets(config: SupportConfig): List<SupportTicket> {
|
||||
val client = supportClient()
|
||||
val request = Request.Builder()
|
||||
.url("${config.supportApiBase}/api/client/tickets")
|
||||
.headers(identityHeaders(config))
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful || response.body == null) {
|
||||
error("Не удалось загрузить обращения (${response.code})")
|
||||
}
|
||||
val array = parseJsonArray(response.body!!.string(), "список обращений")
|
||||
val out = mutableListOf<SupportTicket>()
|
||||
for (i in 0 until array.length()) {
|
||||
val obj = array.optJSONObject(i) ?: continue
|
||||
parseTicket(obj)?.let { out += it }
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadMessages(config: SupportConfig, ticketNumber: String): List<SupportMessage> {
|
||||
val client = supportClient()
|
||||
val request = Request.Builder()
|
||||
.url("${config.supportApiBase}/api/client/tickets/${encodePath(ticketNumber)}/messages")
|
||||
.headers(identityHeaders(config))
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful || response.body == null) {
|
||||
error("Не удалось загрузить сообщения (${response.code})")
|
||||
}
|
||||
val array = parseJsonArray(response.body!!.string(), "сообщения обращения")
|
||||
val out = mutableListOf<SupportMessage>()
|
||||
for (i in 0 until array.length()) {
|
||||
val obj = array.optJSONObject(i) ?: continue
|
||||
val id = obj.optLong("id", obj.optLong("message_id", 0L))
|
||||
val role = obj.optString("author_role")
|
||||
val author = obj.optString("author")
|
||||
val outgoing = messageIsOutgoing(config, role, author)
|
||||
out += SupportMessage(
|
||||
id = id,
|
||||
text = obj.optString("text"),
|
||||
createdAt = obj.optString("created_at"),
|
||||
outgoing = outgoing,
|
||||
authorLabel = supportSenderLabel(config, role, author, outgoing),
|
||||
attachments = parseAttachments(obj.optJSONArray("attachments")),
|
||||
read = obj.optBoolean("read_by_operator", false) ||
|
||||
obj.optBoolean("is_read", false),
|
||||
)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendMessage(config: SupportConfig, ticketNumber: String, text: String): Long {
|
||||
val client = supportClient()
|
||||
val payload = JSONObject().put("text", text).toString()
|
||||
val request = Request.Builder()
|
||||
.url("${config.supportApiBase}/api/client/tickets/${encodePath(ticketNumber)}/messages")
|
||||
.headers(identityHeaders(config))
|
||||
.post(payload.toRequestBody("application/json; charset=utf-8".toMediaType()))
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful || response.body == null) {
|
||||
error("Не удалось отправить сообщение (${response.code})")
|
||||
}
|
||||
val json = parseJsonObject(response.body!!.string(), "ответ на сообщение")
|
||||
return json.optLong("id", json.optLong("message_id", 0L))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun uploadAttachment(
|
||||
config: SupportConfig,
|
||||
ticketNumber: String,
|
||||
messageId: Long,
|
||||
file: SupportPendingFile,
|
||||
) {
|
||||
val client = supportClient()
|
||||
val mediaType = file.mimeType.toMediaTypeOrNull() ?: "application/octet-stream".toMediaType()
|
||||
val body = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("message_id", messageId.toString())
|
||||
.addFormDataPart(
|
||||
"file",
|
||||
file.fileName,
|
||||
file.bytes.toRequestBody(mediaType),
|
||||
)
|
||||
.build()
|
||||
val request = Request.Builder()
|
||||
.url("${config.supportApiBase}/api/client/tickets/${encodePath(ticketNumber)}/attachments")
|
||||
.headers(identityHeaders(config))
|
||||
.post(body)
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
error("Вложение не принято (${response.code})")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendMessageWithAttachments(
|
||||
config: SupportConfig,
|
||||
ticketNumber: String,
|
||||
text: String,
|
||||
attachments: List<SupportPendingFile>,
|
||||
) {
|
||||
val trimmed = text.trim()
|
||||
val messageText = when {
|
||||
trimmed.isNotEmpty() -> trimmed
|
||||
attachments.isNotEmpty() -> MESSAGE_BODY_PLACEHOLDER
|
||||
else -> error("Введите сообщение или прикрепите файл")
|
||||
}
|
||||
val messageId = sendMessage(config, ticketNumber, messageText)
|
||||
if (attachments.isEmpty()) return
|
||||
if (messageId <= 0L) {
|
||||
error("Сообщение создано, но сервер не вернул id — вложения не отправлены")
|
||||
}
|
||||
attachments.forEach { file ->
|
||||
uploadAttachment(config, ticketNumber, messageId, file)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun downloadAttachment(
|
||||
config: SupportConfig,
|
||||
ticketNumber: String,
|
||||
attachmentId: Long,
|
||||
): Pair<ByteArray, String> {
|
||||
val client = supportClient()
|
||||
val request = Request.Builder()
|
||||
.url(
|
||||
"${config.supportApiBase}/api/client/tickets/${encodePath(ticketNumber)}" +
|
||||
"/attachments/${encodePath(attachmentId.toString())}",
|
||||
)
|
||||
.headers(identityHeaders(config))
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful || response.body == null) {
|
||||
error("Не удалось скачать вложение (${response.code})")
|
||||
}
|
||||
val mime = response.header("Content-Type")?.substringBefore(';')?.trim()
|
||||
?: "application/octet-stream"
|
||||
return response.body!!.bytes() to mime
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createTicket(
|
||||
config: SupportConfig,
|
||||
subject: String,
|
||||
body: String,
|
||||
attachments: List<SupportPendingFile> = emptyList(),
|
||||
): String {
|
||||
val client = supportClient()
|
||||
val payload = JSONObject()
|
||||
.put("server_address", config.serverAddress)
|
||||
.put("username", config.username)
|
||||
.put("subject", subject)
|
||||
.put("body", TICKET_CREATE_BODY_PLACEHOLDER)
|
||||
.put("duplicate", 0)
|
||||
.toString()
|
||||
val createReq = Request.Builder()
|
||||
.url("${config.supportApiBase}/api/client/tickets")
|
||||
.headers(identityHeaders(config))
|
||||
.post(payload.toRequestBody("application/json; charset=utf-8".toMediaType()))
|
||||
.build()
|
||||
val ticketNumber = client.newCall(createReq).execute().use { response ->
|
||||
if (!response.isSuccessful || response.body == null) {
|
||||
error("Не удалось создать обращение (${response.code})")
|
||||
}
|
||||
parseJsonObject(response.body!!.string(), "создание обращения").optString("ticket_number")
|
||||
}
|
||||
if (ticketNumber.isBlank()) {
|
||||
error("Сервер не вернул номер обращения")
|
||||
}
|
||||
val messageId = sendMessage(config, ticketNumber, body.trim())
|
||||
if (attachments.isNotEmpty()) {
|
||||
if (messageId <= 0L) {
|
||||
error("Обращение создано, но вложения не отправлены — откройте чат и прикрепите файлы вручную")
|
||||
} else {
|
||||
attachments.forEach { file ->
|
||||
uploadAttachment(config, ticketNumber, messageId, file)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ticketNumber
|
||||
}
|
||||
|
||||
suspend fun markRead(config: SupportConfig, ticketNumber: String) {
|
||||
if (!config.clientReadReceipts) return
|
||||
val client = supportClient()
|
||||
val request = Request.Builder()
|
||||
.url("${config.supportApiBase}/api/client/tickets/${encodePath(ticketNumber)}/read")
|
||||
.headers(identityHeaders(config))
|
||||
.post("".toRequestBody(null))
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 404) return
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun submitComplaint(
|
||||
config: SupportConfig,
|
||||
ticketNumber: String,
|
||||
ticketSubject: String,
|
||||
text: String,
|
||||
) {
|
||||
val client = NetworkFactory.newAuthedClient(config.username, config.appPassword, config.trustAllCerts)
|
||||
val payload = JSONObject()
|
||||
.put("ticketNumber", ticketNumber)
|
||||
.put("ticketSubject", ticketSubject)
|
||||
.put("text", text)
|
||||
.toString()
|
||||
val request = Request.Builder()
|
||||
.url("${config.serverUrl}/ocs/v2.php/apps/f7support/api/v1/complaint?format=json")
|
||||
.applyOcsJson()
|
||||
.post(payload.toRequestBody("application/json; charset=utf-8".toMediaType()))
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
if (!response.isSuccessful) {
|
||||
val body = response.body?.string().orEmpty()
|
||||
val err = runCatching {
|
||||
parseJsonObject(body, "жалоба").ocsMeta()?.optString("message")
|
||||
}.getOrNull().orEmpty()
|
||||
error(err.ifBlank { "Не удалось отправить жалобу (${response.code})" })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseTicket(obj: JSONObject): SupportTicket? {
|
||||
val number = obj.optString("ticket_number")
|
||||
if (number.isBlank()) return null
|
||||
val bitrixRaw = obj.opt("bitrix_deal_id")
|
||||
val bitrixDealId = when (bitrixRaw) {
|
||||
is Number -> bitrixRaw.toLong().takeIf { it > 0 }
|
||||
else -> bitrixRaw?.toString()?.trim()?.toLongOrNull()?.takeIf { it > 0 }
|
||||
}
|
||||
return SupportTicket(
|
||||
ticketNumber = number,
|
||||
subject = obj.optString("subject").ifBlank { "—" },
|
||||
status = obj.optString("status"),
|
||||
preview = obj.optString("preview_text"),
|
||||
hasUnread = obj.optBoolean("has_unread", false),
|
||||
activityAt = obj.optString("activity_at").ifBlank { obj.optString("created_at") },
|
||||
createdAt = obj.optString("created_at"),
|
||||
bitrixDealId = bitrixDealId,
|
||||
assignedEmployee = obj.optString("assigned_employee"),
|
||||
clientUsername = obj.optString("client_username"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseAttachments(array: JSONArray?): List<SupportAttachment> {
|
||||
if (array == null || array.length() == 0) return emptyList()
|
||||
val out = mutableListOf<SupportAttachment>()
|
||||
for (i in 0 until array.length()) {
|
||||
val obj = array.optJSONObject(i) ?: continue
|
||||
val id = obj.optLong("id", obj.optLong("attachment_id", 0L))
|
||||
if (id <= 0L) continue
|
||||
out += SupportAttachment(
|
||||
id = id,
|
||||
filename = obj.optString("filename").ifBlank { "file" },
|
||||
mimeType = obj.optString("mime_type"),
|
||||
sizeBytes = obj.optLong("size_bytes", -1L).takeIf { it >= 0 },
|
||||
)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun messageIsOutgoing(config: SupportConfig, role: String, author: String): Boolean {
|
||||
val r = role.lowercase()
|
||||
val a = author.lowercase()
|
||||
val u = config.username.lowercase()
|
||||
if (config.isSupportAdmin && r == "support" && u.isNotEmpty() && a == u) return true
|
||||
if (r == "client" || r == "user") return true
|
||||
return u.isNotEmpty() && a == u
|
||||
}
|
||||
|
||||
private fun supportSenderLabel(
|
||||
config: SupportConfig,
|
||||
role: String,
|
||||
author: String,
|
||||
outgoing: Boolean,
|
||||
): String {
|
||||
if (outgoing) return "Вы"
|
||||
val name = author.trim()
|
||||
if (name.isNotEmpty()) return name
|
||||
return if (role.equals("support", true)) "Поддержка" else "Клиент"
|
||||
}
|
||||
|
||||
private fun supportClient(): OkHttpClient = OkHttpClient.Builder().build()
|
||||
|
||||
private fun identityHeaders(config: SupportConfig): okhttp3.Headers {
|
||||
val builder = okhttp3.Headers.Builder()
|
||||
.add("X-F7cloud-User", config.username)
|
||||
.add("X-F7cloud-Server", config.serverAddress)
|
||||
if (config.isSupportAdmin) {
|
||||
builder.add("X-F7cloud-Support-Admin", "1")
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun encodePath(segment: String): String {
|
||||
return java.net.URLEncoder.encode(segment, Charsets.UTF_8.name())
|
||||
}
|
||||
|
||||
private fun hostFromUrl(serverUrl: String): String {
|
||||
return runCatching { URI(serverUrl.trimEnd('/')).host }.getOrNull().orEmpty()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MESSAGE_BODY_PLACEHOLDER = "."
|
||||
private const val TICKET_CREATE_BODY_PLACEHOLDER = "."
|
||||
}
|
||||
}
|
||||
|
||||
data class SupportConfig(
|
||||
val supportApiBase: String,
|
||||
val serverAddress: String,
|
||||
val clientReadReceipts: Boolean,
|
||||
val isSupportAdmin: Boolean,
|
||||
val username: String,
|
||||
val serverUrl: String,
|
||||
val appPassword: String,
|
||||
val trustAllCerts: Boolean,
|
||||
)
|
||||
|
||||
data class SupportTicket(
|
||||
val ticketNumber: String,
|
||||
val subject: String,
|
||||
val status: String,
|
||||
val preview: String,
|
||||
val hasUnread: Boolean,
|
||||
val activityAt: String,
|
||||
val createdAt: String,
|
||||
val bitrixDealId: Long? = null,
|
||||
val assignedEmployee: String = "",
|
||||
val clientUsername: String = "",
|
||||
) {
|
||||
fun displayNumber(): String = bitrixDealId?.toString() ?: "—"
|
||||
|
||||
fun statusBucket(): SupportStatusBucket = when (status) {
|
||||
"Закрыт" -> SupportStatusBucket.Closed
|
||||
"В работе" -> SupportStatusBucket.Progress
|
||||
"Новый" -> SupportStatusBucket.New
|
||||
else -> SupportStatusBucket.New
|
||||
}
|
||||
}
|
||||
|
||||
enum class SupportStatusBucket(val title: String) {
|
||||
New("Новые"),
|
||||
Progress("В работе"),
|
||||
Closed("Закрыт"),
|
||||
}
|
||||
|
||||
data class SupportMessage(
|
||||
val id: Long,
|
||||
val text: String,
|
||||
val createdAt: String,
|
||||
val outgoing: Boolean,
|
||||
val authorLabel: String,
|
||||
val attachments: List<SupportAttachment> = emptyList(),
|
||||
val read: Boolean = false,
|
||||
)
|
||||
|
||||
data class SupportAttachment(
|
||||
val id: Long,
|
||||
val filename: String,
|
||||
val mimeType: String,
|
||||
val sizeBytes: Long? = null,
|
||||
)
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
package ru.forbion.f7cloud.feature.f7support
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||
import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
|
||||
import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler
|
||||
|
||||
@Composable
|
||||
fun SupportScreen(
|
||||
session: AuthSession,
|
||||
modifier: Modifier = Modifier,
|
||||
createRequest: Int = 0,
|
||||
openTicketNumber: String? = null,
|
||||
onOpenTicketConsumed: () -> Unit = {},
|
||||
onUnauthorized: () -> Unit = {},
|
||||
) {
|
||||
val vm: SupportViewModel = viewModel()
|
||||
val state by vm.state.collectAsState()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val context = LocalContext.current
|
||||
|
||||
val pickCreateFilesLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenMultipleDocuments(),
|
||||
) { uris ->
|
||||
if (uris.isNotEmpty()) {
|
||||
vm.addCreateFiles(context, uris)
|
||||
}
|
||||
}
|
||||
val pickChatFilesLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenMultipleDocuments(),
|
||||
) { uris ->
|
||||
if (uris.isNotEmpty()) {
|
||||
vm.addChatFiles(context, uris)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(session.serverUrl, session.username) {
|
||||
vm.load(session)
|
||||
}
|
||||
LaunchedEffect(createRequest) {
|
||||
if (createRequest > 0) {
|
||||
vm.setShowCreate(true)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(openTicketNumber) {
|
||||
if (!openTicketNumber.isNullOrBlank()) {
|
||||
vm.openTicketFromPush(session, openTicketNumber)
|
||||
onOpenTicketConsumed()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(state.unauthorized) {
|
||||
if (state.unauthorized) onUnauthorized()
|
||||
}
|
||||
LaunchedEffect(state.snackbar) {
|
||||
val msg = state.snackbar ?: return@LaunchedEffect
|
||||
snackbarHostState.showSnackbar(msg)
|
||||
vm.clearSnackbar()
|
||||
}
|
||||
LaunchedEffect(state.error) {
|
||||
val err = state.error ?: return@LaunchedEffect
|
||||
if (state.selectedTicket != null || state.showCreate || state.showComplaint) {
|
||||
snackbarHostState.showSnackbar(err)
|
||||
vm.clearError()
|
||||
}
|
||||
}
|
||||
|
||||
F7OverlayDismissHandler(
|
||||
enabled = state.showComplaint,
|
||||
onDismiss = { vm.setShowComplaint(false) },
|
||||
)
|
||||
F7OverlayDismissHandler(
|
||||
enabled = state.selectedTicket != null,
|
||||
onDismiss = { vm.closeTicket(session) },
|
||||
)
|
||||
F7OverlayDismissHandler(
|
||||
enabled = state.showCreate,
|
||||
onDismiss = { vm.setShowCreate(false) },
|
||||
)
|
||||
|
||||
if (state.showCreate) {
|
||||
SupportCreateDialog(
|
||||
loading = state.loading,
|
||||
pendingFiles = state.createPendingFiles,
|
||||
onDismiss = { vm.setShowCreate(false) },
|
||||
onPickFiles = { pickCreateFilesLauncher.launch(arrayOf("*/*")) },
|
||||
onRemoveFile = vm::removeCreateFile,
|
||||
onSubmit = { subject, body -> vm.createTicket(session, subject, body) },
|
||||
)
|
||||
}
|
||||
|
||||
state.selectedTicket?.let { ticket ->
|
||||
SupportChatDialog(
|
||||
session = session,
|
||||
ticket = ticket,
|
||||
messages = state.messages,
|
||||
loading = state.loading && state.messages.isEmpty(),
|
||||
sending = state.sending,
|
||||
pendingFiles = state.chatPendingFiles,
|
||||
downloadingAttachmentId = state.downloadingAttachmentId,
|
||||
onDismiss = { vm.closeTicket(session) },
|
||||
onSend = { text -> vm.sendMessage(session, text) },
|
||||
onPickFiles = { pickChatFilesLauncher.launch(arrayOf("*/*")) },
|
||||
onRemoveFile = vm::removeChatFile,
|
||||
onAttachmentClick = { vm.downloadAttachment(context, it) },
|
||||
onComplaintClick = { vm.setShowComplaint(true) },
|
||||
)
|
||||
}
|
||||
|
||||
if (state.showComplaint && state.selectedTicket != null) {
|
||||
SupportComplaintDialog(
|
||||
sending = state.complaintSending,
|
||||
onDismiss = { vm.setShowComplaint(false) },
|
||||
onSubmit = { vm.submitComplaint(it) },
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
F7ModuleScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
loading = state.loading && state.tickets.isEmpty() && state.selectedTicket == null,
|
||||
error = if (state.selectedTicket == null) state.error else null,
|
||||
onRefresh = { vm.load(session) },
|
||||
) {
|
||||
if (state.selectedTicket == null) {
|
||||
SupportHomeContent(
|
||||
serverUrl = session.serverUrl,
|
||||
isSupportAdmin = state.config?.isSupportAdmin == true,
|
||||
ticketsByBucket = state.ticketsByBucket,
|
||||
hasTickets = state.hasTickets,
|
||||
onCreateClick = { vm.setShowCreate(true) },
|
||||
onTicketClick = { vm.openTicket(session, it) },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
SnackbarHost(
|
||||
hostState = snackbarHostState,
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
)
|
||||
if (state.loading && state.tickets.isNotEmpty() && state.selectedTicket == null && !state.showCreate) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
color = F7Colors.Primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+384
@@ -0,0 +1,384 @@
|
||||
package ru.forbion.f7cloud.feature.f7support
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import ru.forbion.f7cloud.core.designsystem.AppForegroundTracker
|
||||
import ru.forbion.f7cloud.core.network.UnauthorizedException
|
||||
|
||||
data class SupportUiState(
|
||||
val loading: Boolean = false,
|
||||
val config: SupportConfig? = null,
|
||||
val tickets: List<SupportTicket> = emptyList(),
|
||||
val selectedTicket: SupportTicket? = null,
|
||||
val messages: List<SupportMessage> = emptyList(),
|
||||
val sending: Boolean = false,
|
||||
val showCreate: Boolean = false,
|
||||
val showComplaint: Boolean = false,
|
||||
val complaintSending: Boolean = false,
|
||||
val createPendingFiles: List<SupportPendingFile> = emptyList(),
|
||||
val chatPendingFiles: List<SupportPendingFile> = emptyList(),
|
||||
val downloadingAttachmentId: Long? = null,
|
||||
val snackbar: String? = null,
|
||||
val error: String? = null,
|
||||
val unauthorized: Boolean = false,
|
||||
) {
|
||||
val ticketsByBucket: Map<SupportStatusBucket, List<SupportTicket>>
|
||||
get() = SupportStatusBucket.entries.associateWith { bucket ->
|
||||
tickets
|
||||
.filter { it.statusBucket() == bucket }
|
||||
.sortedByDescending { it.activityAt }
|
||||
}
|
||||
|
||||
val hasTickets: Boolean get() = tickets.isNotEmpty()
|
||||
}
|
||||
|
||||
class SupportViewModel(
|
||||
private val repository: SupportRepository = SupportRepository(),
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(SupportUiState())
|
||||
val state: StateFlow<SupportUiState> = _state.asStateFlow()
|
||||
private var cachedConfig: SupportConfig? = null
|
||||
private var pollJob: Job? = null
|
||||
|
||||
fun load(session: AuthSession) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
_state.value = _state.value.copy(loading = true, error = null)
|
||||
runCatching {
|
||||
val config = repository.loadConfig(session)
|
||||
cachedConfig = config
|
||||
val tickets = repository.listTickets(config)
|
||||
config to tickets
|
||||
}.onSuccess { (config, tickets) ->
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
config = config,
|
||||
tickets = tickets,
|
||||
error = null,
|
||||
)
|
||||
restartPolling(session)
|
||||
}.onFailure { t ->
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
error = t.message,
|
||||
unauthorized = t is UnauthorizedException,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshTickets(session: AuthSession) {
|
||||
val config = cachedConfig ?: return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
runCatching { repository.listTickets(config) }
|
||||
.onSuccess { tickets ->
|
||||
val selected = _state.value.selectedTicket
|
||||
val updatedSelected = selected?.let { sel ->
|
||||
tickets.find { it.ticketNumber == sel.ticketNumber } ?: sel
|
||||
}
|
||||
_state.value = _state.value.copy(
|
||||
tickets = tickets,
|
||||
selectedTicket = updatedSelected,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openTicket(session: AuthSession, ticketNumber: String) {
|
||||
val config = cachedConfig ?: return
|
||||
val ticket = _state.value.tickets.find { it.ticketNumber == ticketNumber }
|
||||
?: SupportTicket(
|
||||
ticketNumber = ticketNumber,
|
||||
subject = "—",
|
||||
status = "",
|
||||
preview = "",
|
||||
hasUnread = false,
|
||||
activityAt = "",
|
||||
createdAt = "",
|
||||
)
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
_state.value = _state.value.copy(
|
||||
loading = true,
|
||||
selectedTicket = ticket,
|
||||
messages = emptyList(),
|
||||
error = null,
|
||||
)
|
||||
runCatching {
|
||||
repository.loadMessages(config, ticketNumber)
|
||||
}.onSuccess { messages ->
|
||||
repository.markRead(config, ticketNumber)
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
messages = messages,
|
||||
tickets = _state.value.tickets.map {
|
||||
if (it.ticketNumber == ticketNumber) it.copy(hasUnread = false) else it
|
||||
},
|
||||
)
|
||||
restartPolling(session)
|
||||
}.onFailure { t ->
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
error = t.message,
|
||||
unauthorized = t is UnauthorizedException,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun closeTicket(session: AuthSession) {
|
||||
_state.value = _state.value.copy(
|
||||
selectedTicket = null,
|
||||
messages = emptyList(),
|
||||
showComplaint = false,
|
||||
chatPendingFiles = emptyList(),
|
||||
)
|
||||
refreshTickets(session)
|
||||
restartPolling(session)
|
||||
}
|
||||
|
||||
fun sendMessage(session: AuthSession, text: String) {
|
||||
val config = cachedConfig ?: return
|
||||
val ticket = _state.value.selectedTicket ?: return
|
||||
val trimmed = text.trim()
|
||||
val files = _state.value.chatPendingFiles
|
||||
if (trimmed.isEmpty() && files.isEmpty()) return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
_state.value = _state.value.copy(sending = true, error = null)
|
||||
runCatching {
|
||||
repository.sendMessageWithAttachments(config, ticket.ticketNumber, trimmed, files)
|
||||
}.onSuccess {
|
||||
val messages = repository.loadMessages(config, ticket.ticketNumber)
|
||||
_state.value = _state.value.copy(
|
||||
sending = false,
|
||||
messages = messages,
|
||||
chatPendingFiles = emptyList(),
|
||||
)
|
||||
refreshTickets(session)
|
||||
}.onFailure { t ->
|
||||
_state.value = _state.value.copy(
|
||||
sending = false,
|
||||
error = t.message,
|
||||
unauthorized = t is UnauthorizedException,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setShowCreate(show: Boolean) {
|
||||
_state.value = _state.value.copy(
|
||||
showCreate = show,
|
||||
error = null,
|
||||
createPendingFiles = if (show) _state.value.createPendingFiles else emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
fun setShowComplaint(show: Boolean) {
|
||||
_state.value = _state.value.copy(showComplaint = show, error = null)
|
||||
}
|
||||
|
||||
fun createTicket(session: AuthSession, subject: String, body: String) {
|
||||
val config = cachedConfig ?: return
|
||||
val files = _state.value.createPendingFiles
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
_state.value = _state.value.copy(loading = true, error = null)
|
||||
runCatching {
|
||||
repository.createTicket(config, subject.trim(), body.trim(), files)
|
||||
}.onSuccess { ticketNumber ->
|
||||
val tickets = repository.listTickets(config)
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
showCreate = false,
|
||||
createPendingFiles = emptyList(),
|
||||
tickets = tickets,
|
||||
)
|
||||
openTicket(session, ticketNumber)
|
||||
}.onFailure { t ->
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
error = t.message,
|
||||
unauthorized = t is UnauthorizedException,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addCreateFiles(context: Context, uris: List<Uri>) {
|
||||
if (uris.isEmpty()) return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
runCatching { SupportFileIO.readUris(context, uris) }
|
||||
.onSuccess { files ->
|
||||
if (files.isEmpty()) return@onSuccess
|
||||
_state.value = _state.value.copy(
|
||||
createPendingFiles = _state.value.createPendingFiles + files,
|
||||
)
|
||||
}
|
||||
.onFailure { t ->
|
||||
_state.value = _state.value.copy(error = t.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeCreateFile(localId: String) {
|
||||
_state.value = _state.value.copy(
|
||||
createPendingFiles = _state.value.createPendingFiles.filterNot { it.localId == localId },
|
||||
)
|
||||
}
|
||||
|
||||
fun addChatFiles(context: Context, uris: List<Uri>) {
|
||||
if (uris.isEmpty()) return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
runCatching { SupportFileIO.readUris(context, uris) }
|
||||
.onSuccess { files ->
|
||||
if (files.isEmpty()) return@onSuccess
|
||||
_state.value = _state.value.copy(
|
||||
chatPendingFiles = _state.value.chatPendingFiles + files,
|
||||
)
|
||||
}
|
||||
.onFailure { t ->
|
||||
_state.value = _state.value.copy(error = t.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeChatFile(localId: String) {
|
||||
_state.value = _state.value.copy(
|
||||
chatPendingFiles = _state.value.chatPendingFiles.filterNot { it.localId == localId },
|
||||
)
|
||||
}
|
||||
|
||||
fun downloadAttachment(
|
||||
context: Context,
|
||||
attachment: SupportAttachment,
|
||||
) {
|
||||
val config = cachedConfig ?: return
|
||||
val ticket = _state.value.selectedTicket ?: return
|
||||
if (_state.value.downloadingAttachmentId == attachment.id) return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
_state.value = _state.value.copy(downloadingAttachmentId = attachment.id)
|
||||
runCatching {
|
||||
repository.downloadAttachment(config, ticket.ticketNumber, attachment.id)
|
||||
}.onSuccess { (bytes, mime) ->
|
||||
SupportFileIO.openBytes(
|
||||
context = context,
|
||||
fileName = attachment.filename,
|
||||
bytes = bytes,
|
||||
mimeType = mime.ifBlank { attachment.mimeType },
|
||||
)
|
||||
_state.value = _state.value.copy(downloadingAttachmentId = null)
|
||||
}.onFailure { t ->
|
||||
_state.value = _state.value.copy(
|
||||
downloadingAttachmentId = null,
|
||||
error = t.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun submitComplaint(text: String) {
|
||||
val config = cachedConfig ?: return
|
||||
val ticket = _state.value.selectedTicket ?: return
|
||||
val trimmed = text.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
_state.value = _state.value.copy(complaintSending = true, error = null)
|
||||
runCatching {
|
||||
repository.submitComplaint(
|
||||
config = config,
|
||||
ticketNumber = ticket.ticketNumber,
|
||||
ticketSubject = ticket.subject,
|
||||
text = trimmed,
|
||||
)
|
||||
}.onSuccess {
|
||||
_state.value = _state.value.copy(
|
||||
complaintSending = false,
|
||||
showComplaint = false,
|
||||
snackbar = "Жалоба отправлена",
|
||||
)
|
||||
}.onFailure { t ->
|
||||
_state.value = _state.value.copy(
|
||||
complaintSending = false,
|
||||
error = t.message,
|
||||
unauthorized = t is UnauthorizedException,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearSnackbar() {
|
||||
_state.value = _state.value.copy(snackbar = null)
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_state.value = _state.value.copy(error = null)
|
||||
}
|
||||
|
||||
fun openTicketFromPush(session: AuthSession, ticketNumber: String) {
|
||||
if (cachedConfig == null) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
runCatching { repository.loadConfig(session) }
|
||||
.onSuccess { config ->
|
||||
cachedConfig = config
|
||||
_state.value = _state.value.copy(config = config)
|
||||
openTicket(session, ticketNumber)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
openTicket(session, ticketNumber)
|
||||
}
|
||||
}
|
||||
|
||||
private fun restartPolling(session: AuthSession) {
|
||||
pollJob?.cancel()
|
||||
pollJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
while (isActive) {
|
||||
delay(pollIntervalMs())
|
||||
if (!AppForegroundTracker.isForeground) continue
|
||||
val config = cachedConfig ?: continue
|
||||
val selected = _state.value.selectedTicket
|
||||
runCatching {
|
||||
if (selected != null) {
|
||||
val messages = repository.loadMessages(config, selected.ticketNumber)
|
||||
val tickets = repository.listTickets(config)
|
||||
Pair(messages, tickets)
|
||||
} else {
|
||||
Pair(null, repository.listTickets(config))
|
||||
}
|
||||
}.onSuccess { (messages, tickets) ->
|
||||
if (selected != null && messages != null) {
|
||||
val updatedSelected = tickets.find { it.ticketNumber == selected.ticketNumber }
|
||||
_state.value = _state.value.copy(
|
||||
messages = messages,
|
||||
tickets = tickets,
|
||||
selectedTicket = updatedSelected ?: selected,
|
||||
)
|
||||
} else {
|
||||
_state.value = _state.value.copy(tickets = tickets)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun pollIntervalMs(): Long =
|
||||
if (_state.value.selectedTicket != null) POLL_TICKET_MS else POLL_LIST_MS
|
||||
|
||||
override fun onCleared() {
|
||||
pollJob?.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val POLL_LIST_MS = 15_000L
|
||||
private const val POLL_TICKET_MS = 8_000L
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user