diff --git a/.cursor/skills/material-3-skill b/.cursor/skills/material-3-skill index 313f584..bc4e480 160000 --- a/.cursor/skills/material-3-skill +++ b/.cursor/skills/material-3-skill @@ -1 +1 @@ -Subproject commit 313f5842171fde7ad00e6a339b7f07b0e64380aa +Subproject commit bc4e480586621a22f589b78e833a5e999cfb3c8c diff --git a/app/android/src/main/AndroidManifest.xml b/app/android/src/main/AndroidManifest.xml index 5bbb63b..67ef6a1 100644 --- a/app/android/src/main/AndroidManifest.xml +++ b/app/android/src/main/AndroidManifest.xml @@ -56,7 +56,7 @@ diff --git a/app/android/src/main/kotlin/ru/fromchat/App.kt b/app/android/src/main/kotlin/ru/fromchat/App.kt index c8913d2..dd508dd 100644 --- a/app/android/src/main/kotlin/ru/fromchat/App.kt +++ b/app/android/src/main/kotlin/ru/fromchat/App.kt @@ -10,8 +10,8 @@ import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import ru.fromchat.api.ApiClient -import ru.fromchat.api.AttachmentTransferBootstrap -import ru.fromchat.api.WebSocketManager +import ru.fromchat.api.local.workers.AttachmentTransferBootstrap +import ru.fromchat.api.local.WebSocketManager import ru.fromchat.notifications.NotificationHelper class App: Application() { diff --git a/app/android/src/main/kotlin/ru/fromchat/MainActivity.kt b/app/android/src/main/kotlin/ru/fromchat/MainActivity.kt index b10e0d2..e91578a 100644 --- a/app/android/src/main/kotlin/ru/fromchat/MainActivity.kt +++ b/app/android/src/main/kotlin/ru/fromchat/MainActivity.kt @@ -13,7 +13,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability import io.ktor.client.call.body @@ -25,13 +25,11 @@ import io.ktor.http.contentType import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch -import androidx.lifecycle.lifecycleScope import ru.fromchat.api.ApiClient -import ru.fromchat.api.MessagesResponse -import ru.fromchat.core.config.Config -import ru.fromchat.core.Logger +import ru.fromchat.api.schema.messages.MessagesResponse +import ru.fromchat.config.ServerConfig import ru.fromchat.ui.App -import ru.fromchat.ui.isPublicChatVisible +import ru.fromchat.ui.chat.panels.publicchat.isPublicChatVisible private const val EXTRA_NOTIFICATION_CHAT_TYPE = "notification_chat_type" private const val EXTRA_OPEN_DM_USER_ID = "open_dm_user_id" @@ -172,13 +170,13 @@ class MainActivity : ComponentActivity() { try { // Get all unread messages and mark them as read val messageIds = ApiClient.http - .get("${Config.apiBaseUrl}/messages/new") + .get("${ServerConfig.apiBaseUrl}/messages/new") .body() .messages .map { it.id } if (messageIds.isNotEmpty()) { - ApiClient.http.post("${Config.apiBaseUrl}/messages/read") { + ApiClient.http.post("${ServerConfig.apiBaseUrl}/messages/read") { contentType(ContentType.Application.Json) setBody(mapOf("messageIds" to messageIds)) } diff --git a/app/android/src/main/kotlin/ru/fromchat/fcm/FromChatFirebaseMessagingService.kt b/app/android/src/main/kotlin/ru/fromchat/fcm/FromChatFirebaseMessagingService.kt index 5c1c658..ac2948f 100644 --- a/app/android/src/main/kotlin/ru/fromchat/fcm/FromChatFirebaseMessagingService.kt +++ b/app/android/src/main/kotlin/ru/fromchat/fcm/FromChatFirebaseMessagingService.kt @@ -10,7 +10,7 @@ import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import ru.fromchat.api.ApiClient import ru.fromchat.notifications.NotificationHelper -import ru.fromchat.fcm.uploadPendingFcmTokenIfAvailable +import ru.fromchat.api.uploadPendingFcmTokenIfAvailable @OptIn(DelicateCoroutinesApi::class) class FromChatFirebaseMessagingService : FirebaseMessagingService() { diff --git a/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt b/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt index 0938167..7684e71 100644 --- a/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt +++ b/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt @@ -27,14 +27,15 @@ import kotlinx.coroutines.launch import ru.fromchat.MainActivity import ru.fromchat.R import ru.fromchat.api.ApiClient -import ru.fromchat.api.DmHistoryResponse -import ru.fromchat.api.Message -import ru.fromchat.api.MessagesResponse -import ru.fromchat.core.config.Config -import ru.fromchat.crypto.CorruptedDmMessagePlaceholder -import ru.fromchat.crypto.DmCiphertextCorruptedException -import ru.fromchat.crypto.decryptEnvelope -import ru.fromchat.ui.isPublicChatVisible +import ru.fromchat.api.schema.messages.Message +import ru.fromchat.api.schema.messages.MessagesResponse +import ru.fromchat.api.schema.messages.dm.DmHistoryResponse +import ru.fromchat.config.ServerConfig +import ru.fromchat.api.crypto.CorruptedDmMessagePlaceholder +import ru.fromchat.api.crypto.DmCiphertextCorruptedException +import ru.fromchat.api.crypto.decryptEnvelope +import ru.fromchat.ui.chat.panels.publicchat.isPublicChatVisible +import kotlin.time.Instant object NotificationHelper { private const val EXTRA_NOTIFICATION_CHAT_TYPE = "notification_chat_type" @@ -141,7 +142,7 @@ object NotificationHelper { } val messages = ApiClient.http - .get("${Config.apiBaseUrl}/messages/new") + .get("${ServerConfig.apiBaseUrl}/messages/new") .body() .messages Log.d("NotificationHelper", "fetchAndNotify: fetched ${messages.size} public messages") @@ -164,7 +165,7 @@ object NotificationHelper { Log.w("NotificationHelper", "fetchAndNotify: received 401; reloading token and retrying") ApiClient.loadPersistedData() val retryMessages = ApiClient.http - .get("${Config.apiBaseUrl}/messages/new") + .get("${ServerConfig.apiBaseUrl}/messages/new") .body() .messages Log.d( @@ -470,7 +471,7 @@ object NotificationHelper { ).setConversationTitle("Public Chat").let { for (msg in messages.takeLast(10)) { val timestamp = try { - java.time.Instant.parse(msg.timestamp).toEpochMilli() + Instant.parse(msg.timestamp).toEpochMilliseconds() } catch (_: Exception) { System.currentTimeMillis() } diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts index 3eb0683..fad9538 100644 --- a/app/shared/build.gradle.kts +++ b/app/shared/build.gradle.kts @@ -11,7 +11,7 @@ kotlin { android { namespace = "ru.fromchat.shared" minSdk = 24 - compileSdk = 36 + compileSdk = 37 } compilerOptions { diff --git a/app/shared/src/androidMain/AndroidManifest.xml b/app/shared/src/androidMain/AndroidManifest.xml index ec26fe8..7345817 100644 --- a/app/shared/src/androidMain/AndroidManifest.xml +++ b/app/shared/src/androidMain/AndroidManifest.xml @@ -9,15 +9,15 @@ = 1f) return bitmap - val dstW = (bitmap.width * scale).toInt().coerceAtLeast(1) - val dstH = (bitmap.height * scale).toInt().coerceAtLeast(1) - return Bitmap.createScaledBitmap(bitmap, dstW, dstH, true) + + return bitmap.scale( + (bitmap.width * scale).toInt().coerceAtLeast(1), + (bitmap.height * scale).toInt().coerceAtLeast(1) + ) } -private fun readExifOrientation(path: String): Int = +private fun readExifOrientation(path: String) = runCatching { ExifInterface(path).getAttributeInt( ExifInterface.TAG_ORIENTATION, @@ -98,13 +130,13 @@ private fun orientedDimensions(width: Int, height: Int, orientation: Int): Pair< ExifInterface.ORIENTATION_ROTATE_90, ExifInterface.ORIENTATION_ROTATE_270, ExifInterface.ORIENTATION_TRANSPOSE, - ExifInterface.ORIENTATION_TRANSVERSE, - -> height to width + ExifInterface.ORIENTATION_TRANSVERSE -> height to width else -> width to height } private fun applyExifOrientation(bitmap: Bitmap, orientation: Int): Bitmap { val matrix = Matrix() + when (orientation) { ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f) ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f) @@ -121,8 +153,16 @@ private fun applyExifOrientation(bitmap: Bitmap, orientation: Int): Bitmap { } else -> return bitmap } - return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) - ?: bitmap + + return Bitmap.createBitmap( + bitmap, + 0, + 0, + bitmap.width, + bitmap.height, + matrix, + true + ) } /** Android docs: largest inSampleSize where both dimensions stay >= requested. */ @@ -133,12 +173,15 @@ private fun calculateInSampleSize( reqHeightPx: Int, ): Int { var inSampleSize = 1 + if (height > reqHeightPx || width > reqWidthPx) { - var halfHeight = height / 2 - var halfWidth = width / 2 + val halfHeight = height / 2 + val halfWidth = width / 2 + while (halfHeight / inSampleSize >= reqHeightPx && halfWidth / inSampleSize >= reqWidthPx) { inSampleSize *= 2 } } + return inSampleSize.coerceAtLeast(1) } diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/MessageFileSave.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/download/MessageFileSave.android.kt similarity index 88% rename from app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/MessageFileSave.android.kt rename to app/shared/src/androidMain/kotlin/ru/fromchat/api/local/download/MessageFileSave.android.kt index 3403bce..a7e1129 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/MessageFileSave.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/download/MessageFileSave.android.kt @@ -1,4 +1,4 @@ -package ru.fromchat.ui.chat +package ru.fromchat.api.local.download import android.app.Activity import android.content.Context @@ -16,6 +16,9 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import com.pr0gramm3r101.utils.UtilsLibrary import kotlinx.coroutines.launch +import ru.fromchat.api.local.cache.PendingFileSaveEntry +import ru.fromchat.api.local.cache.PendingFileSaveRegistry +import ru.fromchat.ui.chat.copyCachedFileToDestinationUri private class CreateFileSaveContract : ActivityResultContract() { override fun createIntent(context: Context, input: SavableMessageFile): Intent { @@ -54,17 +57,21 @@ actual fun rememberPlatformSaveMessageFile( val launcher = rememberLauncherForActivityResult(CreateFileSaveContract()) { destination -> val pending = pendingSavable pendingSavable = null + if (destination == null || pending == null) { onComplete(false) return@rememberLauncherForActivityResult } + scope.launch { runCatching { - val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION - UtilsLibrary.context.contentResolver.takePersistableUriPermission(destination, flags) + UtilsLibrary.context.contentResolver.takePersistableUriPermission( + destination, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) } - val cacheUri = pending.cacheUri - if (cachedAttachmentFileSize(cacheUri) <= 0L) { + + if (cachedAttachmentFileSize(pending.cacheUri) <= 0L) { PendingFileSaveRegistry.schedule( PendingFileSaveEntry( storageKey = pending.storageKey, @@ -77,12 +84,14 @@ actual fun rememberPlatformSaveMessageFile( onComplete(false) return@launch } + val ok = copyCachedFileToDestinationUri( - sourceCacheUri = cacheUri, + sourceCacheUri = pending.cacheUri, destinationUri = destination.toString(), storageKey = pending.storageKey, displayFilename = pending.filename, ) + if (ok) { PendingFileSaveRegistry.remove(pending.storageKey) } else { @@ -96,9 +105,11 @@ actual fun rememberPlatformSaveMessageFile( ), ) } + onComplete(ok) } } + return remember(launcher) { { savable: SavableMessageFile -> pendingSavable = savable diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/MessageImageSave.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/download/MessageImageSave.android.kt similarity index 86% rename from app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/MessageImageSave.android.kt rename to app/shared/src/androidMain/kotlin/ru/fromchat/api/local/download/MessageImageSave.android.kt index 8f14fa9..3098bf1 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/MessageImageSave.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/download/MessageImageSave.android.kt @@ -1,4 +1,4 @@ -package ru.fromchat.ui.chat +package ru.fromchat.api.local.download import android.app.Activity import android.content.Context @@ -59,15 +59,17 @@ actual fun rememberPlatformSaveMessageImage( onComplete(false) return@rememberLauncherForActivityResult } + scope.launch { - val ok = withContext(Dispatchers.IO) { - runCatching { - UtilsLibrary.context.contentResolver.openOutputStream(destination)?.use { out -> - out.write(bytes) - } != null - }.getOrDefault(false) - } - onComplete(ok) + onComplete( + withContext(Dispatchers.IO) { + runCatching { + UtilsLibrary.context.contentResolver.openOutputStream(destination)?.use { out -> + out.write(bytes) + } != null + }.getOrDefault(false) + } + ) } } diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/api/outbox/MediaUploadForegroundHelper.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/send/MediaUploadForegroundHelper.kt similarity index 96% rename from app/shared/src/androidMain/kotlin/ru/fromchat/api/outbox/MediaUploadForegroundHelper.kt rename to app/shared/src/androidMain/kotlin/ru/fromchat/api/local/send/MediaUploadForegroundHelper.kt index dc2ea6d..4e256b5 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/api/outbox/MediaUploadForegroundHelper.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/send/MediaUploadForegroundHelper.kt @@ -1,5 +1,6 @@ -package ru.fromchat.api.outbox +package ru.fromchat.api.local.send +import android.R import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager @@ -47,7 +48,7 @@ object MediaUploadForegroundHelper { val builder = NotificationCompat.Builder(context, CHANNEL_ID) .setContentTitle(title) .setContentText(contentText) - .setSmallIcon(android.R.drawable.stat_sys_upload) + .setSmallIcon(R.drawable.stat_sys_upload) .setOngoing(true) .setOnlyAlertOnce(true) .setPriority(NotificationCompat.PRIORITY_LOW) diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/api/outbox/OutboxProcessor.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/send/OutboxProcessor.android.kt similarity index 97% rename from app/shared/src/androidMain/kotlin/ru/fromchat/api/outbox/OutboxProcessor.android.kt rename to app/shared/src/androidMain/kotlin/ru/fromchat/api/local/send/OutboxProcessor.android.kt index 6b64dff..3556f9d 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/api/outbox/OutboxProcessor.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/send/OutboxProcessor.android.kt @@ -1,4 +1,4 @@ -package ru.fromchat.api.outbox +package ru.fromchat.api.local.send import androidx.work.Constraints import androidx.work.Data diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/api/outbox/OutboxSendWorker.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/send/OutboxSendWorker.kt similarity index 91% rename from app/shared/src/androidMain/kotlin/ru/fromchat/api/outbox/OutboxSendWorker.kt rename to app/shared/src/androidMain/kotlin/ru/fromchat/api/local/send/OutboxSendWorker.kt index 934e7ea..abf2b0a 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/api/outbox/OutboxSendWorker.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/send/OutboxSendWorker.kt @@ -1,13 +1,13 @@ -package ru.fromchat.api.outbox +package ru.fromchat.api.local.send import android.content.Context import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch -import ru.fromchat.api.AttachmentUploadNotifier -import ru.fromchat.api.AttachmentUploadProgress -import ru.fromchat.core.cache.CacheContext +import ru.fromchat.api.local.workers.AttachmentUploadNotifier +import ru.fromchat.api.local.workers.AttachmentUploadProgress +import ru.fromchat.api.local.cache.CacheContext class OutboxSendWorker( context: Context, diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/download/AttachmentDownloadForegroundService.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/workers/AttachmentDownloadForegroundService.kt similarity index 94% rename from app/shared/src/androidMain/kotlin/ru/fromchat/download/AttachmentDownloadForegroundService.kt rename to app/shared/src/androidMain/kotlin/ru/fromchat/api/local/workers/AttachmentDownloadForegroundService.kt index 14fb25d..27db2e9 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/download/AttachmentDownloadForegroundService.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/workers/AttachmentDownloadForegroundService.kt @@ -1,5 +1,6 @@ -package ru.fromchat.download +package ru.fromchat.api.local.workers +import android.R import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager @@ -104,7 +105,7 @@ class AttachmentDownloadForegroundService : Service() { val builder = NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle(title) .setContentText(contentText) - .setSmallIcon(android.R.drawable.stat_sys_download) + .setSmallIcon(R.drawable.stat_sys_download) .setOngoing(true) .setOnlyAlertOnce(true) .setPriority(NotificationCompat.PRIORITY_LOW) @@ -127,9 +128,9 @@ class AttachmentDownloadForegroundService : Service() { private const val CHANNEL_ID = "fromchat_file_download" private const val NOTIFICATION_ID = 0xFC12 - private const val ACTION_START = "ru.fromchat.download.AttachmentDownloadForegroundService.START" - private const val ACTION_STOP = "ru.fromchat.download.AttachmentDownloadForegroundService.STOP" - private const val ACTION_UPDATE = "ru.fromchat.download.AttachmentDownloadForegroundService.UPDATE" + private const val ACTION_START = "ru.fromchat.api.local.workers.AttachmentDownloadForegroundService.START" + private const val ACTION_STOP = "ru.fromchat.api.local.workers.AttachmentDownloadForegroundService.STOP" + private const val ACTION_UPDATE = "ru.fromchat.api.local.workers.AttachmentDownloadForegroundService.UPDATE" private const val EXTRA_CHANNEL_NAME = "channel_name" private const val EXTRA_TITLE = "title" diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/api/AttachmentFileCopyForeground.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/workers/AttachmentFileCopyForeground.android.kt similarity index 86% rename from app/shared/src/androidMain/kotlin/ru/fromchat/api/AttachmentFileCopyForeground.android.kt rename to app/shared/src/androidMain/kotlin/ru/fromchat/api/local/workers/AttachmentFileCopyForeground.android.kt index be489f4..fff86da 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/api/AttachmentFileCopyForeground.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/workers/AttachmentFileCopyForeground.android.kt @@ -1,7 +1,6 @@ -package ru.fromchat.api +package ru.fromchat.api.local.workers import com.pr0gramm3r101.utils.UtilsLibrary -import ru.fromchat.download.AttachmentFileCopyForegroundService actual object AttachmentFileCopyForeground { actual fun onCopyStarted(storageKey: String, displayLabel: String?) { diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/download/AttachmentFileCopyForegroundService.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/workers/AttachmentFileCopyForegroundService.kt similarity index 93% rename from app/shared/src/androidMain/kotlin/ru/fromchat/download/AttachmentFileCopyForegroundService.kt rename to app/shared/src/androidMain/kotlin/ru/fromchat/api/local/workers/AttachmentFileCopyForegroundService.kt index e24e775..925465e 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/download/AttachmentFileCopyForegroundService.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/workers/AttachmentFileCopyForegroundService.kt @@ -1,5 +1,6 @@ -package ru.fromchat.download +package ru.fromchat.api.local.workers +import android.R import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager @@ -51,7 +52,7 @@ class AttachmentFileCopyForegroundService : Service() { val notification = NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle(title) .setContentText(contentText) - .setSmallIcon(android.R.drawable.stat_sys_download_done) + .setSmallIcon(R.drawable.stat_sys_download_done) .setOngoing(true) .setOnlyAlertOnce(true) .setPriority(NotificationCompat.PRIORITY_LOW) @@ -81,9 +82,9 @@ class AttachmentFileCopyForegroundService : Service() { private const val NOTIFICATION_ID = 0xFC13 private const val ACTION_START = - "ru.fromchat.download.AttachmentFileCopyForegroundService.START" + "ru.fromchat.api.local.workers.AttachmentFileCopyForegroundService.START" private const val ACTION_STOP = - "ru.fromchat.download.AttachmentFileCopyForegroundService.STOP" + "ru.fromchat.api.local.workers.AttachmentFileCopyForegroundService.STOP" private const val EXTRA_CHANNEL_NAME = "channel_name" private const val EXTRA_TITLE = "title" diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/workers/AttachmentUploadQueue.android.kt similarity index 70% rename from app/shared/src/androidMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.android.kt rename to app/shared/src/androidMain/kotlin/ru/fromchat/api/local/workers/AttachmentUploadQueue.android.kt index 35ef5f5..2e6a414 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/workers/AttachmentUploadQueue.android.kt @@ -1,11 +1,12 @@ -package ru.fromchat.api +package ru.fromchat.api.local.workers import kotlinx.coroutines.MainScope import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.launch -import ru.fromchat.api.outbox.OutgoingMessageCoordinator -import ru.fromchat.api.outbox.scheduleOutboxProcessing -import ru.fromchat.core.cache.CacheContext +import ru.fromchat.api.local.db.store.MessageDatabaseProvider +import ru.fromchat.api.local.send.OutgoingMessageCoordinator +import ru.fromchat.api.local.send.scheduleOutboxProcessing +import ru.fromchat.api.local.cache.CacheContext actual object AttachmentUploadQueue { actual val progressFlow: SharedFlow = AttachmentUploadNotifier.progressFlow @@ -18,7 +19,7 @@ actual object AttachmentUploadQueue { val instanceId = CacheContext.activeInstanceId.value.trim() if (instanceId.isEmpty()) return MainScope().launch { - val row = ru.fromchat.api.db.MessageDatabaseProvider.database.messageDatabaseQueries + val row = MessageDatabaseProvider.database.messageDatabaseQueries .selectOutboxItem(instanceId, jobId.trim()) .executeAsOneOrNull() val conversationId = row?.conversationId ?: return@launch diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/core/DeferredStartupNetwork.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/core/DeferredStartupNetwork.android.kt deleted file mode 100644 index b2e0533..0000000 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/core/DeferredStartupNetwork.android.kt +++ /dev/null @@ -1,9 +0,0 @@ -package ru.fromchat.core - -import ru.fromchat.fcm.ensureFcmTokenRegistered -import ru.fromchat.fcm.uploadPendingFcmTokenIfAvailable - -actual suspend fun syncPushTokenAfterStartup() { - uploadPendingFcmTokenIfAvailable() - ensureFcmTokenRegistered() -} diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/debug/DebugLogger.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/debug/DebugLogger.android.kt deleted file mode 100644 index b180964..0000000 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/debug/DebugLogger.android.kt +++ /dev/null @@ -1,55 +0,0 @@ -package ru.fromchat.debug - -import io.ktor.client.HttpClient -import io.ktor.client.engine.okhttp.OkHttp -import io.ktor.client.request.header -import io.ktor.client.request.post -import io.ktor.client.request.setBody -import io.ktor.http.ContentType -import io.ktor.http.contentType -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch - -actual object DebugLogger { - private const val ENDPOINT = - "http://127.0.0.1:7809/ingest/d9aebf8d-fb01-41c9-af88-eb0676507989" - private const val SESSION_ID = "9be525" - - // Single shared Ktor client for all debug logs. - private val client = HttpClient(OkHttp) - - // Lightweight scope for fire-and-forget debug logging. - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - - actual fun log(payload: DebugLogPayload) { - // Network logging is best-effort; never throw from here. - val safeMessage = payload.message.replace("\"", "'") - val safeLocation = payload.location.replace("\"", "'") - val json = buildString { - append('{') - append("\"sessionId\":\"").append(payload.sessionId).append('"') - append(",\"runId\":\"").append(payload.runId).append('"') - append(",\"hypothesisId\":\"").append(payload.hypothesisId).append('"') - append(",\"location\":\"").append(safeLocation).append('"') - append(",\"message\":\"").append(safeMessage).append('"') - append(",\"data\":").append(payload.data) - append(",\"timestamp\":").append(payload.timestamp) - append('}') - } - - scope.launch { - try { - client.post(ENDPOINT) { - contentType(ContentType.Application.Json) - header("X-Debug-Session-Id", SESSION_ID) - setBody(json) - } - } catch (_: Exception) { - // Swallow all errors in debug logger. - } - } - } -} - diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/platform/CurrentDeviceInfo.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/platform/CurrentDeviceInfo.android.kt deleted file mode 100644 index 7d37179..0000000 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/platform/CurrentDeviceInfo.android.kt +++ /dev/null @@ -1,22 +0,0 @@ -package ru.fromchat.platform - -import android.os.Build - -private fun String?.trimIfNotBlank(): String? = - this?.trim()?.takeIf { it.isNotEmpty() } - -actual fun currentDeviceInfo(): CurrentDeviceInfo { - val osName = "Android" - val osVersion = Build.VERSION.RELEASE.trimIfNotBlank() ?: Build.VERSION.SDK_INT.toString() - val brand = Build.BRAND.trimIfNotBlank() ?: Build.MANUFACTURER.trimIfNotBlank() - val model = Build.MODEL.trimIfNotBlank() - - return CurrentDeviceInfo( - osName = osName, - osVersion = osVersion, - deviceType = "mobile", - deviceName = model?.let { if (it.contains(brand.orEmpty(), ignoreCase = true)) it else listOfNotNull(brand, it).joinToString(" ") }, - brand = brand, - model = model - ) -} diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/calls/CallForegroundService.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/calls/CallForegroundService.kt similarity index 97% rename from app/shared/src/androidMain/kotlin/ru/fromchat/calls/CallForegroundService.kt rename to app/shared/src/androidMain/kotlin/ru/fromchat/ui/calls/CallForegroundService.kt index 15a6a77..8db93e0 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/calls/CallForegroundService.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/calls/CallForegroundService.kt @@ -1,19 +1,21 @@ -package ru.fromchat.calls +package ru.fromchat.ui.calls +import android.R import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.app.Service -import android.graphics.Color import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo +import android.graphics.Color import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.app.Person import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat +import ru.fromchat.api.calls.CallStore /** * Foreground call session: keeps camera / mic eligible in background. @@ -34,7 +36,7 @@ class CallForegroundService : Service() { stopSelf() return START_NOT_STICKY } - ACTION_START -> startAsForeground(intent!!) + ACTION_START -> startAsForeground(intent) ACTION_STOP -> { stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() @@ -61,7 +63,7 @@ class CallForegroundService : Service() { val smallIcon = try { packageManager.getApplicationInfo(packageName, 0).icon } catch (_: Exception) { - android.R.drawable.sym_call_outgoing + R.drawable.sym_call_outgoing } val hangUpPi = PendingIntent.getService( diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/calls/CallMediaLayer.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/calls/CallMediaLayer.android.kt similarity index 98% rename from app/shared/src/androidMain/kotlin/ru/fromchat/calls/CallMediaLayer.android.kt rename to app/shared/src/androidMain/kotlin/ru/fromchat/ui/calls/CallMediaLayer.android.kt index 2fb5f62..0f2ae83 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/calls/CallMediaLayer.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/calls/CallMediaLayer.android.kt @@ -1,6 +1,7 @@ -package ru.fromchat.calls +package ru.fromchat.ui.calls import android.Manifest +import android.R import android.app.Activity import android.app.Notification import android.app.NotificationChannel @@ -19,8 +20,8 @@ import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.Spring import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition @@ -40,13 +41,11 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.CircleShape @@ -64,25 +63,24 @@ import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import ru.fromchat.ui.components.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.key import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.zIndex import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext @@ -91,8 +89,10 @@ import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat +import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi @@ -100,21 +100,19 @@ import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.rememberHazeState import io.livekit.android.RoomOptions import io.livekit.android.compose.local.RoomScope -import io.livekit.android.events.RoomEvent -import io.livekit.android.events.collect import io.livekit.android.compose.state.rememberParticipantTrackReferences import io.livekit.android.compose.state.rememberParticipants import io.livekit.android.compose.types.TrackReference import io.livekit.android.compose.ui.ScaleType import io.livekit.android.compose.ui.VideoTrackView +import io.livekit.android.events.RoomEvent +import io.livekit.android.events.collect import io.livekit.android.room.Room -import io.livekit.android.room.track.LocalAudioTrackOptions import io.livekit.android.room.participant.LocalParticipant import io.livekit.android.room.participant.Participant +import io.livekit.android.room.track.LocalAudioTrackOptions import io.livekit.android.room.track.Track import io.livekit.android.room.track.screencapture.ScreenCaptureParams -import kotlin.math.roundToInt -import kotlin.math.sqrt import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -123,14 +121,29 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.coroutines.yield import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res -import ru.fromchat.* import ru.fromchat.api.ApiClient -import ru.fromchat.api.ProfileCache -import ru.fromchat.core.Logger +import ru.fromchat.api.calls.CallStore +import ru.fromchat.api.calls.LiveKitConnectSession +import ru.fromchat.api.local.db.store.ProfileCache +import ru.fromchat.call_status_connecting +import ru.fromchat.call_status_reconnecting +import ru.fromchat.call_status_reconnecting_with_detail +import ru.fromchat.cd_call_camera +import ru.fromchat.cd_call_end +import ru.fromchat.cd_call_mic +import ru.fromchat.cd_call_screenshare +import ru.fromchat.Logger +import ru.fromchat.message_sender_you +import ru.fromchat.notif_call_channel_name +import ru.fromchat.notif_call_ongoing_text +import ru.fromchat.notif_call_ongoing_title +import ru.fromchat.notif_screenshare_text +import ru.fromchat.notif_screenshare_title import ru.fromchat.ui.chat.Avatar +import kotlin.math.roundToInt +import kotlin.math.sqrt private const val TAG = "CallMediaLayer" private const val SCREEN_SHARE_NOTIFICATION_ID = 99102 @@ -458,10 +471,9 @@ actual fun CallMediaLayer( } is RoomEvent.FailedToConnect -> { val msg = - event.error?.message - ?.takeIf { it.isNotBlank() } - ?: event.error?.toString() - .orEmpty() + event.error.message?.takeIf { it.isNotBlank() } + ?: event.error.toString() + Logger.e(TAG, "RoomEvent.FailedToConnect", event.error) scheduleReconnect(msg.takeIf { it.isNotBlank() }) } @@ -1159,7 +1171,7 @@ private fun CallPreviewCluster( } } -@OptIn(ExperimentalSharedTransitionApi::class) +@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalHazeMaterialsApi::class) @Composable private fun PreviewTile( room: Room, @@ -1232,7 +1244,7 @@ private fun CallInlineControlBar( room: Room, micRequestedOn: Boolean, onMicRequestedChange: (Boolean) -> Unit, - hazeState: dev.chrisbanes.haze.HazeState, + hazeState: HazeState, modifier: Modifier, ) { val scope = rememberCoroutineScope() @@ -1277,7 +1289,7 @@ private fun CallInlineControlBar( val smallIcon = try { context.packageManager.getApplicationInfo(context.packageName, 0).icon } catch (_: Exception) { - android.R.drawable.stat_sys_upload + R.drawable.stat_sys_upload } return NotificationCompat.Builder(context, SCREEN_SHARE_CHANNEL_ID) .setContentTitle(updatedTitle) diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/calls/InlineCallChrome.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/calls/InlineCallChrome.android.kt similarity index 63% rename from app/shared/src/androidMain/kotlin/ru/fromchat/calls/InlineCallChrome.android.kt rename to app/shared/src/androidMain/kotlin/ru/fromchat/ui/calls/InlineCallChrome.android.kt index 9b45aff..2ab6851 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/calls/InlineCallChrome.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/calls/InlineCallChrome.android.kt @@ -1,3 +1,3 @@ -package ru.fromchat.calls +package ru.fromchat.ui.calls actual val UseInlineInCallChrome: Boolean = true diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentFileCopy.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentFileCopy.android.kt index 1623efd..8f434a0 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentFileCopy.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentFileCopy.android.kt @@ -4,7 +4,7 @@ import android.net.Uri import com.pr0gramm3r101.utils.UtilsLibrary import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import ru.fromchat.api.AttachmentFileCopyForeground +import ru.fromchat.api.local.workers.AttachmentFileCopyForeground import java.io.File import java.io.FileInputStream diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.android.kt deleted file mode 100644 index a9d3134..0000000 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.android.kt +++ /dev/null @@ -1,150 +0,0 @@ -package ru.fromchat.ui.chat - -import android.graphics.BitmapFactory -import android.net.Uri -import android.provider.OpenableColumns -import androidx.exifinterface.media.ExifInterface -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.PickVisualMediaRequest -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.runtime.Composable - -actual fun getFilenameFromUri(uri: String): String { - val context = com.pr0gramm3r101.utils.UtilsLibrary.context - context.contentResolver.query(Uri.parse(uri), null, null, null, null)?.use { cursor -> - val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - if (nameIndex >= 0 && cursor.moveToFirst()) { - val name = cursor.getString(nameIndex) - if (!name.isNullOrBlank()) return name - } - } - return uri.substringAfterLast('/').takeIf { it.isNotBlank() } ?: "file" -} - -actual suspend fun getImageAspectRatio(uri: String): Float? { - val context = com.pr0gramm3r101.utils.UtilsLibrary.context - val parsed = Uri.parse(uri) - val orientation = when { - parsed.scheme == "content" || parsed.scheme == "file" -> { - runCatching { - context.contentResolver.openInputStream(parsed)?.use { stream -> - ExifInterface(stream).getAttributeInt( - ExifInterface.TAG_ORIENTATION, - ExifInterface.ORIENTATION_NORMAL, - ) - } - }.getOrNull() ?: ExifInterface.ORIENTATION_NORMAL - } - else -> { - val path = parsed.path - if (path != null) { - runCatching { - ExifInterface(path).getAttributeInt( - ExifInterface.TAG_ORIENTATION, - ExifInterface.ORIENTATION_NORMAL, - ) - }.getOrNull() ?: ExifInterface.ORIENTATION_NORMAL - } else { - ExifInterface.ORIENTATION_NORMAL - } - } - } - context.contentResolver.openInputStream(parsed)?.use { stream -> - val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } - BitmapFactory.decodeStream(stream, null, options) - var w = options.outWidth - var h = options.outHeight - if (w <= 0 || h <= 0) return null - when (orientation) { - ExifInterface.ORIENTATION_ROTATE_90, - ExifInterface.ORIENTATION_ROTATE_270, - ExifInterface.ORIENTATION_TRANSPOSE, - ExifInterface.ORIENTATION_TRANSVERSE, - -> { - val swap = w - w = h - h = swap - } - } - return w.toFloat() / h.toFloat() - } - return null -} - -actual suspend fun getImageDimensions(uri: String): Pair? { - val context = com.pr0gramm3r101.utils.UtilsLibrary.context - val parsed = Uri.parse(uri) - val orientation = when { - parsed.scheme == "content" || parsed.scheme == "file" -> { - runCatching { - context.contentResolver.openInputStream(parsed)?.use { stream -> - ExifInterface(stream).getAttributeInt( - ExifInterface.TAG_ORIENTATION, - ExifInterface.ORIENTATION_NORMAL, - ) - } - }.getOrNull() ?: ExifInterface.ORIENTATION_NORMAL - } - else -> { - val path = parsed.path - if (path != null) { - runCatching { - ExifInterface(path).getAttributeInt( - ExifInterface.TAG_ORIENTATION, - ExifInterface.ORIENTATION_NORMAL, - ) - }.getOrNull() ?: ExifInterface.ORIENTATION_NORMAL - } else { - ExifInterface.ORIENTATION_NORMAL - } - } - } - context.contentResolver.openInputStream(parsed)?.use { stream -> - val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } - BitmapFactory.decodeStream(stream, null, options) - var w = options.outWidth - var h = options.outHeight - if (w <= 0 || h <= 0) return null - when (orientation) { - ExifInterface.ORIENTATION_ROTATE_90, - ExifInterface.ORIENTATION_ROTATE_270, - ExifInterface.ORIENTATION_TRANSPOSE, - ExifInterface.ORIENTATION_TRANSVERSE, - -> { - val swap = w - w = h - h = swap - } - } - return w to h - } - return null -} - -@Composable -actual fun rememberImagePicker(onResult: (List) -> Unit): () -> Unit { - val launcher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.PickMultipleVisualMedia() - ) { uris: List -> - onResult(uris.map { it.toString() }) - } - return { - launcher.launch( - PickVisualMediaRequest.Builder() - .setMediaType(ActivityResultContracts.PickVisualMedia.ImageOnly) - .build() - ) - } -} - -@Composable -actual fun rememberFilePicker(onResult: (List) -> Unit): () -> Unit { - val launcher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.OpenMultipleDocuments() - ) { uris: List -> - onResult(uris.map { it.toString() }) - } - return { - launcher.launch(arrayOf("*/*")) - } -} diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentFileDownload.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentFileDownload.android.kt similarity index 98% rename from app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentFileDownload.android.kt rename to app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentFileDownload.android.kt index 64bf29f..c49097b 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentFileDownload.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentFileDownload.android.kt @@ -1,4 +1,4 @@ -package ru.fromchat.ui.chat +package ru.fromchat.ui.chat.utils import android.app.Activity import android.content.Context diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentOpenFeedback.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentOpenFeedback.android.kt similarity index 86% rename from app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentOpenFeedback.android.kt rename to app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentOpenFeedback.android.kt index 1d6dd2f..f8e9512 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentOpenFeedback.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentOpenFeedback.android.kt @@ -1,4 +1,4 @@ -package ru.fromchat.ui.chat +package ru.fromchat.ui.chat.utils import android.widget.Toast import com.pr0gramm3r101.utils.UtilsLibrary diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentPickers.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentPickers.android.kt new file mode 100644 index 0000000..714aa0d --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentPickers.android.kt @@ -0,0 +1,118 @@ +package ru.fromchat.ui.chat.utils + +import android.graphics.BitmapFactory +import android.net.Uri +import android.provider.OpenableColumns +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.core.net.toUri +import androidx.exifinterface.media.ExifInterface +import com.pr0gramm3r101.utils.UtilsLibrary + +private fun orientation(uri: Uri) = when { + uri.scheme in arrayOf("content", "file") -> { + runCatching { + UtilsLibrary.context.contentResolver.openInputStream(uri)?.use { stream -> + ExifInterface(stream).getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL, + ) + } + }.getOrNull() ?: ExifInterface.ORIENTATION_NORMAL + } + + else -> { + uri.path?.let { + runCatching { + ExifInterface(it).getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL, + ) + }.getOrNull() + } ?: ExifInterface.ORIENTATION_NORMAL + } +} + +private fun floatDimensions(uri: Uri): Pair? { + UtilsLibrary.context.contentResolver.openInputStream(uri)?.use { stream -> + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeStream(stream, null, options) + + var w = options.outWidth + var h = options.outHeight + if (w <= 0 || h <= 0) return null + + when (orientation(uri)) { + ExifInterface.ORIENTATION_ROTATE_90, + ExifInterface.ORIENTATION_ROTATE_270, + ExifInterface.ORIENTATION_TRANSPOSE, + ExifInterface.ORIENTATION_TRANSVERSE -> { + val swap = w + w = h + h = swap + } + } + + return w.toFloat() to h.toFloat() + } + + return null +} + +actual fun getFilenameFromUri(uri: String): String { + UtilsLibrary + .context + .contentResolver + .query( + uri.toUri(), + null, + null, + null, + null + )?.use { cursor -> + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex >= 0 && cursor.moveToFirst()) { + val name = cursor.getString(nameIndex) + if (!name.isNullOrBlank()) return name + } + } + + return uri.substringAfterLast('/').takeIf { it.isNotBlank() } ?: "file" +} + +actual suspend fun getImageAspectRatio(uri: String) = + floatDimensions(uri.toUri())?.let { it.first / it.second } + +actual suspend fun getImageDimensions(uri: String) = + floatDimensions(uri.toUri())?.let { it.first.toInt() to it.second.toInt() } + +@Composable +actual fun rememberImagePicker(onResult: (List) -> Unit): () -> Unit { + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickMultipleVisualMedia() + ) { uris: List -> + onResult(uris.map { it.toString() }) + } + return { + launcher.launch( + PickVisualMediaRequest.Builder() + .setMediaType(ActivityResultContracts.PickVisualMedia.ImageOnly) + .build() + ) + } +} + +@Composable +actual fun rememberFilePicker(onResult: (List) -> Unit): () -> Unit { + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenMultipleDocuments() + ) { uris -> + onResult(uris.map { it.toString() }) + } + + return { + launcher.launch(arrayOf("*/*")) + } +} diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/BackHandler.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/components/BackHandler.android.kt similarity index 56% rename from app/shared/src/androidMain/kotlin/ru/fromchat/ui/BackHandler.android.kt rename to app/shared/src/androidMain/kotlin/ru/fromchat/ui/components/BackHandler.android.kt index ad44c43..0904984 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/BackHandler.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/components/BackHandler.android.kt @@ -1,16 +1,44 @@ -package ru.fromchat.ui +package ru.fromchat.ui.components +import android.annotation.SuppressLint import android.view.HapticFeedbackConstants -import androidx.activity.compose.BackHandler as AndroidBackHandler +import androidx.activity.BackEventCompat +import androidx.activity.compose.PredictiveBackHandler import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalView +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.Flow +import ru.fromchat.utils.haptic.HapticFeedbackEvent +import androidx.activity.compose.BackHandler as AndroidBackHandler + +@Composable +actual fun PredictiveBackHandler( + enabled: Boolean, + onProgress: (Float) -> Unit, + onCommit: () -> Unit, + onCancel: () -> Unit, +) { + PredictiveBackHandler(enabled = enabled) { progressFlow: Flow -> + try { + progressFlow.collect { backEvent -> + onProgress(backEvent.progress.coerceIn(0f, 1f)) + } + onCommit() + } catch (e: CancellationException) { + onCancel() + throw e + } + } +} + @Composable actual fun BackHandler(enabled: Boolean, onBack: () -> Unit) { AndroidBackHandler(enabled = enabled, onBack = onBack) } +@SuppressLint("InlinedApi") @Composable actual fun rememberHapticFeedbackInternal(): (Int) -> Unit { val view = LocalView.current diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.kt deleted file mode 100644 index 6668c61..0000000 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.kt +++ /dev/null @@ -1,8 +0,0 @@ -package ru.fromchat.ui.debug - -import androidx.compose.runtime.Composable - -@Composable -actual fun DebugApiScreen() { - DebugApiScreenContent() -} diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/platform/OpenAppNotificationSettings.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.android.kt similarity index 70% rename from app/shared/src/androidMain/kotlin/ru/fromchat/platform/OpenAppNotificationSettings.android.kt rename to app/shared/src/androidMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.android.kt index 6b518a4..900ca49 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/platform/OpenAppNotificationSettings.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.android.kt @@ -1,43 +1,43 @@ -package ru.fromchat.platform +package ru.fromchat.ui.main.settings -import android.content.Intent import android.Manifest +import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.os.Build +import android.provider.Settings import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat -import android.provider.Settings -import com.pr0gramm3r101.utils.UtilsLibrary.context +import com.pr0gramm3r101.utils.UtilsLibrary actual fun openAppNotificationSettings(): Boolean = try { val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { - putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + putExtra(Settings.EXTRA_APP_PACKAGE, UtilsLibrary.context.packageName) } } else { Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { - data = Uri.fromParts("package", context.packageName, null) + data = Uri.fromParts("package", UtilsLibrary.context.packageName, null) } } intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - context.startActivity(intent) + UtilsLibrary.context.startActivity(intent) true } catch (_: Exception) { false } actual fun areAppNotificationsEnabled(): Boolean { - if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) return false + if (!NotificationManagerCompat.from(UtilsLibrary.context).areNotificationsEnabled()) return false return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { ContextCompat.checkSelfPermission( - context, + UtilsLibrary.context, Manifest.permission.POST_NOTIFICATIONS ) == PackageManager.PERMISSION_GRANTED } else { true } -} +} \ No newline at end of file diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/main/settings/SettingsPredictiveBack.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/main/settings/SettingsPredictiveBack.android.kt deleted file mode 100644 index 4ad3d4e..0000000 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/main/settings/SettingsPredictiveBack.android.kt +++ /dev/null @@ -1,28 +0,0 @@ -package ru.fromchat.ui.main.settings - -import androidx.activity.BackEventCompat -import androidx.activity.compose.PredictiveBackHandler -import androidx.compose.runtime.Composable -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.flow.Flow - -@Composable -actual fun SettingsSecurityPredictiveBackHandler( - enabled: Boolean, - onProgress: (Float) -> Unit, - onCommit: () -> Unit, - onCancel: () -> Unit, -) { - PredictiveBackHandler(enabled = enabled) { progressFlow: Flow -> - try { - progressFlow.collect { backEvent -> - onProgress(backEvent.progress.coerceIn(0f, 1f)) - } - onCommit() - } catch (e: CancellationException) { - onCancel() - throw e - } - } -} - diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ProfileLoadErrorMessage.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.android.kt similarity index 86% rename from app/shared/src/androidMain/kotlin/ru/fromchat/ProfileLoadErrorMessage.android.kt rename to app/shared/src/androidMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.android.kt index f095ec2..b123dec 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/ProfileLoadErrorMessage.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.android.kt @@ -1,9 +1,8 @@ -package ru.fromchat +package ru.fromchat.ui.profile import android.widget.Toast import com.pr0gramm3r101.utils.UtilsLibrary actual fun showProfileLoadErrorMessage(message: String) { Toast.makeText(UtilsLibrary.context, message, Toast.LENGTH_SHORT).show() -} - +} \ No newline at end of file diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/net/NetworkConnectivity.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/utils/NetworkConnectivity.android.kt similarity index 85% rename from app/shared/src/androidMain/kotlin/ru/fromchat/net/NetworkConnectivity.android.kt rename to app/shared/src/androidMain/kotlin/ru/fromchat/utils/NetworkConnectivity.android.kt index a90f835..e83183b 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/net/NetworkConnectivity.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/utils/NetworkConnectivity.android.kt @@ -1,15 +1,17 @@ -package ru.fromchat.net +package ru.fromchat.utils +import android.Manifest import android.content.Context import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest +import androidx.annotation.RequiresPermission import com.pr0gramm3r101.utils.UtilsLibrary import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import ru.fromchat.api.WebSocketManager +import ru.fromchat.api.local.WebSocketManager actual object NetworkConnectivity { private val _isOnline = MutableStateFlow(true) @@ -17,6 +19,7 @@ actual object NetworkConnectivity { private var callback: ConnectivityManager.NetworkCallback? = null + @RequiresPermission(Manifest.permission.ACCESS_NETWORK_STATE) @Suppress("DEPRECATION") private fun computeOnline(cm: ConnectivityManager): Boolean { val n = cm.activeNetwork ?: return false @@ -24,6 +27,7 @@ actual object NetworkConnectivity { return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } + @RequiresPermission(Manifest.permission.ACCESS_NETWORK_STATE) actual fun ensureStarted() { if (callback != null) return val context = UtilsLibrary.context diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml index b178904..4d8ef04 100644 --- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -213,12 +213,11 @@ Уведомления Чтобы разрешить или отключить уведомления FromChat, откройте настройки уведомлений телефона. - Открыть настройки уведомлений - Включить push-уведомления - Отключить push-уведомления + Настройки уведомлений + Здесь вы можете включить или выключить типы уведомлений, изменить звук и т.д. Откройте системные настройки и разрешите уведомления. - Push-уведомления включены - Push-уведомления отключены + Push-уведомления + Они помогут вам узнать, если появилось новое сообщение пока приложение закрыто. Устройства Нет активных сессий @@ -245,6 +244,8 @@ Выйти на всех других устройствах Выйти везде, кроме этого? Вы останетесь в аккаунте только на этом устройстве. + Активные сеансы + Подтвердить Отмена @@ -281,6 +282,7 @@ %1$s, %2$s и ещё %3$d печатают… Ещё +%1$d + Неизвестно Ваш аккаунт был заблокирован diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml index fc5dede..a0408d1 100644 --- a/app/shared/src/commonMain/composeResources/values/strings.xml +++ b/app/shared/src/commonMain/composeResources/values/strings.xml @@ -181,9 +181,7 @@ Server IP or hostname 192.168.1.10 Port - Calls port - Secure connection Uses HTTPS. Turn off only for plain HTTP. HTTPS @@ -243,13 +241,11 @@ Log out or delete account Notifications - To allow or block alerts from FromChat, use your phone’s notification settings. - Open notification settings - Enable push notifications - Disable push notifications + Notification settings + Here you can enable or disable certain types of notifications, change the sound and more. Open system notification settings to allow alerts. - Push notifications enabled - Push notifications disabled + Push notifications + These will let you know if you got a new message when the app is closed. Devices No active sessions @@ -276,6 +272,7 @@ Sign out all other devices Sign out everywhere else? You stay signed in on this device only. + Active sessions Confirm Cancel @@ -308,6 +305,7 @@ Wrong username or password Couldn’t connect. Check your internet. Something went wrong. Please try again. + Unknown %1$s is typing… diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/AppForeground.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/AppForeground.kt index cf20a71..e2081df 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/AppForeground.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/AppForeground.kt @@ -4,10 +4,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -/** - * Mirrors process visibility: [setForeground] from [androidx.lifecycle.Lifecycle.Event.ON_START] / - * [androidx.lifecycle.Lifecycle.Event.ON_STOP] (or initial [syncFromLifecycle]). - */ object AppForeground { private val _isInForeground = MutableStateFlow(true) val isInForeground: StateFlow = _isInForeground.asStateFlow() diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/Logger.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/Logger.kt similarity index 91% rename from app/shared/src/commonMain/kotlin/ru/fromchat/core/Logger.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/Logger.kt index b0b8220..451b2f6 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/core/Logger.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/Logger.kt @@ -1,9 +1,8 @@ -package ru.fromchat.core +package ru.fromchat expect object Logger { fun d(tag: String, message: String, throwable: Throwable? = null) fun i(tag: String, message: String, throwable: Throwable? = null) fun w(tag: String, message: String, throwable: Throwable? = null) fun e(tag: String, message: String, throwable: Throwable? = null) -} - +} \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ProfileLoadErrorMessage.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ProfileLoadErrorMessage.kt deleted file mode 100644 index 43e68c6..0000000 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ProfileLoadErrorMessage.kt +++ /dev/null @@ -1,4 +0,0 @@ -package ru.fromchat - -expect fun showProfileLoadErrorMessage(message: String) - diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/SearchHintResource.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/SearchHintResource.kt deleted file mode 100644 index 9a94ff7..0000000 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/SearchHintResource.kt +++ /dev/null @@ -1,44 +0,0 @@ -package ru.fromchat - -import kotlin.collections.HashMap -import org.jetbrains.compose.resources.StringResource -import org.jetbrains.compose.resources.InternalResourceApi - -@OptIn(InternalResourceApi::class) -private fun collectCommonMainStringResources(): HashMap { - val map = HashMap() - _collectCommonMainString0Resources(map) - _collectCommonMainString1Resources(map) - return map -} - -internal fun resolveSearchHintResource(): StringResource { - val map = collectCommonMainStringResources() - return map["search_hint"] - ?: map["app_name"] - ?: error("Search hint resource is missing") -} - -internal fun resolveSearchNotFoundTitleResource(): StringResource { - val map = collectCommonMainStringResources() - return map["search_not_found"] - ?: map["search_not_found_message"] - ?: map["app_name"] - ?: error("Search not-found title resource is missing") -} - -internal fun resolveSearchNotFoundMessageResource(): StringResource { - val map = collectCommonMainStringResources() - return map["search_not_found_message"] - ?: map["search_not_found"] - ?: map["app_name"] - ?: error("Search not-found message resource is missing") -} - -internal fun resolveSearchTitleResource(): StringResource { - val map = collectCommonMainStringResources() - return map["search_title"] - ?: map["search_hint"] - ?: map["app_name"] - ?: error("Search title resource is missing") -} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt index d5c8d4c..9577767 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt @@ -1,5 +1,8 @@ package ru.fromchat.api +import com.pr0gramm3r101.utils.crypto.Base64 +import com.pr0gramm3r101.utils.currentDeviceInfo +import com.pr0gramm3r101.utils.files.PlatformFileSystem import com.pr0gramm3r101.utils.settings.secureSettings import com.pr0gramm3r101.utils.settings.settings import io.ktor.client.HttpClient @@ -12,46 +15,90 @@ import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logging import io.ktor.client.plugins.logging.SIMPLE -import io.ktor.client.request.header import io.ktor.client.plugins.websocket.WebSockets import io.ktor.client.plugins.websocket.pingInterval import io.ktor.client.request.bearerAuth import io.ktor.client.request.delete import io.ktor.client.request.get -import io.ktor.http.HttpHeaders -import io.ktor.http.HttpStatusCode -import com.pr0gramm3r101.utils.files.PlatformFileSystem -import io.ktor.client.request.patch +import io.ktor.client.request.header import io.ktor.client.request.parameter +import io.ktor.client.request.patch import io.ktor.client.request.post -import io.ktor.client.request.setBody import io.ktor.client.request.put +import io.ktor.client.request.setBody import io.ktor.client.statement.HttpResponse import io.ktor.http.ContentType import io.ktor.http.contentType import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.MainScope -import kotlinx.coroutines.launch import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import kotlinx.serialization.json.encodeToJsonElement -import ru.fromchat.core.Settings -import ru.fromchat.core.config.Config -import ru.fromchat.core.instance.InstanceIdGuard -import ru.fromchat.api.db.InstanceRegistryStore -import ru.fromchat.ui.chat.PublicChatPanelCache -import ru.fromchat.ui.dm.DmPanelCache -import ru.fromchat.fcm.uploadPendingFcmTokenIfAvailable +import ru.fromchat.api.ApiClient.logout +import ru.fromchat.api.ApiClient.persistSessionToStorage +import ru.fromchat.api.crypto.IdentityKeyManager +import ru.fromchat.api.crypto.transport.TransportCiphertext +import ru.fromchat.api.crypto.transport.TransportCrypto +import ru.fromchat.api.instance.InstanceIdGuard +import ru.fromchat.api.local.WebSocketManager +import ru.fromchat.api.local.cache.readOutboundFileBytes +import ru.fromchat.api.local.db.store.InstanceRegistryStore +import ru.fromchat.api.local.db.store.ProfileCache +import ru.fromchat.api.local.download.streamEncryptedFileToDisk +import ru.fromchat.api.schema.calls.CallSignalingLiveKitControl +import ru.fromchat.api.schema.calls.CallSignalingLiveKitPayload +import ru.fromchat.api.schema.calls.LiveKitTokenRequest +import ru.fromchat.api.schema.calls.LiveKitTokenResponse +import ru.fromchat.api.schema.core.SimpleStatusResponse +import ru.fromchat.api.schema.messages.MessagesResponse +import ru.fromchat.api.schema.messages.dm.DmConversation +import ru.fromchat.api.schema.messages.dm.DmConversationsResponse +import ru.fromchat.api.schema.messages.dm.DmHistoryResponse +import ru.fromchat.api.schema.messages.dm.EditDmRequest +import ru.fromchat.api.schema.messages.dm.SendDmFile +import ru.fromchat.api.schema.messages.dm.SendDmRequest +import ru.fromchat.api.schema.messages.dm.upload.DmUploadChunkRequest +import ru.fromchat.api.schema.messages.dm.upload.DmUploadChunkResponse +import ru.fromchat.api.schema.messages.dm.upload.DmUploadCompleteResponse +import ru.fromchat.api.schema.messages.dm.upload.DmUploadInitRequest +import ru.fromchat.api.schema.messages.dm.upload.DmUploadInitResponse +import ru.fromchat.api.schema.messages.dm.upload.DmUploadStatusResponse +import ru.fromchat.api.schema.messages.publicchat.SendMessageRequest +import ru.fromchat.api.schema.server.RegisteredUserCountResponse +import ru.fromchat.api.schema.server.ServerInstanceIdResponse +import ru.fromchat.api.schema.server.TransportKeyResponse +import ru.fromchat.api.schema.user.ChangePasswordApiRequest +import ru.fromchat.api.schema.user.FcmTokenRequest +import ru.fromchat.api.schema.user.User +import ru.fromchat.api.schema.user.auth.CheckAuthResponse +import ru.fromchat.api.schema.user.auth.LoginRequest +import ru.fromchat.api.schema.user.auth.LoginResponse +import ru.fromchat.api.schema.user.auth.RegisterRequest +import ru.fromchat.api.schema.user.devices.DeviceSessionInfo +import ru.fromchat.api.schema.user.devices.DevicesListResponse +import ru.fromchat.api.schema.user.keys.BackupBlobRequest +import ru.fromchat.api.schema.user.keys.BackupBlobResponse +import ru.fromchat.api.schema.user.keys.PublicKeyResponse +import ru.fromchat.api.schema.user.profile.SimilarityResult +import ru.fromchat.api.schema.user.profile.UserProfile +import ru.fromchat.api.schema.user.profile.VerifyResponse +import ru.fromchat.api.schema.websocket.WebSocketCredentials +import ru.fromchat.api.schema.websocket.WebSocketMessage +import ru.fromchat.api.schema.websocket.requests.WebSocketDeleteDmRequest +import ru.fromchat.api.schema.websocket.requests.WebSocketDeleteMessageRequest +import ru.fromchat.api.schema.websocket.requests.WebSocketEditMessageRequest +import ru.fromchat.api.schema.websocket.requests.WebSocketSendMessageRequest +import ru.fromchat.api.schema.websocket.types.DmTypingData +import ru.fromchat.api.schema.websocket.types.SubscribeStatusData +import ru.fromchat.config.ServerConfig +import ru.fromchat.config.Settings +import ru.fromchat.ui.chat.panels.dm.DmPanelCache +import ru.fromchat.ui.chat.utils.PublicChatPanelCache import kotlin.concurrent.Volatile import kotlin.time.Duration.Companion.milliseconds -import com.pr0gramm3r101.utils.crypto.Base64 -import ru.fromchat.crypto.IdentityKeyManager -import ru.fromchat.crypto.transport.TransportCiphertext -import ru.fromchat.crypto.transport.TransportCrypto -import ru.fromchat.platform.currentDeviceInfo -import ru.fromchat.fcm.unregisterFcmTokenFromServer /** * Creates a platform-specific HTTP client that supports WebSockets @@ -157,7 +204,7 @@ object ApiClient { validateResponse { response -> val instanceHeader = response.headers[InstanceIdGuard.INSTANCE_ID_HEADER] runCatching { - Config.serverConfig.value?.let { cfg -> + ServerConfig.serverConfig.value?.let { cfg -> InstanceIdGuard.onResponseHeader(instanceHeader, cfg) } } @@ -244,7 +291,7 @@ object ApiClient { suspend fun refreshServerInstanceFingerprint() { runCatching { - val id = fetchServerInstanceId(Config.apiBaseUrl) + val id = fetchServerInstanceId(ServerConfig.apiBaseUrl) if (id.isNotEmpty()) { Settings.lastKnownServerInstanceId = id InstanceRegistryStore.registerInstanceEncountered(id) @@ -315,14 +362,14 @@ object ApiClient { } } } catch (e: Exception) { - ru.fromchat.core.Logger.e("ApiClient", "Error loading persisted data", e) + ru.fromchat.Logger.e("ApiClient", "Error loading persisted data", e) } } suspend fun loginRequest(request: LoginRequest): LoginResponse = http - .post("${Config.apiBaseUrl}/login") { + .post("${ServerConfig.apiBaseUrl}/login") { contentType(ContentType.Application.Json) setBody(request) } @@ -330,7 +377,7 @@ object ApiClient { suspend fun registerRequest(request: RegisterRequest): LoginResponse = http - .post("${Config.apiBaseUrl}/register") { + .post("${ServerConfig.apiBaseUrl}/register") { contentType(ContentType.Application.Json) setBody(request) } @@ -365,7 +412,7 @@ object ApiClient { suspend fun getMessages(limit: Int = 50, beforeId: Int? = null) = http - .get("${Config.apiBaseUrl}/get_messages") { + .get("${ServerConfig.apiBaseUrl}/get_messages") { contentType(ContentType.Application.Json) parameter("limit", limit) beforeId?.let { parameter("before_id", it) } @@ -374,28 +421,28 @@ object ApiClient { suspend fun getOwnProfile(): UserProfile = http - .get("${Config.apiBaseUrl}/user/profile") { + .get("${ServerConfig.apiBaseUrl}/user/profile") { contentType(ContentType.Application.Json) } .body() suspend fun getProfileById(userId: Int): UserProfile = http - .get("${Config.apiBaseUrl}/user/id/$userId") { + .get("${ServerConfig.apiBaseUrl}/user/id/$userId") { contentType(ContentType.Application.Json) } .body() suspend fun getProfileByUsername(username: String): UserProfile = http - .get("${Config.apiBaseUrl}/user/$username") { + .get("${ServerConfig.apiBaseUrl}/user/$username") { contentType(ContentType.Application.Json) } .body() suspend fun getRegisteredUserCount(): Int = http - .get("${Config.apiBaseUrl}/user/stats/registered-count") { + .get("${ServerConfig.apiBaseUrl}/user/stats/registered-count") { contentType(ContentType.Application.Json) } .body() @@ -404,7 +451,7 @@ object ApiClient { suspend fun checkSimilarity(userId: Int): SimilarityResult? = runCatching { http - .get("${Config.apiBaseUrl}/user/check-similarity/$userId") { + .get("${ServerConfig.apiBaseUrl}/user/check-similarity/$userId") { contentType(ContentType.Application.Json) } .body() @@ -413,7 +460,7 @@ object ApiClient { suspend fun verifyUser(userId: Int): VerifyResponse? = runCatching { http - .post("${Config.apiBaseUrl}/user/$userId/verify") { + .post("${ServerConfig.apiBaseUrl}/user/$userId/verify") { contentType(ContentType.Application.Json) } .body() @@ -421,7 +468,7 @@ object ApiClient { suspend fun getDmConversations(): List = http - .get("${Config.apiBaseUrl}/dm/conversations") { + .get("${ServerConfig.apiBaseUrl}/dm/conversations") { contentType(ContentType.Application.Json) } .body() @@ -429,7 +476,7 @@ object ApiClient { suspend fun getDmFetch(since: Int? = null): DmHistoryResponse { return http - .get("${Config.apiBaseUrl}/dm/fetch") { + .get("${ServerConfig.apiBaseUrl}/dm/fetch") { contentType(ContentType.Application.Json) since?.let { parameter("since", it) } } @@ -442,7 +489,7 @@ object ApiClient { beforeId: Int? = null ): DmHistoryResponse = http - .get("${Config.apiBaseUrl}/dm/history/$otherUserId") { + .get("${ServerConfig.apiBaseUrl}/dm/history/$otherUserId") { contentType(ContentType.Application.Json) parameter("limit", limit) beforeId?.let { parameter("before_id", it) } @@ -451,21 +498,21 @@ object ApiClient { suspend fun getOwnPublicKey(): PublicKeyResponse = http - .get("${Config.apiBaseUrl}/crypto/public-key") { + .get("${ServerConfig.apiBaseUrl}/crypto/public-key") { contentType(ContentType.Application.Json) } .body() suspend fun getUserPublicKey(userId: Int): PublicKeyResponse = http - .get("${Config.apiBaseUrl}/crypto/public-key/of/$userId") { + .get("${ServerConfig.apiBaseUrl}/crypto/public-key/of/$userId") { contentType(ContentType.Application.Json) } .body() suspend fun getTransportPublicKey(): TransportKeyResponse = http - .get("${Config.apiBaseUrl}/dm/key/transport/public") { + .get("${ServerConfig.apiBaseUrl}/dm/key/transport/public") { contentType(ContentType.Application.Json) } .body() @@ -512,7 +559,7 @@ object ApiClient { uploadedFileIds = uploadedFileIds ) - http.post("${Config.apiBaseUrl}/dm/send") { + http.post("${ServerConfig.apiBaseUrl}/dm/send") { contentType(ContentType.Application.Json) setBody(body) } @@ -524,7 +571,7 @@ object ApiClient { recipientId: Int, chunkSize: Int? = null ): DmUploadInitResponse = - http.post("${Config.apiBaseUrl}/dm/upload/init") { + http.post("${ServerConfig.apiBaseUrl}/dm/upload/init") { contentType(ContentType.Application.Json) setBody( DmUploadInitRequest( @@ -537,7 +584,7 @@ object ApiClient { }.body() suspend fun getDmUploadStatus(uploadId: String): DmUploadStatusResponse = - http.get("${Config.apiBaseUrl}/dm/upload/$uploadId") { + http.get("${ServerConfig.apiBaseUrl}/dm/upload/$uploadId") { contentType(ContentType.Application.Json) }.body() @@ -546,7 +593,7 @@ object ApiClient { offset: Long, dataB64: String ): DmUploadChunkResponse = - http.patch("${Config.apiBaseUrl}/dm/upload/$uploadId") { + http.patch("${ServerConfig.apiBaseUrl}/dm/upload/$uploadId") { contentType(ContentType.Application.Json) setBody( DmUploadChunkRequest( @@ -557,13 +604,13 @@ object ApiClient { }.body() suspend fun completeDmUpload(uploadId: String): DmUploadCompleteResponse = - http.post("${Config.apiBaseUrl}/dm/upload/$uploadId/complete") { + http.post("${ServerConfig.apiBaseUrl}/dm/upload/$uploadId/complete") { contentType(ContentType.Application.Json) setBody(mapOf("upload_id" to uploadId)) }.body() suspend fun abortDmUpload(uploadId: String) { - http.delete("${Config.apiBaseUrl}/dm/upload/$uploadId") { + http.delete("${ServerConfig.apiBaseUrl}/dm/upload/$uploadId") { contentType(ContentType.Application.Json) } } @@ -575,10 +622,10 @@ object ApiClient { fun encryptedFileUrl(path: String): String = when { path.startsWith("http") -> path path.startsWith("/api") -> { - val serverBase = Config.apiBaseUrl.removeSuffix("/api") + val serverBase = ServerConfig.apiBaseUrl.removeSuffix("/api") "$serverBase$path" } - else -> "${Config.apiBaseUrl}$path" + else -> "${ServerConfig.apiBaseUrl}$path" } /** Encrypted ciphertext stored on disk after a resumable download. */ @@ -659,7 +706,7 @@ object ApiClient { return EncryptedFileOnDisk(outputPath, received) } - private fun currentDownloadUserAgent(): String? { + private fun currentDownloadUserAgent(): String { val currentDevice = currentDeviceInfo() return buildLoginUserAgent( osName = currentDevice.osName?.takeIf { it.isNotBlank() }, @@ -886,7 +933,7 @@ object ApiClient { private suspend fun readPartialDownloadMetaFileFromDisk(path: String): PartialDownloadMeta? { if (!PlatformFileSystem.exists(path)) return null val text = runCatching { - ru.fromchat.core.cache.readOutboundFileBytes("file://$path").decodeToString() + readOutboundFileBytes("file://$path").decodeToString() }.getOrNull() ?: return null return parsePartialDownloadMeta(text) } @@ -946,7 +993,7 @@ object ApiClient { val path = pausedDownloadIndexPath() ?: return emptySet() if (!PlatformFileSystem.exists(path)) return emptySet() return runCatching { - ru.fromchat.core.cache.readOutboundFileBytes("file://$path") + readOutboundFileBytes("file://$path") .decodeToString() .lineSequence() .map { it.trim() } @@ -1027,7 +1074,7 @@ object ApiClient { recipientPublicKeyB64 = recipientPublicKey ) - http.put("${Config.apiBaseUrl}/dm/edit/$messageId") { + http.put("${ServerConfig.apiBaseUrl}/dm/edit/$messageId") { contentType(ContentType.Application.Json) setBody(body) } @@ -1035,20 +1082,20 @@ object ApiClient { suspend fun fetchBackupBlob(): String? { return try { - val response = http.get("${Config.apiBaseUrl}/crypto/backup") { + val response = http.get("${ServerConfig.apiBaseUrl}/crypto/backup") { contentType(ContentType.Application.Json) } val backupResponse = response.body() backupResponse.blob } catch (e: Exception) { - ru.fromchat.core.Logger.d("ApiClient", "No backup found or error fetching: ${e.message}") + ru.fromchat.Logger.d("ApiClient", "No backup found or error fetching: ${e.message}") null } } suspend fun uploadBackupBlob(blobJson: String) { val payload = BackupBlobRequest(blob = blobJson) - http.post("${Config.apiBaseUrl}/crypto/backup") { + http.post("${ServerConfig.apiBaseUrl}/crypto/backup") { contentType(ContentType.Application.Json) setBody(payload) } @@ -1058,7 +1105,7 @@ object ApiClient { suspend fun validateToken(): Boolean { try { http - .get("${Config.apiBaseUrl}/api/user/profile") + .get("${ServerConfig.apiBaseUrl}/api/user/profile") return true // Token is valid if no exception thrown } catch (e: ClientRequestException) { if (e.response.status.value == 401 || e.response.status.value == 403) { @@ -1073,27 +1120,27 @@ object ApiClient { suspend fun listDevices(): List = http - .get("${Config.apiBaseUrl}/devices") { + .get("${ServerConfig.apiBaseUrl}/devices") { contentType(ContentType.Application.Json) } .body() .devices suspend fun revokeDeviceSession(sessionId: String) { - http.delete("${Config.apiBaseUrl}/devices/$sessionId") { + http.delete("${ServerConfig.apiBaseUrl}/devices/$sessionId") { contentType(ContentType.Application.Json) } } suspend fun revokeAllOtherDeviceSessions() { - http.post("${Config.apiBaseUrl}/devices/logout-all") { + http.post("${ServerConfig.apiBaseUrl}/devices/logout-all") { contentType(ContentType.Application.Json) } } suspend fun registerFcmToken(token: String): SimpleStatusResponse { return http - .post("${Config.apiBaseUrl}/push/register") { + .post("${ServerConfig.apiBaseUrl}/push/register") { contentType(ContentType.Application.Json) setBody(FcmTokenRequest(token = token)) } @@ -1103,13 +1150,13 @@ object ApiClient { suspend fun unregisterFcmToken(token: String? = null): SimpleStatusResponse { return if (token.isNullOrBlank()) { http - .post("${Config.apiBaseUrl}/push/unregister") { + .post("${ServerConfig.apiBaseUrl}/push/unregister") { contentType(ContentType.Application.Json) } .body() } else { http - .post("${Config.apiBaseUrl}/push/unregister") { + .post("${ServerConfig.apiBaseUrl}/push/unregister") { contentType(ContentType.Application.Json) setBody(FcmTokenRequest(token = token)) } @@ -1122,7 +1169,7 @@ object ApiClient { newPasswordDerived: String, logoutAllExceptCurrent: Boolean ) { - http.post("${Config.apiBaseUrl}/change-password") { + http.post("${ServerConfig.apiBaseUrl}/change-password") { contentType(ContentType.Application.Json) setBody( ChangePasswordApiRequest( @@ -1140,14 +1187,14 @@ object ApiClient { suspend fun deleteAccount(): SimpleStatusResponse { try { return http - .post("${Config.apiBaseUrl}/account/delete") { + .post("${ServerConfig.apiBaseUrl}/account/delete") { contentType(ContentType.Application.Json) } .body() } catch (e: ClientRequestException) { if (e.response.status.value != 404) throw e return http - .post("${Config.apiBaseUrl}/delete") { + .post("${ServerConfig.apiBaseUrl}/delete") { contentType(ContentType.Application.Json) } .body() @@ -1177,7 +1224,7 @@ object ApiClient { } suspend fun logout() { - runCatching { http.get("${Config.apiBaseUrl}/logout") } + runCatching { http.get("${ServerConfig.apiBaseUrl}/logout") } runCatching { unregisterFcmTokenFromServer() } clearLocalSession() } @@ -1186,7 +1233,7 @@ object ApiClient { suspend fun sendMessageViaHttp(content: String, replyToId: Int? = null) { if (_suspensionState.value.isSuspended) return - http.post("${Config.apiBaseUrl}/send_message") { + http.post("${ServerConfig.apiBaseUrl}/send_message") { contentType(ContentType.Application.Json) setBody(SendMessageRequest(content = content, reply_to_id = replyToId)) } @@ -1366,7 +1413,7 @@ object ApiClient { throw IllegalStateException("Suspended") } return http - .post("${Config.apiBaseUrl}/livekit/token") { + .post("${ServerConfig.apiBaseUrl}/livekit/token") { contentType(ContentType.Application.Json) setBody(LiveKitTokenRequest(peerUserId = peerUserId, roomName = roomName)) } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/DeferredStartupNetwork.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/DeferredStartupNetwork.kt similarity index 94% rename from app/shared/src/commonMain/kotlin/ru/fromchat/core/DeferredStartupNetwork.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/DeferredStartupNetwork.kt index 14ac7d3..ad28756 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/core/DeferredStartupNetwork.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/DeferredStartupNetwork.kt @@ -1,10 +1,9 @@ -package ru.fromchat.core +package ru.fromchat.api import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch -import ru.fromchat.api.ApiClient /** * Network work that must not block cold start or the first frame. diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/fcm/FcmRegistration.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/FcmRegistration.kt similarity index 94% rename from app/shared/src/commonMain/kotlin/ru/fromchat/fcm/FcmRegistration.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/FcmRegistration.kt index a3c6751..0a3b91e 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/fcm/FcmRegistration.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/FcmRegistration.kt @@ -1,4 +1,4 @@ -package ru.fromchat.fcm +package ru.fromchat.api expect suspend fun uploadPendingFcmTokenIfAvailable() diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt deleted file mode 100644 index cc5bcc5..0000000 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt +++ /dev/null @@ -1,476 +0,0 @@ -package ru.fromchat.api - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonElement - -@Serializable -data class LoginRequest( - val username: String, - val password: String -) - -@Serializable -data class RegisterRequest( - val username: String, - val display_name: String, - val password: String, - val confirm_password: String -) - -@Serializable -data class ErrorResponse( - val detail: String -) - -@Serializable -data class User( - val id: Int, - val created_at: String, - val last_seen: String, - val online: Boolean, - val username: String, - @SerialName("display_name") val displayName: String? = null, - val admin: Boolean? = null, - val bio: String? = null, - val profile_picture: String? = null, - val suspended: Boolean? = null, - @SerialName("suspension_reason") val suspensionReason: String? = null -) - -@Serializable -data class UserProfile( - val id: Int, - val username: String, - @SerialName("display_name") val displayName: String? = null, - @SerialName("profile_picture") val profilePicture: String? = null, - val bio: String? = null, - val online: Boolean = false, - @SerialName("last_seen") val lastSeen: String? = null, - @SerialName("created_at") val createdAt: String? = null, - val verified: Boolean? = null, - val suspended: Boolean? = null, - @SerialName("suspension_reason") val suspensionReason: String? = null, - val deleted: Boolean? = null, - /** - * Client-only: true when this row was built from public-chat message metadata, not a full - * `/user/...` response. The backend does not send this key. - */ - @SerialName("client_preview_only") val isClientPreviewOnly: Boolean = false -) - -@Serializable -data class ProfileDialogData( - @SerialName("user_id") val userId: Int? = null, - val username: String? = null, - @SerialName("display_name") val displayName: String? = null, - @SerialName("profile_picture") val profilePicture: String? = null, - val bio: String? = null, - @SerialName("member_since") val memberSince: String? = null, - val online: Boolean? = null, - @SerialName("is_own_profile") val isOwnProfile: Boolean = false, - val verified: Boolean? = null, - val suspended: Boolean? = null, - @SerialName("suspension_reason") val suspensionReason: String? = null, - val deleted: Boolean? = null -) - -@Serializable -data class LoginResponse( - val user: User, - val token: String -) - -@Serializable -data class DevicesListResponse( - val devices: List = emptyList() -) - -@Serializable -data class DeviceSessionInfo( - @SerialName("session_id") val sessionId: String, - @SerialName("device_name") val deviceName: String? = null, - @SerialName("device_type") val deviceType: String? = null, - @SerialName("os_name") val osName: String? = null, - @SerialName("os_version") val osVersion: String? = null, - @SerialName("browser_name") val browserName: String? = null, - @SerialName("browser_version") val browserVersion: String? = null, - val brand: String? = null, - val model: String? = null, - @SerialName("created_at") val createdAt: String? = null, - @SerialName("last_seen") val lastSeen: String? = null, - val revoked: Boolean? = null, - val current: Boolean = false -) - -@Serializable -data class ChangePasswordApiRequest( - val currentPasswordDerived: String, - val newPasswordDerived: String, - val logoutAllExceptCurrent: Boolean = false -) - -@Serializable -data class SimpleStatusResponse( - val status: String? = null, - val message: String? = null -) - -@Serializable -data class ServerInstanceIdResponse( - @SerialName("instance_id") val instanceId: String, -) - -@Serializable -data class CheckAuthResponse( - val authenticated: Boolean = false, - val username: String? = null, - val admin: Boolean? = null, -) - -@Serializable -data class MessagesResponse( - val status: String, - val messages: List -) - -@Serializable -data class Message( - val id: Int, - val user_id: Int, - val content: String, - val timestamp: String, - val is_read: Boolean, - val is_edited: Boolean, - val username: String, - val profile_picture: String? = null, - val verified: Boolean? = null, - val reply_to: Message? = null, - val client_message_id: String? = null, - val reactions: List? = null, - val files: List? = null, - /** For optimistic UI: local URI when sending, null when confirmed. */ - val pendingFileUri: String? = null, - /** For optimistic UI: filename when sending file (non-image), null when confirmed. */ - val pendingFilename: String? = null, - /** For optimistic UI: aspect ratio (width/height) when sending image, null when confirmed. */ - val pendingFileAspectRatio: Float? = null, - /** For optimistic UI: jobId to track upload progress. */ - val uploadJobId: String? = null, - /** For optimistic UI: 0-100 upload progress, null when complete. */ - val uploadProgress: Int? = null, - /** Set when outbound upload failed; use [UPLOAD_ERROR_FILE_TOO_LARGE] for localized copy. */ - @kotlinx.serialization.Transient val uploadError: String? = null, - /** For DM file decryption; not serialized over network. */ - @kotlinx.serialization.Transient val dmEnvelope: DmEnvelope? = null, - /** Blurhashes for image files (by index); from decrypted message JSON. */ - @kotlinx.serialization.Transient val fileThumbnails: List? = null, - /** Aspect ratios (width/height) for image files (by index); from decrypted message JSON. */ - @kotlinx.serialization.Transient val fileAspectRatios: List? = null, - /** File sizes in bytes (by index); from decrypted message JSON. */ - @kotlinx.serialization.Transient val fileSizes: List? = null, - /** Image dimensions (width, height) for image files (by index); from decrypted message JSON. */ - @kotlinx.serialization.Transient val fileDimensions: List>? = null, - /** True when DM plaintext could not be decrypted and [content] shows the corrupted placeholder. */ - @kotlinx.serialization.Transient val isContentCorrupted: Boolean = false -) - -@Serializable -data class SendMessageRequest( - val content: String, - val reply_to_id: Int? = null -) - -@Serializable -data class EditMessageRequest( - val content: String -) - -@Serializable -data class SendMessageResponse( - val status: String, - val message: Message -) - -// WebSocket types mirror frontend src/core/types.d.ts -@Serializable -data class WebSocketCredentials( - val scheme: String, - val credentials: String -) - -@Serializable -data class WebSocketError( - val code: Int, - val detail: String -) - -@Serializable -data class WebSocketMessage( - val type: String, - val credentials: WebSocketCredentials? = null, - val data: JsonElement? = null, - val error: WebSocketError? = null -) - -// WebSocket message data types -@Serializable -data class NewMessageData( - val message: Message -) - -@Serializable -data class MessageEditedData( - val message: Message -) - -@Serializable -data class MessageDeletedData( - val message_id: Int -) - -@Serializable -data class TypingData( - val userId: Int, - val username: String -) - -@Serializable -data class DmFile( - val id: Int, - val name: String, - val path: String, - @SerialName("dm_envelope_id") val dmEnvelopeId: Int? = null, - @SerialName("wrapped_mek_b64") val wrappedMekB64: String? = null, - @SerialName("nonce_b64") val nonceB64: String? = null -) - -@Serializable -data class DmEnvelope( - val id: Int, - val senderId: Int, - val recipientId: Int, - @SerialName("sender_username") val senderUsername: String? = null, - @SerialName("iv_b64") val ivB64: String, - @SerialName("ciphertext_b64") val ciphertextB64: String, - @SerialName("wrapped_mek_b64") val wrappedMekB64: String? = null, - val timestamp: String, - @SerialName("client_message_id") val clientMessageId: String? = null, - @SerialName("reply_to_id") val replyToId: Int? = null, - val files: List? = null -) - -@Serializable -data class DmConversation( - val user: User, - val lastMessage: DmEnvelope, - val unreadCount: Int -) - -@Serializable -data class RegisteredUserCountResponse( - val count: Int -) - -@Serializable -data class DmConversationsResponse( - val conversations: List = emptyList() -) - -@Serializable -data class DmHistoryResponse( - val messages: List = emptyList(), - @SerialName("has_more") val hasMore: Boolean? = null -) - -@Serializable -data class PublicKeyResponse( - val publicKey: String? = null -) - -@Serializable -data class SendDmFile( - @SerialName("encrypted_file_data_b64") val encryptedFileDataB64: String, - val filename: String, - @SerialName("file_size") val fileSize: Long -) - -@Serializable -data class SendDmRequest( - @SerialName("recipient_id") val recipientId: Int, - @SerialName("client_public_key_b64") val clientPublicKeyB64: String, - @SerialName("transport_nonce_b64") val transportNonceB64: String, - @SerialName("transport_ciphertext_b64") val transportCiphertextB64: String, - @SerialName("sender_public_key_b64") val senderPublicKeyB64: String, - @SerialName("recipient_public_key_b64") val recipientPublicKeyB64: String, - @SerialName("client_message_id") val clientMessageId: String? = null, - @SerialName("reply_to_id") val replyToId: Int? = null, - @SerialName("transport_files") val transportFiles: List = emptyList(), - @SerialName("uploaded_file_ids") val uploadedFileIds: List = emptyList() -) - -@Serializable -data class EditDmRequest( - @SerialName("client_public_key_b64") val clientPublicKeyB64: String, - @SerialName("transport_nonce_b64") val transportNonceB64: String, - @SerialName("transport_ciphertext_b64") val transportCiphertextB64: String, - @SerialName("sender_public_key_b64") val senderPublicKeyB64: String, - @SerialName("recipient_public_key_b64") val recipientPublicKeyB64: String -) - -@Serializable -data class TransportKeyResponse( - @SerialName("key_id") val keyId: String, - @SerialName("public_key_b64") val publicKeyB64: String, - @SerialName("created_at") val createdAt: Double? = null -) - -@Serializable -data class DmUploadInitRequest( - val filename: String, - @SerialName("total_size") val totalSize: Long, - @SerialName("recipient_id") val recipientId: Int, - @SerialName("chunk_size") val chunkSize: Int? = null -) - -@Serializable -data class DmUploadInitResponse( - @SerialName("upload_id") val uploadId: String, - @SerialName("chunk_size") val chunkSize: Int, - val offset: Long = 0L -) - -@Serializable -data class DmUploadStatusResponse( - @SerialName("upload_id") val uploadId: String, - val filename: String, - @SerialName("total_size") val totalSize: Long, - val offset: Long, - val complete: Boolean -) - -@Serializable -data class DmUploadChunkRequest( - val offset: Long, - @SerialName("data_b64") val dataB64: String -) - -@Serializable -data class DmUploadChunkResponse( - @SerialName("offset_received") val offsetReceived: Long -) - -@Serializable -data class DmUploadCompleteResponse( - @SerialName("file_id") val fileId: String, - @SerialName("upload_id") val uploadId: String -) - -// Batched updates message -@Serializable -data class UpdateItem( - val type: String, - val data: JsonElement? = null -) - -@Serializable -data class UpdatesMessage( - val type: String, - val seq: Int, - val updates: List -) - -// WebSocket request types -@Serializable -data class WebSocketSendMessageRequest( - val content: String, - val reply_to_id: Int? = null, - val client_message_id: String? = null -) - -@Serializable -data class WebSocketEditMessageRequest( - val message_id: Int, - val content: String -) - -@Serializable -data class WebSocketDeleteMessageRequest( - val message_id: Int -) - -@Serializable -data class WebSocketDeleteDmRequest( - val id: Int, - @SerialName("recipientId") val recipientId: Int, -) - -@Serializable -data class DmDeletedData( - val id: Int, - @SerialName("senderId") val senderId: Int, - @SerialName("recipientId") val recipientId: Int? = null, -) - -@Serializable -data class DmTypingData( - @SerialName("recipientId") val recipientId: Int -) - -@Serializable -data class SubscribeStatusData( - @SerialName("userId") val userId: Int -) - -@Serializable -data class BackupBlobResponse( - val blob: String? -) - -@Serializable -data class BackupBlobRequest( - val blob: String -) - -@Serializable -data class SimilarityResult( - @SerialName("isSimilar") val isSimilar: Boolean, - @SerialName("similarTo") val similarTo: String? = null -) - -@Serializable -data class VerifyResponse( - val verified: Boolean -) - -@Serializable -data class FcmTokenRequest( - val token: String -) - -@Serializable -data class LiveKitTokenRequest( - @SerialName("peer_user_id") val peerUserId: Int, - @SerialName("room_name") val roomName: String? = null, -) - -@Serializable -data class LiveKitTokenResponse( - @SerialName("server_url") val serverUrl: String, - val token: String, - @SerialName("room_name") val roomName: String, -) - -@Serializable -data class CallSignalingLiveKitPayload( - val toUserId: Int, - val roomName: String, - val serverUrl: String, -) - -@Serializable -data class CallSignalingLiveKitControl( - val toUserId: Int, - val kind: String, - val roomName: String? = null, -) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/UpdateSyncManager.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/UpdateSyncManager.kt index 2f1ca14..3144765 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/UpdateSyncManager.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/UpdateSyncManager.kt @@ -1,13 +1,20 @@ package ru.fromchat.api import com.pr0gramm3r101.utils.settings.settings +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import ru.fromchat.core.Logger +import ru.fromchat.Logger +import ru.fromchat.api.local.WebSocketManager +import ru.fromchat.api.local.db.store.ConnectionStateStore +import ru.fromchat.api.schema.websocket.WebSocketCredentials +import ru.fromchat.api.schema.websocket.WebSocketMessage +import ru.fromchat.api.schema.websocket.requests.GetUpdatesRequest +import ru.fromchat.api.schema.websocket.requests.GetUpdatesResponse import kotlin.concurrent.Volatile /** @@ -35,6 +42,7 @@ object UpdateSyncManager { ConnectionStateStore.updateSeqAndMissed(lastSeq = stored, missedCount = null) } + @OptIn(DelicateCoroutinesApi::class) fun onUpdatesBatch(seq: Int) { if (seq <= 0) return val currentUserId = ApiClient.user?.id ?: return diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Utils.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Utils.kt index d460e35..b3122ec 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Utils.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Utils.kt @@ -1,8 +1,9 @@ package ru.fromchat.api -import ru.fromchat.core.Logger import io.ktor.client.call.body import io.ktor.client.plugins.ClientRequestException +import ru.fromchat.api.schema.core.ErrorResponse +import ru.fromchat.Logger suspend inline fun apiRequest( unexpectedError: String, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/WebSocketData.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/WebSocketData.kt deleted file mode 100644 index 3b6859a..0000000 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/WebSocketData.kt +++ /dev/null @@ -1,51 +0,0 @@ -package ru.fromchat.api - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class WebSocketUpdatesData( - val seq: Int, - val updates: List -) - -@Serializable -data class GetUpdatesRequest( - @SerialName("lastSeq") val lastSeq: Int -) - -@Serializable -data class GetUpdatesResponse( - val status: String, - @SerialName("lastSeq") val lastSeq: Int, - @SerialName("missedCount") val missedCount: Int -) - -@Serializable -data class ReactionUpdateData( - val message_id: Int, - val emoji: String, - val action: String, - val user_id: Int, - val username: String, - val reactions: List -) - -@Serializable -data class ReactionData( - val emoji: String, - val count: Int, - val users: List -) - -@Serializable -data class ReactionUser( - val id: Int, - val username: String -) - -@Serializable -data class TypingUpdateData( - val userId: Int, - val username: String -) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/calls/CallStore.kt similarity index 95% rename from app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallStore.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/calls/CallStore.kt index 6fa5e66..0c8635e 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallStore.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/calls/CallStore.kt @@ -1,4 +1,4 @@ -package ru.fromchat.calls +package ru.fromchat.api.calls import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -14,11 +14,11 @@ import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import ru.fromchat.api.ApiClient -import ru.fromchat.api.ProfileCache -import ru.fromchat.api.WebSocketMessage -import ru.fromchat.api.visibleDisplayName -import ru.fromchat.core.Logger -import ru.fromchat.core.config.Config +import ru.fromchat.api.local.db.store.ProfileCache +import ru.fromchat.api.local.db.store.visibleDisplayName +import ru.fromchat.api.schema.websocket.WebSocketMessage +import ru.fromchat.Logger +import ru.fromchat.config.ServerConfig private const val TAG = "CallStore" @@ -105,7 +105,7 @@ object CallStore { val roomName = obj["roomName"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } if (serverUrl == null || roomName == null || fromUserId == null) return if (fromUserId == currentId) return - if (!Config.callsEnabled) { + if (!ServerConfig.callsEnabled) { Logger.d(TAG, "call_signaling invite ignored (calls disabled in server config)") return } @@ -122,7 +122,7 @@ object CallStore { fun startOutgoingCall(peerUserId: Int) { if (peerUserId <= 0 || peerUserId == ApiClient.user?.id) return - if (!Config.callsEnabled) { + if (!ServerConfig.callsEnabled) { Logger.d(TAG, "startOutgoingCall ignored (calls disabled: set calls port in server settings)") return } @@ -134,7 +134,7 @@ object CallStore { val tok = ApiClient.fetchLiveKitToken(peerUserId, null) // LiveKit WS endpoint is exposed on the same host as the server config, // using the configured calls port. - val signalUrl = Config.liveKitWsUrl() + val signalUrl = ServerConfig.liveKitWsUrl() ApiClient.sendLiveKitInvite(peerUserId, tok.roomName, signalUrl) val label = peerLabel(peerUserId) LiveKitConnectSession( @@ -162,7 +162,7 @@ object CallStore { fun acceptIncoming() { val inc = _ui.value as? CallUiState.Incoming ?: return - if (!Config.callsEnabled) { + if (!ServerConfig.callsEnabled) { Logger.d(TAG, "acceptIncoming ignored (calls disabled)") return } @@ -175,7 +175,7 @@ object CallStore { val display = if (inc.fromUsername.isNotBlank()) inc.fromUsername else label LiveKitConnectSession( - serverUrl = Config.liveKitWsUrl(), + serverUrl = ServerConfig.liveKitWsUrl(), token = tok.token, peerUserId = inc.fromUserId, peerDisplayName = display, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/calls/LiveKitConnectSession.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/calls/LiveKitConnectSession.kt similarity index 85% rename from app/shared/src/commonMain/kotlin/ru/fromchat/calls/LiveKitConnectSession.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/calls/LiveKitConnectSession.kt index 38484a7..307822a 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/calls/LiveKitConnectSession.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/calls/LiveKitConnectSession.kt @@ -1,4 +1,4 @@ -package ru.fromchat.calls +package ru.fromchat.api.calls data class LiveKitConnectSession( val serverUrl: String, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/DmCiphertextCorruptedException.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/crypto/DmCiphertextCorruptedException.kt similarity index 90% rename from app/shared/src/commonMain/kotlin/ru/fromchat/crypto/DmCiphertextCorruptedException.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/crypto/DmCiphertextCorruptedException.kt index e7e4dde..80e93e5 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/DmCiphertextCorruptedException.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/crypto/DmCiphertextCorruptedException.kt @@ -1,4 +1,4 @@ -package ru.fromchat.crypto +package ru.fromchat.api.crypto /** * AES-GCM authentication failed or ciphertext is unrecoverable (wrong key, truncated, or tampered). diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/DmCrypto.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/crypto/DmCrypto.kt similarity index 88% rename from app/shared/src/commonMain/kotlin/ru/fromchat/crypto/DmCrypto.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/crypto/DmCrypto.kt index 7669053..b11296b 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/DmCrypto.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/crypto/DmCrypto.kt @@ -1,8 +1,12 @@ -package ru.fromchat.crypto +package ru.fromchat.api.crypto import com.pr0gramm3r101.utils.crypto.PasswordHash -import ru.fromchat.api.DmEnvelope -import ru.fromchat.crypto.dm.DmCrypto +import com.pr0gramm3r101.utils.files.PlatformFileSystem +import ru.fromchat.Logger +import ru.fromchat.api.ApiClient +import ru.fromchat.api.schema.messages.dm.DmEnvelope +import ru.fromchat.api.schema.messages.dm.DmFile +import ru.fromchat.api.crypto.dm.DmCrypto /** Shown in the UI when [DmCiphertextCorruptedException] is caught while decrypting a DM. */ /** Must match [ru.fromchat.Res.string.message_corrupted] (Compose resources). */ @@ -55,7 +59,7 @@ suspend fun decryptEnvelope(envelope: DmEnvelope, currentUserId: Int?): String { * Decrypt a DM file attachment to [outputPath]: streams download + decrypt without holding the full blob in RAM. */ suspend fun decryptFileToPath( - file: ru.fromchat.api.DmFile, + file: DmFile, envelope: DmEnvelope, currentUserId: Int?, outputPath: String, @@ -71,8 +75,8 @@ suspend fun decryptFileToPath( ?: throw IllegalArgumentException("No nonce available for file decryption: ${file.path}") val mek = unwrapMek(wrappedMekB64, envelope, currentUserId) - ru.fromchat.core.Logger.d("DmCrypto", "fetchEncryptedFile path=${file.path}") - val encryptedOnDisk = ru.fromchat.api.ApiClient.fetchEncryptedFileResumable( + Logger.d("DmCrypto", "fetchEncryptedFile path=${file.path}") + val encryptedOnDisk = ApiClient.fetchEncryptedFileResumable( path = file.path, resumeKey = downloadResumeKey, onProgress = onDownloadProgress, @@ -93,7 +97,7 @@ suspend fun decryptFileToPath( decryptedSize } finally { if (downloadResumeKey == null) { - com.pr0gramm3r101.utils.files.PlatformFileSystem.delete(encryptedOnDisk.path) + PlatformFileSystem.delete(encryptedOnDisk.path) } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/IdentityKeyManager.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/crypto/IdentityKeyManager.kt similarity index 91% rename from app/shared/src/commonMain/kotlin/ru/fromchat/crypto/IdentityKeyManager.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/crypto/IdentityKeyManager.kt index b4bb3b9..d8b5e3e 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/IdentityKeyManager.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/crypto/IdentityKeyManager.kt @@ -1,4 +1,4 @@ -package ru.fromchat.crypto +package ru.fromchat.api.crypto import com.pr0gramm3r101.utils.crypto.Base64 import com.pr0gramm3r101.utils.settings.secureSettings @@ -10,14 +10,15 @@ import io.ktor.http.ContentType import io.ktor.http.contentType import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable import ru.fromchat.api.ApiClient -import ru.fromchat.api.PublicKeyResponse -import ru.fromchat.core.Logger -import ru.fromchat.core.config.Config -import ru.fromchat.crypto.backup.BackupCrypto -import ru.fromchat.crypto.backup.PrivateKeyBundle -import ru.fromchat.crypto.backup.decodeBlob -import ru.fromchat.crypto.backup.encodeBlob +import ru.fromchat.api.schema.user.keys.PublicKeyResponse +import ru.fromchat.Logger +import ru.fromchat.config.ServerConfig +import ru.fromchat.api.crypto.backup.BackupCrypto +import ru.fromchat.api.crypto.backup.PrivateKeyBundle +import ru.fromchat.api.crypto.backup.decodeBlob +import ru.fromchat.api.crypto.backup.encodeBlob import kotlin.concurrent.Volatile /** @@ -168,7 +169,7 @@ object IdentityKeyManager { private suspend fun fetchBackupBlob(token: String): String? { return try { - val response = ApiClient.http.get("${Config.apiBaseUrl}/crypto/backup") + val response = ApiClient.http.get("${ServerConfig.apiBaseUrl}/crypto/backup") val backupResponse = response.body() backupResponse.blob } catch (e: Exception) { @@ -179,7 +180,7 @@ object IdentityKeyManager { private suspend fun uploadBackupBlob(blobJson: String, token: String) { val payload = BackupBlobRequest(blob = blobJson) - ApiClient.http.post("${Config.apiBaseUrl}/crypto/backup") { + ApiClient.http.post("${ServerConfig.apiBaseUrl}/crypto/backup") { contentType(ContentType.Application.Json) setBody(payload) } @@ -197,24 +198,24 @@ object IdentityKeyManager { private suspend fun uploadPublicKey(publicKey: ByteArray, token: String) { val payload = UploadPublicKeyRequest(publicKey = Base64.encode(publicKey)) - ApiClient.http.post("${Config.apiBaseUrl}/crypto/public-key") { + ApiClient.http.post("${ServerConfig.apiBaseUrl}/crypto/public-key") { contentType(ContentType.Application.Json) setBody(payload) } } } -@kotlinx.serialization.Serializable +@Serializable private data class BackupBlobResponse( val blob: String? ) -@kotlinx.serialization.Serializable +@Serializable private data class BackupBlobRequest( val blob: String ) -@kotlinx.serialization.Serializable +@Serializable private data class UploadPublicKeyRequest( val publicKey: String ) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/backup/BackupCrypto.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/crypto/backup/BackupCrypto.kt similarity index 99% rename from app/shared/src/commonMain/kotlin/ru/fromchat/crypto/backup/BackupCrypto.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/crypto/backup/BackupCrypto.kt index 5835214..3260c88 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/backup/BackupCrypto.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/crypto/backup/BackupCrypto.kt @@ -1,4 +1,4 @@ -package ru.fromchat.crypto.backup +package ru.fromchat.api.crypto.backup import com.pr0gramm3r101.utils.crypto.Base64 import kotlinx.serialization.json.Json diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/dm/AesGcmStreamingFileDecrypt.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/crypto/dm/AesGcmStreamingFileDecrypt.kt similarity index 80% rename from app/shared/src/commonMain/kotlin/ru/fromchat/crypto/dm/AesGcmStreamingFileDecrypt.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/crypto/dm/AesGcmStreamingFileDecrypt.kt index 66b05a1..5ba052a 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/dm/AesGcmStreamingFileDecrypt.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/crypto/dm/AesGcmStreamingFileDecrypt.kt @@ -1,8 +1,8 @@ -package ru.fromchat.crypto.dm +package ru.fromchat.api.crypto.dm import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import ru.fromchat.crypto.DmCiphertextCorruptedException +import ru.fromchat.api.crypto.DmCiphertextCorruptedException /** * Streams a MEK-encrypted attachment (ciphertext || tag on disk; IV passed separately) to [outputPath]. @@ -21,11 +21,7 @@ internal suspend fun aesGcmDecryptMekFileToPath( outputPath = outputPath, ) } catch (e: Throwable) { - throw if (e is DmCiphertextCorruptedException) { - e - } else { - DmCiphertextCorruptedException(cause = e) - } + throw e as? DmCiphertextCorruptedException ?: DmCiphertextCorruptedException(cause = e) } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/crypto/dm/DmCrypto.kt similarity index 97% rename from app/shared/src/commonMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/crypto/dm/DmCrypto.kt index 2de3785..957cb74 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/crypto/dm/DmCrypto.kt @@ -1,4 +1,4 @@ -package ru.fromchat.crypto.dm +package ru.fromchat.api.crypto.dm import com.pr0gramm3r101.utils.crypto.Base64 diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/dm/DmFileOps.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/crypto/dm/DmFileOps.kt similarity index 92% rename from app/shared/src/commonMain/kotlin/ru/fromchat/crypto/dm/DmFileOps.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/crypto/dm/DmFileOps.kt index 8b4c8f4..3e132c8 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/dm/DmFileOps.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/crypto/dm/DmFileOps.kt @@ -1,4 +1,4 @@ -package ru.fromchat.crypto.dm +package ru.fromchat.api.crypto.dm /** * DM attachment file decrypt (streaming; shared across Android and iOS). diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/crypto/transport/TransportCrypto.kt similarity index 97% rename from app/shared/src/commonMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/crypto/transport/TransportCrypto.kt index 3f77857..0e556cd 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/crypto/transport/TransportCrypto.kt @@ -1,4 +1,4 @@ -package ru.fromchat.crypto.transport +package ru.fromchat.api.crypto.transport /** * Result of client-side transport encryption for DMs. diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/transport/TransportFileEncryptor.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/crypto/transport/TransportFileEncryptor.kt similarity index 96% rename from app/shared/src/commonMain/kotlin/ru/fromchat/crypto/transport/TransportFileEncryptor.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/crypto/transport/TransportFileEncryptor.kt index d1f8790..04242fb 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/transport/TransportFileEncryptor.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/crypto/transport/TransportFileEncryptor.kt @@ -1,6 +1,6 @@ -package ru.fromchat.crypto.transport +package ru.fromchat.api.crypto.transport -import ru.fromchat.core.cache.openOutboundFileInputStream +import ru.fromchat.api.local.cache.openOutboundFileInputStream /** * Encrypts a plaintext attachment file to a transport blob on disk without loading the full file into RAM. diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/transport/TransportFileKey.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/crypto/transport/TransportFileKey.kt similarity index 96% rename from app/shared/src/commonMain/kotlin/ru/fromchat/crypto/transport/TransportFileKey.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/crypto/transport/TransportFileKey.kt index b13edac..b1e4bcd 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/transport/TransportFileKey.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/crypto/transport/TransportFileKey.kt @@ -1,4 +1,4 @@ -package ru.fromchat.crypto.transport +package ru.fromchat.api.crypto.transport private const val TRANSPORT_FILE_KEY_CONTEXT = "fromchat_transport_file_v1" diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/transport/TransportStreamFormat.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/crypto/transport/TransportStreamFormat.kt similarity index 94% rename from app/shared/src/commonMain/kotlin/ru/fromchat/crypto/transport/TransportStreamFormat.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/crypto/transport/TransportStreamFormat.kt index c67f64b..6fcfa0d 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/transport/TransportStreamFormat.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/crypto/transport/TransportStreamFormat.kt @@ -1,4 +1,4 @@ -package ru.fromchat.crypto.transport +package ru.fromchat.api.crypto.transport /** * Streaming transport file blob (AES-256-GCM, chunked). diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/instance/InstanceIdGuard.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/InstanceIdGuard.kt similarity index 86% rename from app/shared/src/commonMain/kotlin/ru/fromchat/core/instance/InstanceIdGuard.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/InstanceIdGuard.kt index f4d60bf..0581f39 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/core/instance/InstanceIdGuard.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/InstanceIdGuard.kt @@ -1,13 +1,14 @@ -package ru.fromchat.core.instance +package ru.fromchat.api.instance import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import ru.fromchat.api.local.cache.CacheContext +import ru.fromchat.api.local.db.store.InstanceRegistryStore +import ru.fromchat.config.ServerConfigData +import ru.fromchat.api.instance.InstanceIdGuard.INSTANCE_ID_HEADER import kotlin.concurrent.Volatile -import ru.fromchat.api.db.InstanceRegistryStore -import ru.fromchat.core.ServerConfigData -import ru.fromchat.core.cache.CacheContext /** * Handles [INSTANCE_ID_HEADER] on API responses (main client + probe during server setup). diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/instance/InstanceIdResolve.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/InstanceIdResolve.kt similarity index 96% rename from app/shared/src/commonMain/kotlin/ru/fromchat/core/instance/InstanceIdResolve.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/InstanceIdResolve.kt index 54d5ed8..6d31d5c 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/core/instance/InstanceIdResolve.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/InstanceIdResolve.kt @@ -1,15 +1,14 @@ -package ru.fromchat.core.instance +package ru.fromchat.api.instance -import io.ktor.client.plugins.ClientRequestException -import io.ktor.client.plugins.HttpRequestTimeoutException import io.ktor.client.network.sockets.ConnectTimeoutException import io.ktor.client.network.sockets.SocketTimeoutException +import io.ktor.client.plugins.ClientRequestException +import io.ktor.client.plugins.HttpRequestTimeoutException import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.withTimeout import ru.fromchat.api.ApiClient -import ru.fromchat.api.db.InstanceRegistryStore -import ru.fromchat.core.ServerConfigData -import ru.fromchat.core.configKey +import ru.fromchat.api.local.db.store.InstanceRegistryStore +import ru.fromchat.config.ServerConfigData private val UUID_REGEX = Regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/ServerConfigKeys.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/ServerConfigKeys.kt similarity index 66% rename from app/shared/src/commonMain/kotlin/ru/fromchat/core/ServerConfigKeys.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/ServerConfigKeys.kt index cb5d227..977a586 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/core/ServerConfigKeys.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/ServerConfigKeys.kt @@ -1,4 +1,6 @@ -package ru.fromchat.core +package ru.fromchat.api.instance + +import ru.fromchat.config.ServerConfigData fun ServerConfigData.configKey(): String { val scheme = if (httpsEnabled) "1" else "0" diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/instance/ServerConfigProbe.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/ServerConfigProbe.kt similarity index 90% rename from app/shared/src/commonMain/kotlin/ru/fromchat/core/instance/ServerConfigProbe.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/ServerConfigProbe.kt index c6d8516..ea44cc7 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/core/instance/ServerConfigProbe.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/ServerConfigProbe.kt @@ -1,14 +1,13 @@ -package ru.fromchat.core.instance +package ru.fromchat.api.instance -import kotlin.time.TimeSource -import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.withTimeout import ru.fromchat.api.ApiClient -import ru.fromchat.api.WebSocketManager -import ru.fromchat.api.db.InstanceRegistryStore -import ru.fromchat.core.ServerConfigData -import ru.fromchat.core.cache.CacheContext -import ru.fromchat.core.config.Config +import ru.fromchat.api.local.WebSocketManager +import ru.fromchat.api.local.db.store.InstanceRegistryStore +import ru.fromchat.config.ServerConfigData +import ru.fromchat.api.local.cache.CacheContext +import ru.fromchat.config.ServerConfig +import kotlin.time.TimeSource sealed interface ServerProbeResult { data class Supported( @@ -64,7 +63,7 @@ suspend fun applyServerConfig( callsOk: Boolean, ) { val tentative = config.copy(callsEnabled = callsOk) - Config.updateServerConfig(tentative) + ServerConfig.updateServerConfig(tentative) val userId = ApiClient.user?.id InstanceRegistryStore.rebindServerInstance(tentative, instanceId) CacheContext.setActiveInstance(instanceId, userId) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/instance/SessionInstanceBootstrap.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/SessionInstanceBootstrap.kt similarity index 94% rename from app/shared/src/commonMain/kotlin/ru/fromchat/core/instance/SessionInstanceBootstrap.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/SessionInstanceBootstrap.kt index e2f629d..b2313d7 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/core/instance/SessionInstanceBootstrap.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/SessionInstanceBootstrap.kt @@ -1,4 +1,4 @@ -package ru.fromchat.core.instance +package ru.fromchat.api.instance import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -7,11 +7,11 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import ru.fromchat.api.ApiClient -import ru.fromchat.api.AttachmentDownloadNotifier -import ru.fromchat.api.db.InstanceRegistryStore -import ru.fromchat.core.Settings -import ru.fromchat.api.outbox.scheduleOutboxProcessing -import ru.fromchat.core.cache.CacheContext +import ru.fromchat.api.local.db.store.InstanceRegistryStore +import ru.fromchat.api.local.download.AttachmentDownloadNotifier +import ru.fromchat.api.local.send.scheduleOutboxProcessing +import ru.fromchat.config.Settings +import ru.fromchat.api.local.cache.CacheContext sealed interface SessionBootstrapResult { data object Ready : SessionBootstrapResult diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentMediaLog.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/AttachmentMediaLog.kt similarity index 96% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentMediaLog.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/AttachmentMediaLog.kt index c29371d..82bd364 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentMediaLog.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/AttachmentMediaLog.kt @@ -1,7 +1,7 @@ -package ru.fromchat.ui.chat +package ru.fromchat.api.local +import ru.fromchat.Logger import kotlin.time.Clock -import ru.fromchat.core.Logger /** * Unified filter tag for attachment media pipeline (upload, download, disk/bitmap cache, tile decode). @@ -36,4 +36,4 @@ object AttachmentMediaLog { } Logger.d(TAG, "[$subsystem] $message$suffix") } -} +} \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentMime.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/AttachmentMime.kt similarity index 97% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentMime.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/AttachmentMime.kt index 8642480..3b85eba 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentMime.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/AttachmentMime.kt @@ -1,4 +1,4 @@ -package ru.fromchat.ui.chat +package ru.fromchat.api.local fun mimeTypeForFilename(filename: String): String { val ext = filename.substringAfterLast('.').lowercase() diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/WebSocketManager.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/WebSocketManager.kt similarity index 92% rename from app/shared/src/commonMain/kotlin/ru/fromchat/api/WebSocketManager.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/WebSocketManager.kt index dceb1cc..de263fa 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/WebSocketManager.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/WebSocketManager.kt @@ -1,4 +1,4 @@ -package ru.fromchat.api +package ru.fromchat.api.local import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession import io.ktor.client.plugins.websocket.webSocket @@ -11,29 +11,36 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO +import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.isActive -import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import ru.fromchat.AppForeground -import ru.fromchat.core.Logger -import ru.fromchat.core.cache.CacheContext -import ru.fromchat.core.config.Config -import ru.fromchat.core.instance.InstanceIdGuard +import ru.fromchat.Logger +import ru.fromchat.api.ApiClient +import ru.fromchat.api.UpdateSyncManager +import ru.fromchat.api.instance.InstanceIdGuard +import ru.fromchat.api.local.cache.CacheContext +import ru.fromchat.api.local.db.store.ConnectionStateStore +import ru.fromchat.api.schema.websocket.WebSocketCredentials +import ru.fromchat.api.schema.websocket.WebSocketMessage +import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData +import ru.fromchat.config.ServerConfig import kotlin.concurrent.Volatile -import kotlin.coroutines.coroutineContext -import kotlin.coroutines.suspendCoroutine import kotlin.time.Clock +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.ExperimentalTime @OptIn(ExperimentalTime::class) @@ -41,7 +48,6 @@ object WebSocketManager { private const val TAG = "WebSocketManager" private const val MIN_RECONNECT_DELAY_MS = 1_000L private const val MAX_RECONNECT_DELAY_MS = 60_000L - /** Android [NetworkCallback.onAvailable] can fire repeatedly; avoid canceling the reconnect loop each time. */ private const val NETWORK_AVAILABLE_DEBOUNCE_MS = 2_000L private const val FOREGROUND_DELAY_CHUNK_MS = 200L @@ -91,10 +97,10 @@ object WebSocketManager { private suspend fun delayWhileForeground(totalMs: Long) { var remaining = totalMs while (remaining > 0) { - coroutineContext.ensureActive() + currentCoroutineContext().ensureActive() awaitForeground() val chunk = minOf(FOREGROUND_DELAY_CHUNK_MS, remaining) - delay(chunk) + delay(chunk.milliseconds) remaining -= chunk } } @@ -104,7 +110,7 @@ object WebSocketManager { if (session != null) return true val startTime = Clock.System.now().toEpochMilliseconds() while (session == null && (Clock.System.now().toEpochMilliseconds() - startTime) < timeoutMs) { - delay(100) + delay(100.milliseconds) } logD("waitForConnection finished: session=${session != null}") return session != null @@ -144,7 +150,7 @@ object WebSocketManager { } try { - val wsUrl = Config.webSocketUrl + val wsUrl = ServerConfig.webSocketUrl logD("Attempting to connect to: $wsUrl") connecting = true ConnectionStateStore.onConnecting() @@ -194,7 +200,8 @@ object WebSocketManager { val msg = when (messageType) { "updates" -> { runCatching { - val updatesData = json.decodeFromJsonElement(WebSocketUpdatesData.serializer(), jsonTree) + val updatesData = json.decodeFromJsonElement( + WebSocketUpdatesData.serializer(), jsonTree) UpdateSyncManager.onUpdatesBatch(updatesData.seq) }.onFailure { logW("Failed to decode updates envelope for seq tracking: ${it.message}", it) @@ -277,8 +284,8 @@ object WebSocketManager { } send(message) - withTimeout(timeoutMs) { - suspendCoroutine { continuation -> + withTimeout(timeoutMs.milliseconds) { + suspendCancellableCoroutine { continuation -> handler = { response -> if (response.type == message.type) { continuation.resumeWith(Result.success(response)) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/AttachmentUploadLimits.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/AttachmentUploadLimits.kt similarity index 87% rename from app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/AttachmentUploadLimits.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/AttachmentUploadLimits.kt index f6955e0..04cfe24 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/AttachmentUploadLimits.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/AttachmentUploadLimits.kt @@ -1,6 +1,6 @@ -package ru.fromchat.core.cache +package ru.fromchat.api.local.cache -/** Error key stored on [ru.fromchat.api.Message.uploadError] for localized UI. */ +/** Error key stored on [ru.fromchat.api.schema.Message.uploadError] for localized UI. */ const val UPLOAD_ERROR_FILE_TOO_LARGE = "file_too_large" /** Maximum plaintext attachment size (matches file_storage MAX_UPLOAD_SIZE). */ diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/CacheContext.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/CacheContext.kt similarity index 81% rename from app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/CacheContext.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/CacheContext.kt index b786674..77a7442 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/CacheContext.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/CacheContext.kt @@ -1,12 +1,13 @@ -package ru.fromchat.core.cache +package ru.fromchat.api.local.cache import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import ru.fromchat.api.ProfileCache -import ru.fromchat.api.db.MessageDatabaseProvider -import ru.fromchat.ui.chat.PublicChatPanelCache -import ru.fromchat.ui.dm.DmPanelCache +import ru.fromchat.api.local.db.store.ProfileCache +import ru.fromchat.api.local.db.store.MessageDatabaseProvider +import ru.fromchat.api.local.cache.CacheContext.activeInstanceId +import ru.fromchat.ui.chat.utils.PublicChatPanelCache +import ru.fromchat.ui.chat.panels.dm.DmPanelCache /** * Active server instance partition for reads/writes. diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/CacheValidator.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/CacheValidator.kt similarity index 82% rename from app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/CacheValidator.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/CacheValidator.kt index c21e00c..b9cfb75 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/CacheValidator.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/CacheValidator.kt @@ -1,11 +1,11 @@ -package ru.fromchat.core.cache +package ru.fromchat.api.local.cache -import ru.fromchat.api.Message -import ru.fromchat.api.db.GENERAL_PUBLIC_GROUP_ID -import ru.fromchat.api.db.conversationIdForDm -import ru.fromchat.api.db.conversationIdForGroup -import ru.fromchat.api.db.dmOtherUserIdFromConversationId -import ru.fromchat.api.db.groupIdFromConversationId +import ru.fromchat.api.local.messages.GENERAL_PUBLIC_GROUP_ID +import ru.fromchat.api.local.messages.conversationIdForDm +import ru.fromchat.api.local.messages.conversationIdForGroup +import ru.fromchat.api.local.messages.dmOtherUserIdFromConversationId +import ru.fromchat.api.local.messages.groupIdFromConversationId +import ru.fromchat.api.schema.messages.Message /** * In-partition validation so stale rows from a wrong server switch cannot render as trusted history. diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DecryptedFileCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/DecryptedFileCache.kt similarity index 95% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DecryptedFileCache.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/DecryptedFileCache.kt index db259d3..6178848 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DecryptedFileCache.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/DecryptedFileCache.kt @@ -1,4 +1,4 @@ -package ru.fromchat.ui.chat +package ru.fromchat.api.local.cache import com.pr0gramm3r101.utils.files.PlatformFileSystem import kotlinx.coroutines.CancellationException @@ -7,13 +7,17 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import ru.fromchat.api.ApiClient -import ru.fromchat.api.AttachmentDownloadNotifier -import ru.fromchat.api.AttachmentDownloadProgress -import ru.fromchat.api.DmEnvelope -import ru.fromchat.api.DmFile -import ru.fromchat.crypto.dm.decryptFailureMessage -import ru.fromchat.core.cache.copyOutboundFileToPath -import ru.fromchat.crypto.decryptFileToPath +import ru.fromchat.api.crypto.decryptFileToPath +import ru.fromchat.api.crypto.dm.decryptFailureMessage +import ru.fromchat.api.local.AttachmentMediaLog +import ru.fromchat.api.local.download.AttachmentDownloadNotifier +import ru.fromchat.api.local.download.AttachmentDownloadProgress +import ru.fromchat.api.local.download.AttachmentDownloadScheduler +import ru.fromchat.api.local.download.checkAttachmentDownloadActive +import ru.fromchat.api.local.download.ensureAttachmentDownloadActive +import ru.fromchat.api.schema.messages.dm.DmEnvelope +import ru.fromchat.api.schema.messages.dm.DmFile +import ru.fromchat.api.local.download.DownloadedFileRegistry /** * Disk cache for decrypted non-image DM attachments (bytes on disk, opened via platform URI). diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DecryptedImageCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/DecryptedImageCache.kt similarity index 95% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DecryptedImageCache.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/DecryptedImageCache.kt index c701a12..ae71879 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DecryptedImageCache.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/DecryptedImageCache.kt @@ -1,4 +1,4 @@ -package ru.fromchat.ui.chat +package ru.fromchat.api.local.cache import com.pr0gramm3r101.utils.files.PlatformFileSystem import kotlinx.coroutines.CancellationException @@ -7,11 +7,18 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import ru.fromchat.api.ApiClient -import ru.fromchat.api.DmEnvelope -import ru.fromchat.api.DmFile -import ru.fromchat.api.AttachmentDownloadNotifier -import ru.fromchat.api.AttachmentDownloadProgress -import ru.fromchat.crypto.decryptFileToPath +import ru.fromchat.api.local.download.AttachmentDownloadNotifier +import ru.fromchat.api.local.download.AttachmentDownloadProgress +import ru.fromchat.api.schema.messages.dm.DmEnvelope +import ru.fromchat.api.schema.messages.dm.DmFile +import ru.fromchat.api.crypto.decryptFileToPath +import ru.fromchat.api.local.AttachmentMediaLog +import ru.fromchat.api.local.download.AttachmentDownloadScheduler +import ru.fromchat.api.local.download.checkAttachmentDownloadActive +import ru.fromchat.api.local.download.ensureAttachmentDownloadActive +import ru.fromchat.ui.chat.utils.AttachmentDownloadVisibility +import ru.fromchat.api.local.cache.DecryptedImageCache.storageKey +import ru.fromchat.api.local.download.LocalDecodedImageCache /** * Disk + in-memory cache for decrypted DM images. @@ -112,7 +119,7 @@ object DecryptedImageCache { val cidKey = storageKey(messageId, fileIndex, cid) val sourceUri = readDisk(cidKey) ?: return val bytes = runCatching { - ru.fromchat.core.cache.readOutboundFileBytes(sourceUri) + readOutboundFileBytes(sourceUri) }.getOrNull() ?: return if (bytes.isEmpty()) return withContext(Dispatchers.Default) { @@ -286,7 +293,7 @@ object DecryptedImageCache { } val t0 = AttachmentMediaLog.nowMs() val bytes = runCatching { - ru.fromchat.core.cache.readOutboundFileBytes(localFileUri) + readOutboundFileBytes(localFileUri) }.getOrNull() if (bytes == null) { AttachmentMediaLog.diskCache("seed_read_failed", "key" to key, "src" to localFileUri) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/files/FileWriteSink.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/FileWriteSink.kt similarity index 88% rename from app/shared/src/commonMain/kotlin/ru/fromchat/core/files/FileWriteSink.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/FileWriteSink.kt index c6bf42e..e26a6d7 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/core/files/FileWriteSink.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/FileWriteSink.kt @@ -1,4 +1,4 @@ -package ru.fromchat.core.files +package ru.fromchat.api.local.cache /** * Buffered file writer for streaming HTTP downloads (single open, chunked writes). @@ -7,4 +7,4 @@ internal expect class FileWriteSink(path: String, append: Boolean) : AutoCloseab fun write(buffer: ByteArray, offset: Int, length: Int) fun flush() override fun close() -} +} \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/FromChatCacheDirs.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.kt similarity index 83% rename from app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/FromChatCacheDirs.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.kt index 8cb80be..da4712d 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/FromChatCacheDirs.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.kt @@ -1,4 +1,4 @@ -package ru.fromchat.core.cache +package ru.fromchat.api.local.cache /** Deletes the `cacheDir/fromchat/` tree (blobs, DB file on disk). Call after [ru.fromchat.api.db.MessageRepository.clearAllCache]. */ expect suspend fun wipeFromChatCacheDirectory() diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/FromChatCacheGeneration.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheGeneration.kt similarity index 85% rename from app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/FromChatCacheGeneration.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheGeneration.kt index b74588f..bfe5d03 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/FromChatCacheGeneration.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheGeneration.kt @@ -1,4 +1,4 @@ -package ru.fromchat.core.cache +package ru.fromchat.api.local.cache /** * Detects OS "clear cache" (generation sentinel missing) and records a new generation after open. diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/OutboundFileUnavailableException.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/OutboundFileUnavailableException.kt similarity index 92% rename from app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/OutboundFileUnavailableException.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/OutboundFileUnavailableException.kt index 02760be..da75c02 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/OutboundFileUnavailableException.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/OutboundFileUnavailableException.kt @@ -1,4 +1,4 @@ -package ru.fromchat.core.cache +package ru.fromchat.api.local.cache /** Original attachment URI is no longer readable (revoked permission, deleted file, etc.). */ class OutboundFileUnavailableException( diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PendingFileSaveRegistry.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/PendingFileSaveRegistry.kt similarity index 95% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PendingFileSaveRegistry.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/PendingFileSaveRegistry.kt index 3a44ce0..5ae4e5a 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PendingFileSaveRegistry.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/PendingFileSaveRegistry.kt @@ -1,4 +1,4 @@ -package ru.fromchat.ui.chat +package ru.fromchat.api.local.cache import com.pr0gramm3r101.utils.files.PlatformFileSystem import kotlinx.coroutines.Dispatchers @@ -6,10 +6,8 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import ru.fromchat.core.cache.CacheContext -import ru.fromchat.core.cache.readOutboundFileBytes +import ru.fromchat.api.local.download.cachedAttachmentFileSize @Serializable data class PendingFileSaveEntry( diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/ResumableUploadStorage.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/ResumableUploadStorage.kt similarity index 98% rename from app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/ResumableUploadStorage.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/ResumableUploadStorage.kt index 7e15e91..4755240 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/ResumableUploadStorage.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/ResumableUploadStorage.kt @@ -1,4 +1,4 @@ -package ru.fromchat.core.cache +package ru.fromchat.api.local.cache data class StagedOutboundFile( val uri: String, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/UploadArtifactMarkers.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/UploadArtifactMarkers.kt similarity index 96% rename from app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/UploadArtifactMarkers.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/UploadArtifactMarkers.kt index 3f4e990..b8b726a 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/UploadArtifactMarkers.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/UploadArtifactMarkers.kt @@ -1,4 +1,4 @@ -package ru.fromchat.core.cache +package ru.fromchat.api.local.cache import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/DmStoredMessageContent.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/DmStoredMessageContent.kt similarity index 97% rename from app/shared/src/commonMain/kotlin/ru/fromchat/api/db/DmStoredMessageContent.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/DmStoredMessageContent.kt index 088692b..e039190 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/DmStoredMessageContent.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/DmStoredMessageContent.kt @@ -1,19 +1,18 @@ -package ru.fromchat.api.db +package ru.fromchat.api.local.db +import com.pr0gramm3r101.utils.files.PlatformFileSystem import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -import com.pr0gramm3r101.utils.files.PlatformFileSystem -import ru.fromchat.api.DmEnvelope -import ru.fromchat.api.Message -import ru.fromchat.ui.chat.AttachmentMediaLog -import ru.fromchat.ui.chat.DecryptedImageCache +import ru.fromchat.api.schema.messages.Message +import ru.fromchat.api.schema.messages.dm.DmEnvelope +import ru.fromchat.api.local.AttachmentMediaLog +import ru.fromchat.api.local.cache.DecryptedImageCache private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/LocalCacheWipe.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/LocalCacheWipe.kt similarity index 63% rename from app/shared/src/commonMain/kotlin/ru/fromchat/api/db/LocalCacheWipe.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/LocalCacheWipe.kt index b4f6df5..dca4b80 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/LocalCacheWipe.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/LocalCacheWipe.kt @@ -1,12 +1,13 @@ -package ru.fromchat.api.db +package ru.fromchat.api.local.db import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import ru.fromchat.api.outbox.cancelOutboxProcessing -import ru.fromchat.core.cache.CacheContext -import ru.fromchat.core.cache.wipeFromChatCacheDirectory -import ru.fromchat.ui.chat.PublicChatPanelCache -import ru.fromchat.ui.dm.DmPanelCache +import ru.fromchat.api.local.db.store.MessageDatabaseProvider +import ru.fromchat.api.local.send.cancelOutboxProcessing +import ru.fromchat.api.local.cache.CacheContext +import ru.fromchat.api.local.cache.wipeFromChatCacheDirectory +import ru.fromchat.ui.chat.utils.PublicChatPanelCache +import ru.fromchat.ui.chat.panels.dm.DmPanelCache /** * Drops the on-disk FromChat cache tree and reopens SQLite on next access. diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageDatabaseExpectedSchema.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseExpectedSchema.kt similarity index 99% rename from app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageDatabaseExpectedSchema.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseExpectedSchema.kt index 403f523..9211298 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageDatabaseExpectedSchema.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseExpectedSchema.kt @@ -1,4 +1,4 @@ -package ru.fromchat.api.db +package ru.fromchat.api.local.db /** * Target SQLDelight schema (source of truth for diff). Not a version number — only shape. diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageDatabaseLock.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseLock.kt similarity index 67% rename from app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageDatabaseLock.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseLock.kt index f3873fc..10567f0 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageDatabaseLock.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseLock.kt @@ -1,3 +1,3 @@ -package ru.fromchat.api.db +package ru.fromchat.api.local.db internal expect fun withMessageDatabaseLock(block: () -> T): T diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageDatabaseSchemaGuard.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseSchemaGuard.kt similarity index 98% rename from app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageDatabaseSchemaGuard.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseSchemaGuard.kt index ef528d1..79d145c 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageDatabaseSchemaGuard.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseSchemaGuard.kt @@ -1,10 +1,10 @@ -package ru.fromchat.api.db +package ru.fromchat.api.local.db import app.cash.sqldelight.db.QueryResult import app.cash.sqldelight.db.SqlDriver import kotlinx.coroutines.runBlocking -import ru.fromchat.core.Settings -import ru.fromchat.core.instance.isValidInstanceUuid +import ru.fromchat.config.Settings +import ru.fromchat.api.instance.isValidInstanceUuid /** * Aligns on-disk SQLite with [MessageDatabaseExpectedSchema] by structural diff only @@ -91,9 +91,9 @@ private fun computeSchemaDiff(driver: SqlDriver): SchemaDiff { ) } - var createTables = mutableListOf() - var partitionRebuilds = mutableListOf() - var addColumns = mutableListOf() + val createTables = mutableListOf() + val partitionRebuilds = mutableListOf() + val addColumns = mutableListOf() var unrecoverable: String? = null fun fail(reason: String) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ConnectionStateStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ConnectionStateStore.kt similarity index 97% rename from app/shared/src/commonMain/kotlin/ru/fromchat/api/ConnectionStateStore.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ConnectionStateStore.kt index 34d2e40..de8598b 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ConnectionStateStore.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ConnectionStateStore.kt @@ -1,4 +1,4 @@ -package ru.fromchat.api +package ru.fromchat.api.local.db.store import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/InstanceRegistryStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/InstanceRegistryStore.kt similarity index 92% rename from app/shared/src/commonMain/kotlin/ru/fromchat/api/db/InstanceRegistryStore.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/InstanceRegistryStore.kt index d8236b7..d15da60 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/InstanceRegistryStore.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/InstanceRegistryStore.kt @@ -1,14 +1,14 @@ -package ru.fromchat.api.db +package ru.fromchat.api.local.db.store import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import ru.fromchat.api.local.send.cancelOutboxProcessing +import ru.fromchat.config.ServerConfigData +import ru.fromchat.api.local.cache.CacheContext +import ru.fromchat.api.instance.configKey +import ru.fromchat.ui.chat.utils.PublicChatPanelCache +import ru.fromchat.ui.chat.panels.dm.DmPanelCache import kotlin.time.Clock -import ru.fromchat.core.ServerConfigData -import ru.fromchat.core.cache.CacheContext -import ru.fromchat.core.configKey -import ru.fromchat.api.outbox.cancelOutboxProcessing -import ru.fromchat.ui.chat.PublicChatPanelCache -import ru.fromchat.ui.dm.DmPanelCache object InstanceRegistryStore { private val db get() = MessageDatabaseProvider.database diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageCacheStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt similarity index 93% rename from app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageCacheStore.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt index 7aa1c3d..769d841 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageCacheStore.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt @@ -1,4 +1,4 @@ -package ru.fromchat.api.db +package ru.fromchat.api.local.db.store import app.cash.sqldelight.coroutines.asFlow import app.cash.sqldelight.coroutines.mapToList @@ -8,20 +8,28 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import ru.fromchat.api.ApiClient -import ru.fromchat.api.DmConversation -import ru.fromchat.api.Message -import ru.fromchat.api.sortMessagesForChatDisplay -import ru.fromchat.ui.chat.DecryptedImageCache -import ru.fromchat.ui.chat.DownloadedFileRegistry -import ru.fromchat.ui.chat.dedupeMessagesByClientId -import ru.fromchat.ui.chat.dropSupersededOptimisticMessages -import ru.fromchat.api.ProfileCache -import ru.fromchat.api.outbox.DmAttachmentOutboxPayload -import ru.fromchat.api.outbox.OutgoingMessageCoordinator -import ru.fromchat.core.cache.CacheContext -import ru.fromchat.core.cache.CacheValidator +import ru.fromchat.api.local.messages.GENERAL_PUBLIC_GROUP_ID +import ru.fromchat.api.local.db.aspectRatioFromDimensionPair +import ru.fromchat.api.local.messages.conversationIdForDm +import ru.fromchat.api.local.messages.conversationIdForGroup +import ru.fromchat.api.local.messages.dmOtherUserIdFromConversationId +import ru.fromchat.api.local.db.encodeOptimisticOutboundMessage +import ru.fromchat.api.local.db.encodePersistedDmMessage +import ru.fromchat.api.local.db.parseDmMessageContent +import ru.fromchat.api.local.db.resolveLocalPreviewUri +import ru.fromchat.api.local.messages.sortMessagesForChatDisplay +import ru.fromchat.api.local.send.DmAttachmentOutboxPayload +import ru.fromchat.api.local.send.OutgoingMessageCoordinator +import ru.fromchat.api.schema.messages.Message +import ru.fromchat.api.schema.messages.dm.DmConversation +import ru.fromchat.api.local.cache.CacheContext +import ru.fromchat.api.local.cache.CacheValidator import ru.fromchat.db.Conversation import ru.fromchat.db.MessageDatabase +import ru.fromchat.api.local.cache.DecryptedImageCache +import ru.fromchat.api.local.download.DownloadedFileRegistry +import ru.fromchat.ui.chat.utils.dedupeMessagesByClientId +import ru.fromchat.ui.chat.utils.dropSupersededOptimisticMessages import ru.fromchat.db.Message as DbMessage data class CachedConversation( @@ -71,15 +79,19 @@ object MessageCacheStore { loadRecentMessages(conversationIdForPublic(), limit) suspend fun replacePublicMessages(messages: List) { - val convId = conversationIdForPublic() - val pending = loadPendingMessages(convId) - val stillPending = pending.filter { p -> - val cid = p.client_message_id - cid == null || messages.none { it.client_message_id == cid } + conversationIdForPublic().let { + replaceMessages( + it, + sortMessagesForChatDisplay( + dedupeMessagesByClientId( + messages + loadPendingMessages(it).filter { p -> + val cid = p.client_message_id + cid == null || messages.none { it.client_message_id == cid } + } + ) + ) + ) } - val merged = dedupeMessagesByClientId(messages + stillPending) - .let { sortMessagesForChatDisplay(it) } - replaceMessages(convId, merged) } suspend fun clearPublicMessages() { @@ -228,7 +240,7 @@ object MessageCacheStore { .selectRecentMessagesByConversation(iid, conversationId, 1) .executeAsList() .firstOrNull() - val rawPreview = recent?.content?.orEmpty()?.trim().orEmpty() + val rawPreview = recent?.content.orEmpty().trim() val preview = rawPreview.takeIf { it.isNotEmpty() } ?.let { truncateDmListPreview(it) } ?.takeIf { it.isNotEmpty() } @@ -277,7 +289,7 @@ object MessageCacheStore { .selectRecentMessagesByConversation(iid, convId, 1) .executeAsList() .firstOrNull() - val rawPreview = recent?.content?.orEmpty()?.trim().orEmpty() + val rawPreview = recent?.content.orEmpty().trim() val preview = rawPreview.takeIf { it.isNotEmpty() } ?.let { truncateDmListPreview(it) } ?.takeIf { it.isNotEmpty() } @@ -580,7 +592,7 @@ object MessageCacheStore { withContext(Dispatchers.Default) { db.messageDatabaseQueries.transaction { db.messageDatabaseQueries.deleteMessagesForConversation(iid, conversationId) - validated.forEach { msg -> + validated.forEach { msg: Message -> db.messageDatabaseQueries.upsertMessage( instanceId = iid, id = msg.id.toLong(), diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageDatabaseProvider.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseProvider.kt similarity index 91% rename from app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageDatabaseProvider.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseProvider.kt index 57c3a4e..8150d97 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageDatabaseProvider.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseProvider.kt @@ -1,6 +1,9 @@ -package ru.fromchat.api.db +package ru.fromchat.api.local.db.store import app.cash.sqldelight.db.SqlDriver +import ru.fromchat.api.local.db.ensureMessageDatabaseSchema +import ru.fromchat.api.local.db.rebindUnboundMigrationInstance +import ru.fromchat.api.local.db.withMessageDatabaseLock import ru.fromchat.db.MessageDatabase /** diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageRepository.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageRepository.kt similarity index 86% rename from app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageRepository.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageRepository.kt index 9d828ad..8e78448 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageRepository.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageRepository.kt @@ -1,8 +1,12 @@ -package ru.fromchat.api.db +package ru.fromchat.api.local.db.store import kotlinx.coroutines.flow.Flow -import ru.fromchat.api.Message -import ru.fromchat.core.cache.CacheContext +import ru.fromchat.api.local.messages.GENERAL_PUBLIC_GROUP_ID +import ru.fromchat.api.local.messages.conversationIdForDm +import ru.fromchat.api.local.messages.conversationIdForGroup +import ru.fromchat.api.schema.messages.Message +import ru.fromchat.api.schema.messages.dm.DmConversation +import ru.fromchat.api.local.cache.CacheContext /** * Instance-scoped message access for UI and send pipeline. @@ -59,11 +63,11 @@ object MessageRepository { suspend fun deleteDmMessageById(otherUserId: Int, messageId: Int) = MessageCacheStore.deleteDmMessageById(otherUserId, messageId) - suspend fun replaceDmConversations(conversations: List) = + suspend fun replaceDmConversations(conversations: List) = MessageCacheStore.replaceDmConversations(conversations) suspend fun loadCachedDmConversations(): List = MessageCacheStore.loadCachedDmConversations() suspend fun clearAllCache() = MessageCacheStore.clearAll() -} +} \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ProfileCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCache.kt similarity index 93% rename from app/shared/src/commonMain/kotlin/ru/fromchat/api/ProfileCache.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCache.kt index 76ef66b..53276f1 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ProfileCache.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCache.kt @@ -1,4 +1,4 @@ -package ru.fromchat.api +package ru.fromchat.api.local.db.store import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -6,7 +6,10 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import ru.fromchat.api.db.ProfileCacheStore +import ru.fromchat.api.schema.messages.Message +import ru.fromchat.api.schema.user.User +import ru.fromchat.api.schema.user.profile.UserProfile +import ru.fromchat.api.local.cache.CacheContext import kotlin.concurrent.Volatile /** @@ -27,7 +30,7 @@ fun UserProfile.visibleDisplayName(currentUserId: Int? = null): String? = displayName?.trim()?.ifEmpty { null } ?: visibleUsername(currentUserId) /** - * In-memory profile cache for the active [ru.fromchat.core.cache.CacheContext] instance, + * In-memory profile cache for the active [CacheContext] instance, * backed by SQLDelight [profile_cache] per instance partition. */ object ProfileCache { @@ -95,7 +98,8 @@ object ProfileCache { UserProfile( id = user.id, username = incomingUsername, - displayName = existing?.displayName?.takeIf { it.isNotBlank() } ?: incomingDisplayName, + displayName = existing?.displayName?.takeIf { it.isNotBlank() } + ?: incomingDisplayName, profilePicture = user.profile_picture?.takeIf { it.isNotBlank() } ?: existing?.profilePicture, bio = existing?.bio, @@ -155,7 +159,7 @@ object ProfileCache { } suspend fun hydrateFromDisk() { - val instanceId = ru.fromchat.core.cache.CacheContext.activeInstanceId.value.trim() + val instanceId = CacheContext.activeInstanceId.value.trim() persistMutex.withLock { loadedInstanceId = instanceId profiles = if (instanceId.isNotEmpty()) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/ProfileCacheStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCacheStore.kt similarity index 95% rename from app/shared/src/commonMain/kotlin/ru/fromchat/api/db/ProfileCacheStore.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCacheStore.kt index 5e68d29..a04b75d 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/ProfileCacheStore.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCacheStore.kt @@ -1,9 +1,9 @@ -package ru.fromchat.api.db +package ru.fromchat.api.local.db.store import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json -import ru.fromchat.api.UserProfile +import ru.fromchat.api.schema.user.profile.UserProfile object ProfileCacheStore { private val json = Json { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/UserStatusStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/UserStatusStore.kt similarity index 97% rename from app/shared/src/commonMain/kotlin/ru/fromchat/api/UserStatusStore.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/UserStatusStore.kt index 77c4492..7132d97 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/UserStatusStore.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/UserStatusStore.kt @@ -1,4 +1,4 @@ -package ru.fromchat.api +package ru.fromchat.api.local.db.store import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentDownloadForeground.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/AttachmentDownloadForeground.kt similarity index 90% rename from app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentDownloadForeground.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/AttachmentDownloadForeground.kt index fcdad19..6b7f00d 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentDownloadForeground.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/AttachmentDownloadForeground.kt @@ -1,4 +1,4 @@ -package ru.fromchat.api +package ru.fromchat.api.local.download /** * Android: keeps DM file attachment downloads alive in the background via a foreground service. diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentDownloadNotifier.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/AttachmentDownloadNotifier.kt similarity index 95% rename from app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentDownloadNotifier.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/AttachmentDownloadNotifier.kt index 04096d5..6cf8fb3 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentDownloadNotifier.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/AttachmentDownloadNotifier.kt @@ -1,4 +1,4 @@ -package ru.fromchat.api +package ru.fromchat.api.local.download import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -10,12 +10,15 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import ru.fromchat.api.db.MessageCacheStore -import ru.fromchat.ui.chat.AttachmentMediaLog -import ru.fromchat.ui.chat.DecryptedFileCache -import ru.fromchat.ui.chat.DecryptedImageCache -import ru.fromchat.ui.chat.DmFileDownloader -import ru.fromchat.ui.chat.DownloadedFileRegistry +import ru.fromchat.api.ApiClient +import ru.fromchat.api.local.db.store.MessageCacheStore +import ru.fromchat.api.local.download.AttachmentDownloadNotifier.hydrateFromDisk +import ru.fromchat.api.local.download.AttachmentDownloadNotifier.progressFlow +import ru.fromchat.api.local.download.AttachmentDownloadNotifier.progressPercentByKey +import ru.fromchat.api.local.messages.DownloadProgressThrottle +import ru.fromchat.api.local.AttachmentMediaLog +import ru.fromchat.api.local.cache.DecryptedFileCache +import ru.fromchat.api.local.cache.DecryptedImageCache sealed class AttachmentDownloadProgress { data class InProgress(val storageKey: String, val percent: Int) : AttachmentDownloadProgress() @@ -245,7 +248,7 @@ object AttachmentDownloadNotifier { } } - /** @deprecated Use [hydrateFromDisk] + [AttachmentTransferBootstrap.runColdStart]. */ + /** @deprecated Use [hydrateFromDisk] + [ru.fromchat.api.local.workers.AttachmentTransferBootstrap.runColdStart]. */ suspend fun restoreAllPausedFromDisk() = hydrateFromDisk() suspend fun resumeInterruptedDownloadsOnAppStart() { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentDownloadScheduler.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/AttachmentDownloadScheduler.kt similarity index 95% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentDownloadScheduler.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/AttachmentDownloadScheduler.kt index 16bd645..4eac7d4 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentDownloadScheduler.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/AttachmentDownloadScheduler.kt @@ -1,4 +1,4 @@ -package ru.fromchat.ui.chat +package ru.fromchat.api.local.download import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred @@ -7,13 +7,14 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch -import kotlin.coroutines.coroutineContext import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import ru.fromchat.api.AttachmentDownloadForeground -import ru.fromchat.api.AttachmentDownloadNotifier +import ru.fromchat.api.local.download.AttachmentDownloadScheduler.MAX_PARALLEL +import ru.fromchat.ui.chat.utils.AttachmentDownloadVisibility +import ru.fromchat.api.local.AttachmentMediaLog /** * Limits concurrent DM attachment decrypt/download work to [MAX_PARALLEL] across **different** keys. @@ -211,6 +212,6 @@ internal fun checkAttachmentDownloadActive(storageKey: String) { /** Cooperative cancel check for in-flight decrypt/download loops. */ internal suspend fun ensureAttachmentDownloadActive(storageKey: String) { - coroutineContext.ensureActive() + currentCoroutineContext().ensureActive() checkAttachmentDownloadActive(storageKey) } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DmFileDownloader.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/DmFileDownloader.kt similarity index 81% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DmFileDownloader.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/DmFileDownloader.kt index 3f23ee0..64a2f4b 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DmFileDownloader.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/DmFileDownloader.kt @@ -1,9 +1,10 @@ -package ru.fromchat.ui.chat +package ru.fromchat.api.local.download import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import ru.fromchat.api.DmEnvelope -import ru.fromchat.api.DmFile +import ru.fromchat.api.local.cache.DecryptedFileCache +import ru.fromchat.api.schema.messages.dm.DmEnvelope +import ru.fromchat.api.schema.messages.dm.DmFile object DmFileDownloader { suspend fun downloadToCache( diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DownloadedFileRegistry.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/DownloadedFileRegistry.kt similarity index 97% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DownloadedFileRegistry.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/DownloadedFileRegistry.kt index d95c6f2..04ef180 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DownloadedFileRegistry.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/DownloadedFileRegistry.kt @@ -1,14 +1,13 @@ -package ru.fromchat.ui.chat +package ru.fromchat.api.local.download import com.pr0gramm3r101.utils.files.PlatformFileSystem import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import ru.fromchat.core.cache.CacheContext -import ru.fromchat.core.cache.readOutboundFileBytes +import ru.fromchat.api.local.cache.CacheContext +import ru.fromchat.api.local.cache.readOutboundFileBytes /** * Maps DM file attachment slots to a user-chosen export URI (SAF / document picker). diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/EncryptedFileDownloader.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/EncryptedFileDownloader.kt similarity index 95% rename from app/shared/src/commonMain/kotlin/ru/fromchat/api/EncryptedFileDownloader.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/EncryptedFileDownloader.kt index 2cd4517..020ed24 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/EncryptedFileDownloader.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/EncryptedFileDownloader.kt @@ -1,10 +1,7 @@ -package ru.fromchat.api +package ru.fromchat.api.local.download import com.pr0gramm3r101.utils.files.PlatformFileSystem -import ru.fromchat.core.files.FileWriteSink import io.ktor.client.HttpClient -import io.ktor.client.plugins.HttpTimeout -import io.ktor.client.plugins.HttpTimeoutConfig import io.ktor.client.request.header import io.ktor.client.request.prepareGet import io.ktor.client.statement.HttpResponse @@ -15,6 +12,7 @@ import io.ktor.http.contentLength import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.readAvailable import kotlinx.coroutines.ensureActive +import ru.fromchat.api.local.cache.FileWriteSink import kotlin.coroutines.coroutineContext private const val DOWNLOAD_CHUNK_BYTES = 256 * 1024 diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/LocalDecodedImageCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/LocalDecodedImageCache.kt similarity index 98% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/LocalDecodedImageCache.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/LocalDecodedImageCache.kt index 30cf2ab..9c7665f 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/LocalDecodedImageCache.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/LocalDecodedImageCache.kt @@ -1,4 +1,4 @@ -package ru.fromchat.ui.chat +package ru.fromchat.api.local.download import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -6,6 +6,7 @@ import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp +import ru.fromchat.api.local.AttachmentMediaLog import kotlin.math.ceil /** Target decode dimensions in physical pixels for a chat attachment tile. */ diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageFileSave.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/MessageFileSave.kt similarity index 93% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageFileSave.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/MessageFileSave.kt index ad4f9fd..1eb034a 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageFileSave.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/MessageFileSave.kt @@ -1,12 +1,13 @@ -package ru.fromchat.ui.chat +package ru.fromchat.api.local.download import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import com.pr0gramm3r101.utils.files.PlatformFileSystem -import kotlinx.coroutines.launch -import ru.fromchat.api.AttachmentDownloadNotifier -import ru.fromchat.api.Message +import ru.fromchat.api.local.cache.DecryptedFileCache +import ru.fromchat.api.local.mimeTypeForFilename +import ru.fromchat.api.schema.messages.Message +import ru.fromchat.ui.chat.isImageFilename data class SavableMessageFile( val fileIndex: Int, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageImageSave.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/MessageImageSave.kt similarity index 93% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageImageSave.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/MessageImageSave.kt index 1809b7a..17e59e0 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageImageSave.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/MessageImageSave.kt @@ -1,4 +1,4 @@ -package ru.fromchat.ui.chat +package ru.fromchat.api.local.download import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -6,8 +6,11 @@ import androidx.compose.runtime.rememberCoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import ru.fromchat.api.Message -import ru.fromchat.core.cache.readOutboundFileBytes +import ru.fromchat.api.local.cache.DecryptedImageCache +import ru.fromchat.api.schema.messages.Message +import ru.fromchat.api.local.cache.readOutboundFileBytes +import ru.fromchat.api.local.mimeTypeForFilename +import ru.fromchat.ui.chat.isImageFilename data class SavableMessageImage( val fileIndex: Int, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ClientMessageId.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/ClientMessageId.kt similarity index 91% rename from app/shared/src/commonMain/kotlin/ru/fromchat/api/ClientMessageId.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/ClientMessageId.kt index 0532493..22c6e04 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ClientMessageId.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/ClientMessageId.kt @@ -1,4 +1,4 @@ -package ru.fromchat.api +package ru.fromchat.api.local.messages import korlibs.crypto.SHA256 import kotlin.random.Random diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/ConversationIds.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/ConversationIds.kt similarity index 93% rename from app/shared/src/commonMain/kotlin/ru/fromchat/api/db/ConversationIds.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/ConversationIds.kt index c96bd52..f472061 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/ConversationIds.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/ConversationIds.kt @@ -1,4 +1,4 @@ -package ru.fromchat.api.db +package ru.fromchat.api.local.messages /** General public group chat (only group for now). Groups use negative ids. */ const val GENERAL_PUBLIC_GROUP_ID: Int = -1 diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/DownloadProgressThrottle.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/DownloadProgressThrottle.kt similarity index 81% rename from app/shared/src/commonMain/kotlin/ru/fromchat/api/DownloadProgressThrottle.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/DownloadProgressThrottle.kt index 644e458..9325c21 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/DownloadProgressThrottle.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/DownloadProgressThrottle.kt @@ -1,6 +1,6 @@ -package ru.fromchat.api +package ru.fromchat.api.local.messages -import ru.fromchat.ui.chat.AttachmentMediaLog +import ru.fromchat.api.local.AttachmentMediaLog /** * Coalesces download progress for UI (~display refresh rate) and system notifications (≤1/s). @@ -17,7 +17,7 @@ internal class DownloadProgressThrottle( fun shouldPublishUi(percent: Int, nowMs: Long = AttachmentMediaLog.nowMs()): Boolean { val pct = percent.coerceIn(0, 100) if (pct == lastUiPercent) return false - if (pct <= 1 || pct >= 100 || lastUiAtMs == 0L || nowMs - lastUiAtMs >= uiFrameMs) { + if (pct !in 2..<100 || lastUiAtMs == 0L || nowMs - lastUiAtMs >= uiFrameMs) { lastUiPercent = pct lastUiAtMs = nowMs return true @@ -27,7 +27,7 @@ internal class DownloadProgressThrottle( fun shouldPublishNotification(percent: Int, nowMs: Long = AttachmentMediaLog.nowMs()): Boolean { val pct = percent.coerceIn(0, 100) - if (pct >= 100 || pct <= 1 || lastNotifAtMs == 0L || nowMs - lastNotifAtMs >= notificationIntervalMs) { + if (pct !in 2..<100 || lastNotifAtMs == 0L || nowMs - lastNotifAtMs >= notificationIntervalMs) { if (pct != lastNotifPercent || nowMs - lastNotifAtMs >= notificationIntervalMs) { lastNotifPercent = pct lastNotifAtMs = nowMs diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/MessageOrdering.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/MessageOrdering.kt similarity index 88% rename from app/shared/src/commonMain/kotlin/ru/fromchat/api/MessageOrdering.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/MessageOrdering.kt index 9f2d78a..9b595f0 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/MessageOrdering.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/MessageOrdering.kt @@ -1,4 +1,6 @@ -package ru.fromchat.api +package ru.fromchat.api.local.messages + +import ru.fromchat.api.schema.messages.Message /** * Chat list order: confirmed messages by time, then outgoing queued (negative id) at the bottom. diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/MessageTimestamps.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/MessageTimestamps.kt similarity index 90% rename from app/shared/src/commonMain/kotlin/ru/fromchat/api/MessageTimestamps.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/MessageTimestamps.kt index 2334121..a160718 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/MessageTimestamps.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/MessageTimestamps.kt @@ -1,8 +1,9 @@ -package ru.fromchat.api +package ru.fromchat.api.local.messages -import kotlinx.datetime.Instant +import kotlin.time.Instant import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone +import kotlinx.datetime.number import kotlinx.datetime.toLocalDateTime import kotlin.time.Clock @@ -38,8 +39,8 @@ internal fun formatMessageTimeLocal(timestamp: String): String { /** MM/dd/yyyy HH:mm in the device time zone (fullscreen header). */ internal fun formatMessageDateTimeLocal(timestamp: String): String { val local = parseMessageInstant(timestamp)?.toDeviceLocal() ?: return timestamp.trim() - val month = local.monthNumber.toString().padStart(2, '0') - val day = local.dayOfMonth.toString().padStart(2, '0') + val month = local.month.number.toString().padStart(2, '0') + val day = local.day.toString().padStart(2, '0') val hour = local.hour.toString().padStart(2, '0') val minute = local.minute.toString().padStart(2, '0') return "$month/$day/${local.year} $hour:$minute" diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/OptimisticMessageIds.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/OptimisticMessageIds.kt similarity index 61% rename from app/shared/src/commonMain/kotlin/ru/fromchat/api/OptimisticMessageIds.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/OptimisticMessageIds.kt index f7b6204..f463d6f 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/OptimisticMessageIds.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/OptimisticMessageIds.kt @@ -1,9 +1,12 @@ -package ru.fromchat.api +package ru.fromchat.api.local.messages + +import ru.fromchat.api.schema.messages.Message +import kotlin.math.abs /** Stable negative row id for an optimistic / outbox [clientMessageId]. */ fun optimisticMessageIdForClientMessageId(clientMessageId: String): Int { val hc = clientMessageId.hashCode() - val absHc = if (hc == Int.MIN_VALUE) Int.MAX_VALUE else kotlin.math.abs(hc) + val absHc = if (hc == Int.MIN_VALUE) Int.MAX_VALUE else abs(hc) return -(if (absHc == 0) 1 else absHc) } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/outbox/DmAttachmentOutboxHandler.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/DmAttachmentOutboxHandler.kt similarity index 91% rename from app/shared/src/commonMain/kotlin/ru/fromchat/api/outbox/DmAttachmentOutboxHandler.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/DmAttachmentOutboxHandler.kt index cfc3654..293a24e 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/outbox/DmAttachmentOutboxHandler.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/DmAttachmentOutboxHandler.kt @@ -1,49 +1,45 @@ -package ru.fromchat.api.outbox +package ru.fromchat.api.local.send import com.pr0gramm3r101.utils.crypto.Base64 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import ru.fromchat.api.ApiClient -import ru.fromchat.api.AttachmentUploadNotifier -import ru.fromchat.api.AttachmentUploadProgress -import ru.fromchat.api.DmUploadCompleteResponse -import ru.fromchat.api.SendDmFile -import ru.fromchat.api.db.MessageCacheStore -import ru.fromchat.api.db.MessageDatabaseProvider -import ru.fromchat.api.db.conversationIdForDm -import ru.fromchat.core.cache.OutboundFileUnavailableException -import ru.fromchat.core.cache.UPLOAD_ERROR_FILE_TOO_LARGE -import ru.fromchat.core.cache.clearUploadSecretsOnly -import ru.fromchat.api.optimisticMessageIdForClientMessageId -import ru.fromchat.core.cache.commitEncryptedUploadBlob -import ru.fromchat.core.cache.encryptedUploadBlobPartPath -import ru.fromchat.core.cache.encryptedUploadBlobSizeBytes -import ru.fromchat.core.cache.isEncryptedBlobReady -import ru.fromchat.core.cache.isFileTooLargeForUpload -import ru.fromchat.core.cache.isLikelyUploadMemoryError -import ru.fromchat.core.cache.isOutboundFileUnavailable -import ru.fromchat.core.cache.loadEncryptedUploadBlob -import ru.fromchat.core.cache.loadUploadTransportCipherJson -import ru.fromchat.core.cache.queryOutboundUriSizeBytes -import ru.fromchat.core.cache.readEncryptedUploadBlobRange -import ru.fromchat.core.cache.readOutboundFileBytes -import ru.fromchat.core.cache.repairInterruptedUploadArtifacts -import ru.fromchat.core.cache.saveEncryptedUploadBlob -import ru.fromchat.core.cache.saveUploadTransportCipherJsonAtomic -import ru.fromchat.core.cache.shouldStreamEncryptPlaintext -import ru.fromchat.core.cache.stageOutboundFileForUpload -import ru.fromchat.ui.chat.AttachmentMediaLog -import ru.fromchat.ui.chat.clearOutboundFileCaches -import ru.fromchat.ui.chat.clearOutboundImageCaches -import ru.fromchat.ui.chat.isImageFilename -import ru.fromchat.ui.chat.seedOutboundFileAsDownloaded -import ru.fromchat.crypto.transport.TransportCiphertext -import ru.fromchat.crypto.transport.TransportCrypto -import ru.fromchat.crypto.transport.TransportFileEncryptor +import ru.fromchat.api.local.workers.AttachmentUploadProgress +import ru.fromchat.api.local.db.store.MessageCacheStore +import ru.fromchat.api.local.db.store.MessageDatabaseProvider +import ru.fromchat.api.local.messages.conversationIdForDm +import ru.fromchat.api.local.messages.optimisticMessageIdForClientMessageId +import ru.fromchat.api.local.workers.AttachmentUploadNotifier +import ru.fromchat.api.schema.messages.dm.SendDmFile +import ru.fromchat.api.schema.messages.dm.upload.DmUploadCompleteResponse +import ru.fromchat.api.local.cache.OutboundFileUnavailableException +import ru.fromchat.api.local.cache.UPLOAD_ERROR_FILE_TOO_LARGE +import ru.fromchat.api.local.cache.clearUploadSecretsOnly +import ru.fromchat.api.local.cache.commitEncryptedUploadBlob +import ru.fromchat.api.local.cache.encryptedUploadBlobPartPath +import ru.fromchat.api.local.cache.encryptedUploadBlobSizeBytes +import ru.fromchat.api.local.cache.isEncryptedBlobReady +import ru.fromchat.api.local.cache.isFileTooLargeForUpload +import ru.fromchat.api.local.cache.isLikelyUploadMemoryError +import ru.fromchat.api.local.cache.isOutboundFileUnavailable +import ru.fromchat.api.local.cache.loadEncryptedUploadBlob +import ru.fromchat.api.local.cache.loadUploadTransportCipherJson +import ru.fromchat.api.local.cache.queryOutboundUriSizeBytes +import ru.fromchat.api.local.cache.readEncryptedUploadBlobRange +import ru.fromchat.api.local.cache.readOutboundFileBytes +import ru.fromchat.api.local.cache.repairInterruptedUploadArtifacts +import ru.fromchat.api.local.cache.saveEncryptedUploadBlob +import ru.fromchat.api.local.cache.saveUploadTransportCipherJsonAtomic +import ru.fromchat.api.local.cache.shouldStreamEncryptPlaintext +import ru.fromchat.api.local.cache.stageOutboundFileForUpload +import ru.fromchat.api.crypto.transport.TransportCiphertext +import ru.fromchat.api.crypto.transport.TransportCrypto +import ru.fromchat.api.crypto.transport.TransportFileEncryptor import ru.fromchat.db.Outbox +import ru.fromchat.api.local.AttachmentMediaLog +import ru.fromchat.ui.chat.isImageFilename private const val INLINE_UPLOAD_THRESHOLD_BYTES = 512 * 1024 private const val DEFAULT_CHUNK_SIZE = 262_144 @@ -149,7 +145,7 @@ object DmAttachmentOutboxHandler { msgCipher = encrypted.cipher activePayload = encrypted.payload } - val blobSize = encryptedSize ?: return@runCatching false + val blobSize = encryptedSize if (activePayload.encryptedFileSizeBytes <= 0L) { activePayload = activePayload.copy(encryptedFileSizeBytes = blobSize) } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/OutboundFileCacheBootstrap.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboundFileCacheBootstrap.kt similarity index 84% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/OutboundFileCacheBootstrap.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboundFileCacheBootstrap.kt index 175bbe3..4e98daa 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/OutboundFileCacheBootstrap.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboundFileCacheBootstrap.kt @@ -1,6 +1,8 @@ -package ru.fromchat.ui.chat +package ru.fromchat.api.local.send -import ru.fromchat.api.AttachmentDownloadNotifier +import ru.fromchat.api.local.cache.DecryptedFileCache +import ru.fromchat.api.local.download.AttachmentDownloadNotifier +import ru.fromchat.api.local.download.DownloadedFileRegistry /** * Copies a staged outbound file into the cache under [displayFilename] (correct extension on disk). diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/OutboundFileStaging.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboundFileStaging.kt similarity index 84% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/OutboundFileStaging.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboundFileStaging.kt index b236d93..ecce8ef 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/OutboundFileStaging.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboundFileStaging.kt @@ -1,9 +1,11 @@ -package ru.fromchat.ui.chat +package ru.fromchat.api.local.send import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import ru.fromchat.core.cache.CacheContext -import ru.fromchat.core.cache.stageOutboundFileForUpload +import ru.fromchat.api.local.cache.CacheContext +import ru.fromchat.api.local.cache.DecryptedFileCache +import ru.fromchat.api.local.cache.stageOutboundFileForUpload +import ru.fromchat.api.local.download.DownloadedFileRegistry /** * Copy a non-image attachment into instance upload storage before any upload work runs. diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/OutboundImageStaging.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboundImageStaging.kt similarity index 87% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/OutboundImageStaging.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboundImageStaging.kt index 37c8183..1f636d5 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/OutboundImageStaging.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboundImageStaging.kt @@ -1,9 +1,12 @@ -package ru.fromchat.ui.chat +package ru.fromchat.api.local.send import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import ru.fromchat.core.cache.CacheContext -import ru.fromchat.core.cache.stageOutboundFileForUpload +import ru.fromchat.api.local.cache.CacheContext +import ru.fromchat.api.local.cache.DecryptedImageCache +import ru.fromchat.api.local.cache.stageOutboundFileForUpload +import ru.fromchat.api.local.download.ChatPreviewDecodeSize +import ru.fromchat.api.local.download.LocalDecodedImageCache data class StagedOutboundPreview( val stagedUri: String, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/outbox/OutboxPayloads.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboxPayloads.kt similarity index 90% rename from app/shared/src/commonMain/kotlin/ru/fromchat/api/outbox/OutboxPayloads.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboxPayloads.kt index 0e2767c..ce12941 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/outbox/OutboxPayloads.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboxPayloads.kt @@ -1,7 +1,7 @@ -package ru.fromchat.api.outbox +package ru.fromchat.api.local.send import kotlinx.serialization.Serializable -import ru.fromchat.api.SendDmFile +import ru.fromchat.api.schema.messages.dm.SendDmFile @Serializable data class PublicOutboxPayload( diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/outbox/OutboxProcessor.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboxProcessor.kt similarity index 86% rename from app/shared/src/commonMain/kotlin/ru/fromchat/api/outbox/OutboxProcessor.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboxProcessor.kt index 6adeba6..716d76d 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/outbox/OutboxProcessor.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboxProcessor.kt @@ -1,4 +1,4 @@ -package ru.fromchat.api.outbox +package ru.fromchat.api.local.send /** * Drains pending outbox rows for the active instance (Android: WorkManager; iOS: BG task hook). diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/outbox/OutgoingMessageCoordinator.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutgoingMessageCoordinator.kt similarity index 93% rename from app/shared/src/commonMain/kotlin/ru/fromchat/api/outbox/OutgoingMessageCoordinator.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutgoingMessageCoordinator.kt index 2d6b94d..dc96b0b 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/outbox/OutgoingMessageCoordinator.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutgoingMessageCoordinator.kt @@ -1,4 +1,4 @@ -package ru.fromchat.api.outbox +package ru.fromchat.api.local.send import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -8,23 +8,22 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import ru.fromchat.api.AttachmentUploadNotifier -import ru.fromchat.api.AttachmentUploadProgress -import ru.fromchat.ui.chat.AttachmentMediaLog -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import ru.fromchat.api.ApiClient -import ru.fromchat.api.Message -import ru.fromchat.api.db.MessageCacheStore -import ru.fromchat.api.db.MessageDatabaseProvider -import ru.fromchat.api.db.MessageRepository -import ru.fromchat.api.db.conversationIdForDm -import ru.fromchat.api.db.conversationIdForGroup -import ru.fromchat.api.db.GENERAL_PUBLIC_GROUP_ID -import ru.fromchat.core.cache.CacheContext -import ru.fromchat.core.cache.clearUploadArtifacts -import ru.fromchat.core.cache.clearUploadSecretsOnly -import ru.fromchat.core.cache.readOutboundFileBytes +import ru.fromchat.api.local.workers.AttachmentUploadProgress +import ru.fromchat.api.local.messages.GENERAL_PUBLIC_GROUP_ID +import ru.fromchat.api.local.db.store.MessageCacheStore +import ru.fromchat.api.local.db.store.MessageDatabaseProvider +import ru.fromchat.api.local.db.store.MessageRepository +import ru.fromchat.api.local.messages.conversationIdForDm +import ru.fromchat.api.local.messages.conversationIdForGroup +import ru.fromchat.api.local.workers.AttachmentUploadNotifier +import ru.fromchat.api.schema.messages.Message +import ru.fromchat.api.schema.messages.dm.SendDmFile +import ru.fromchat.api.local.cache.CacheContext +import ru.fromchat.api.local.cache.clearUploadArtifacts +import ru.fromchat.api.local.cache.clearUploadSecretsOnly +import ru.fromchat.api.local.AttachmentMediaLog /** * Single entry point for enqueueing outbound messages (DB row + outbox + worker). @@ -73,7 +72,7 @@ object OutgoingMessageCoordinator { clientMessageId: String, replyToId: Int?, optimisticMessage: Message, - transportFiles: List = emptyList(), + transportFiles: List = emptyList(), uploadedFileIds: List = emptyList(), ) { val instanceId = CacheContext.requireActiveInstanceId() diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentFileCopyForeground.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/workers/AttachmentFileCopyForeground.kt similarity index 81% rename from app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentFileCopyForeground.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/workers/AttachmentFileCopyForeground.kt index 8bc8ce5..e68f21c 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentFileCopyForeground.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/workers/AttachmentFileCopyForeground.kt @@ -1,4 +1,4 @@ -package ru.fromchat.api +package ru.fromchat.api.local.workers expect object AttachmentFileCopyForeground { fun onCopyStarted(storageKey: String, displayLabel: String? = null) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentTransferBootstrap.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/workers/AttachmentTransferBootstrap.kt similarity index 75% rename from app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentTransferBootstrap.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/workers/AttachmentTransferBootstrap.kt index 15bec6c..858af7b 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentTransferBootstrap.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/workers/AttachmentTransferBootstrap.kt @@ -1,18 +1,21 @@ -package ru.fromchat.api +package ru.fromchat.api.local.workers import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch -import ru.fromchat.api.db.MessageDatabaseProvider -import ru.fromchat.api.outbox.DmAttachmentOutboxPayload -import ru.fromchat.api.outbox.OutgoingMessageCoordinator -import ru.fromchat.api.outbox.scheduleOutboxProcessing -import ru.fromchat.core.cache.repairInterruptedUploadArtifacts import kotlinx.serialization.json.Json -import ru.fromchat.core.cache.CacheContext -import ru.fromchat.core.instance.applyCachedSessionInstanceIfAvailable -import ru.fromchat.core.instance.scheduleSessionInstanceNetworkRefresh +import ru.fromchat.api.ApiClient +import ru.fromchat.api.local.db.store.MessageDatabaseProvider +import ru.fromchat.api.local.download.AttachmentDownloadNotifier +import ru.fromchat.api.local.send.DmAttachmentOutboxPayload +import ru.fromchat.api.local.send.OutgoingMessageCoordinator +import ru.fromchat.api.local.send.scheduleOutboxProcessing +import ru.fromchat.api.local.cache.CacheContext +import ru.fromchat.api.local.cache.repairInterruptedUploadArtifacts +import ru.fromchat.api.instance.applyCachedSessionInstanceIfAvailable +import ru.fromchat.api.instance.scheduleSessionInstanceNetworkRefresh + /** * Cold-start hook for attachment downloads and outbound media uploads. * Call from Android [android.app.Application] and from the iOS app entry (not Activity / Compose lifecycle). diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentUploadNotifier.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/workers/AttachmentUploadNotifier.kt similarity index 95% rename from app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentUploadNotifier.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/workers/AttachmentUploadNotifier.kt index 589e227..a650199 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentUploadNotifier.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/workers/AttachmentUploadNotifier.kt @@ -1,4 +1,4 @@ -package ru.fromchat.api +package ru.fromchat.api.local.workers import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -6,7 +6,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.launch -import ru.fromchat.ui.chat.AttachmentMediaLog +import ru.fromchat.api.local.AttachmentMediaLog /** Shared upload/download progress events (outbox worker + UI + decrypt pipeline). */ object AttachmentUploadNotifier { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/workers/AttachmentUploadQueue.kt similarity index 95% rename from app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/api/local/workers/AttachmentUploadQueue.kt index 7a80efb..54dcf51 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/workers/AttachmentUploadQueue.kt @@ -1,4 +1,4 @@ -package ru.fromchat.api +package ru.fromchat.api.local.workers import kotlinx.coroutines.flow.SharedFlow diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/calls/CallSignalingLiveKitControl.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/calls/CallSignalingLiveKitControl.kt new file mode 100644 index 0000000..517a910 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/calls/CallSignalingLiveKitControl.kt @@ -0,0 +1,10 @@ +package ru.fromchat.api.schema.calls + +import kotlinx.serialization.Serializable + +@Serializable +data class CallSignalingLiveKitControl( + val toUserId: Int, + val kind: String, + val roomName: String? = null, +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/calls/CallSignalingLiveKitPayload.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/calls/CallSignalingLiveKitPayload.kt new file mode 100644 index 0000000..2b44dee --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/calls/CallSignalingLiveKitPayload.kt @@ -0,0 +1,10 @@ +package ru.fromchat.api.schema.calls + +import kotlinx.serialization.Serializable + +@Serializable +data class CallSignalingLiveKitPayload( + val toUserId: Int, + val roomName: String, + val serverUrl: String, +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/calls/LiveKitTokenRequest.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/calls/LiveKitTokenRequest.kt new file mode 100644 index 0000000..ad45d6f --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/calls/LiveKitTokenRequest.kt @@ -0,0 +1,10 @@ +package ru.fromchat.api.schema.calls + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class LiveKitTokenRequest( + @SerialName("peer_user_id") val peerUserId: Int, + @SerialName("room_name") val roomName: String? = null, +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/calls/LiveKitTokenResponse.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/calls/LiveKitTokenResponse.kt new file mode 100644 index 0000000..2d0ad3d --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/calls/LiveKitTokenResponse.kt @@ -0,0 +1,11 @@ +package ru.fromchat.api.schema.calls + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class LiveKitTokenResponse( + @SerialName("server_url") val serverUrl: String, + val token: String, + @SerialName("room_name") val roomName: String, +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/core/ErrorResponse.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/core/ErrorResponse.kt new file mode 100644 index 0000000..28d9e3a --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/core/ErrorResponse.kt @@ -0,0 +1,8 @@ +package ru.fromchat.api.schema.core + +import kotlinx.serialization.Serializable + +@Serializable +data class ErrorResponse( + val detail: String +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/core/SimpleStatusResponse.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/core/SimpleStatusResponse.kt new file mode 100644 index 0000000..6d9386e --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/core/SimpleStatusResponse.kt @@ -0,0 +1,9 @@ +package ru.fromchat.api.schema.core + +import kotlinx.serialization.Serializable + +@Serializable +data class SimpleStatusResponse( + val status: String? = null, + val message: String? = null +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/Message.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/Message.kt new file mode 100644 index 0000000..f05e7af --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/Message.kt @@ -0,0 +1,48 @@ +package ru.fromchat.api.schema.messages + +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import ru.fromchat.api.schema.messages.dm.DmEnvelope +import ru.fromchat.api.schema.messages.dm.DmFile +import ru.fromchat.api.schema.websocket.types.ReactionData + +@Serializable +data class Message( + val id: Int, + val user_id: Int, + val content: String, + val timestamp: String, + val is_read: Boolean, + val is_edited: Boolean, + val username: String, + val profile_picture: String? = null, + val verified: Boolean? = null, + val reply_to: Message? = null, + val client_message_id: String? = null, + val reactions: List? = null, + val files: List? = null, + /** For optimistic UI: local URI when sending, null when confirmed. */ + val pendingFileUri: String? = null, + /** For optimistic UI: filename when sending file (non-image), null when confirmed. */ + val pendingFilename: String? = null, + /** For optimistic UI: aspect ratio (width/height) when sending image, null when confirmed. */ + val pendingFileAspectRatio: Float? = null, + /** For optimistic UI: jobId to track upload progress. */ + val uploadJobId: String? = null, + /** For optimistic UI: 0-100 upload progress, null when complete. */ + val uploadProgress: Int? = null, + /** Set when outbound upload failed; use [UPLOAD_ERROR_FILE_TOO_LARGE] for localized copy. */ + @Transient val uploadError: String? = null, + /** For DM file decryption; not serialized over network. */ + @Transient val dmEnvelope: DmEnvelope? = null, + /** Blurhashes for image files (by index); from decrypted message JSON. */ + @Transient val fileThumbnails: List? = null, + /** Aspect ratios (width/height) for image files (by index); from decrypted message JSON. */ + @Transient val fileAspectRatios: List? = null, + /** File sizes in bytes (by index); from decrypted message JSON. */ + @Transient val fileSizes: List? = null, + /** Image dimensions (width, height) for image files (by index); from decrypted message JSON. */ + @Transient val fileDimensions: List>? = null, + /** True when DM plaintext could not be decrypted and [content] shows the corrupted placeholder. */ + @Transient val isContentCorrupted: Boolean = false +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/MessagesResponse.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/MessagesResponse.kt new file mode 100644 index 0000000..5f751da --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/MessagesResponse.kt @@ -0,0 +1,9 @@ +package ru.fromchat.api.schema.messages + +import kotlinx.serialization.Serializable + +@Serializable +data class MessagesResponse( + val status: String, + val messages: List +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/ReactionUser.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/ReactionUser.kt new file mode 100644 index 0000000..e2825f0 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/ReactionUser.kt @@ -0,0 +1,9 @@ +package ru.fromchat.api.schema.messages + +import kotlinx.serialization.Serializable + +@Serializable +data class ReactionUser( + val id: Int, + val username: String +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmConversation.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmConversation.kt new file mode 100644 index 0000000..b5c2973 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmConversation.kt @@ -0,0 +1,11 @@ +package ru.fromchat.api.schema.messages.dm + +import kotlinx.serialization.Serializable +import ru.fromchat.api.schema.user.User + +@Serializable +data class DmConversation( + val user: User, + val lastMessage: DmEnvelope, + val unreadCount: Int +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmConversationsResponse.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmConversationsResponse.kt new file mode 100644 index 0000000..3a731a8 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmConversationsResponse.kt @@ -0,0 +1,8 @@ +package ru.fromchat.api.schema.messages.dm + +import kotlinx.serialization.Serializable + +@Serializable +data class DmConversationsResponse( + val conversations: List = emptyList() +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmEnvelope.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmEnvelope.kt new file mode 100644 index 0000000..09c4e92 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmEnvelope.kt @@ -0,0 +1,19 @@ +package ru.fromchat.api.schema.messages.dm + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class DmEnvelope( + val id: Int, + val senderId: Int, + val recipientId: Int, + @SerialName("sender_username") val senderUsername: String? = null, + @SerialName("iv_b64") val ivB64: String, + @SerialName("ciphertext_b64") val ciphertextB64: String, + @SerialName("wrapped_mek_b64") val wrappedMekB64: String? = null, + val timestamp: String, + @SerialName("client_message_id") val clientMessageId: String? = null, + @SerialName("reply_to_id") val replyToId: Int? = null, + val files: List? = null +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmFile.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmFile.kt new file mode 100644 index 0000000..4c9947c --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmFile.kt @@ -0,0 +1,14 @@ +package ru.fromchat.api.schema.messages.dm + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class DmFile( + val id: Int, + val name: String, + val path: String, + @SerialName("dm_envelope_id") val dmEnvelopeId: Int? = null, + @SerialName("wrapped_mek_b64") val wrappedMekB64: String? = null, + @SerialName("nonce_b64") val nonceB64: String? = null +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmHistoryResponse.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmHistoryResponse.kt new file mode 100644 index 0000000..ac290e2 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmHistoryResponse.kt @@ -0,0 +1,10 @@ +package ru.fromchat.api.schema.messages.dm + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class DmHistoryResponse( + val messages: List = emptyList(), + @SerialName("has_more") val hasMore: Boolean? = null +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/EditDmRequest.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/EditDmRequest.kt new file mode 100644 index 0000000..76f43ce --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/EditDmRequest.kt @@ -0,0 +1,13 @@ +package ru.fromchat.api.schema.messages.dm + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class EditDmRequest( + @SerialName("client_public_key_b64") val clientPublicKeyB64: String, + @SerialName("transport_nonce_b64") val transportNonceB64: String, + @SerialName("transport_ciphertext_b64") val transportCiphertextB64: String, + @SerialName("sender_public_key_b64") val senderPublicKeyB64: String, + @SerialName("recipient_public_key_b64") val recipientPublicKeyB64: String +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/SendDmFile.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/SendDmFile.kt new file mode 100644 index 0000000..d39d68e --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/SendDmFile.kt @@ -0,0 +1,11 @@ +package ru.fromchat.api.schema.messages.dm + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SendDmFile( + @SerialName("encrypted_file_data_b64") val encryptedFileDataB64: String, + val filename: String, + @SerialName("file_size") val fileSize: Long +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/SendDmRequest.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/SendDmRequest.kt new file mode 100644 index 0000000..6e74276 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/SendDmRequest.kt @@ -0,0 +1,18 @@ +package ru.fromchat.api.schema.messages.dm + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SendDmRequest( + @SerialName("recipient_id") val recipientId: Int, + @SerialName("client_public_key_b64") val clientPublicKeyB64: String, + @SerialName("transport_nonce_b64") val transportNonceB64: String, + @SerialName("transport_ciphertext_b64") val transportCiphertextB64: String, + @SerialName("sender_public_key_b64") val senderPublicKeyB64: String, + @SerialName("recipient_public_key_b64") val recipientPublicKeyB64: String, + @SerialName("client_message_id") val clientMessageId: String? = null, + @SerialName("reply_to_id") val replyToId: Int? = null, + @SerialName("transport_files") val transportFiles: List = emptyList(), + @SerialName("uploaded_file_ids") val uploadedFileIds: List = emptyList() +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/upload/DmUploadChunkRequest.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/upload/DmUploadChunkRequest.kt new file mode 100644 index 0000000..91a9c35 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/upload/DmUploadChunkRequest.kt @@ -0,0 +1,10 @@ +package ru.fromchat.api.schema.messages.dm.upload + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class DmUploadChunkRequest( + val offset: Long, + @SerialName("data_b64") val dataB64: String +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/upload/DmUploadChunkResponse.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/upload/DmUploadChunkResponse.kt new file mode 100644 index 0000000..71943b0 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/upload/DmUploadChunkResponse.kt @@ -0,0 +1,9 @@ +package ru.fromchat.api.schema.messages.dm.upload + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class DmUploadChunkResponse( + @SerialName("offset_received") val offsetReceived: Long +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/upload/DmUploadCompleteResponse.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/upload/DmUploadCompleteResponse.kt new file mode 100644 index 0000000..0f4ea6f --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/upload/DmUploadCompleteResponse.kt @@ -0,0 +1,10 @@ +package ru.fromchat.api.schema.messages.dm.upload + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class DmUploadCompleteResponse( + @SerialName("file_id") val fileId: String, + @SerialName("upload_id") val uploadId: String +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/upload/DmUploadInitRequest.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/upload/DmUploadInitRequest.kt new file mode 100644 index 0000000..b6ceea0 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/upload/DmUploadInitRequest.kt @@ -0,0 +1,12 @@ +package ru.fromchat.api.schema.messages.dm.upload + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class DmUploadInitRequest( + val filename: String, + @SerialName("total_size") val totalSize: Long, + @SerialName("recipient_id") val recipientId: Int, + @SerialName("chunk_size") val chunkSize: Int? = null +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/upload/DmUploadInitResponse.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/upload/DmUploadInitResponse.kt new file mode 100644 index 0000000..3c331ff --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/upload/DmUploadInitResponse.kt @@ -0,0 +1,11 @@ +package ru.fromchat.api.schema.messages.dm.upload + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class DmUploadInitResponse( + @SerialName("upload_id") val uploadId: String, + @SerialName("chunk_size") val chunkSize: Int, + val offset: Long = 0L +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/upload/DmUploadStatusResponse.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/upload/DmUploadStatusResponse.kt new file mode 100644 index 0000000..5cc9b86 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/upload/DmUploadStatusResponse.kt @@ -0,0 +1,13 @@ +package ru.fromchat.api.schema.messages.dm.upload + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class DmUploadStatusResponse( + @SerialName("upload_id") val uploadId: String, + val filename: String, + @SerialName("total_size") val totalSize: Long, + val offset: Long, + val complete: Boolean +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/EditMessageRequest.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/EditMessageRequest.kt new file mode 100644 index 0000000..9ec5980 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/EditMessageRequest.kt @@ -0,0 +1,8 @@ +package ru.fromchat.api.schema.messages.publicchat + +import kotlinx.serialization.Serializable + +@Serializable +data class EditMessageRequest( + val content: String +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/SendMessageRequest.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/SendMessageRequest.kt new file mode 100644 index 0000000..30962da --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/SendMessageRequest.kt @@ -0,0 +1,9 @@ +package ru.fromchat.api.schema.messages.publicchat + +import kotlinx.serialization.Serializable + +@Serializable +data class SendMessageRequest( + val content: String, + val reply_to_id: Int? = null +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/SendMessageResponse.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/SendMessageResponse.kt new file mode 100644 index 0000000..68b7977 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/SendMessageResponse.kt @@ -0,0 +1,10 @@ +package ru.fromchat.api.schema.messages.publicchat + +import kotlinx.serialization.Serializable +import ru.fromchat.api.schema.messages.Message + +@Serializable +data class SendMessageResponse( + val status: String, + val message: Message +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/server/RegisteredUserCountResponse.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/server/RegisteredUserCountResponse.kt new file mode 100644 index 0000000..24816cf --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/server/RegisteredUserCountResponse.kt @@ -0,0 +1,8 @@ +package ru.fromchat.api.schema.server + +import kotlinx.serialization.Serializable + +@Serializable +data class RegisteredUserCountResponse( + val count: Int +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/server/ServerInstanceIdResponse.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/server/ServerInstanceIdResponse.kt new file mode 100644 index 0000000..d97bb7e --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/server/ServerInstanceIdResponse.kt @@ -0,0 +1,9 @@ +package ru.fromchat.api.schema.server + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ServerInstanceIdResponse( + @SerialName("instance_id") val instanceId: String, +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/server/TransportKeyResponse.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/server/TransportKeyResponse.kt new file mode 100644 index 0000000..9334b06 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/server/TransportKeyResponse.kt @@ -0,0 +1,11 @@ +package ru.fromchat.api.schema.server + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TransportKeyResponse( + @SerialName("key_id") val keyId: String, + @SerialName("public_key_b64") val publicKeyB64: String, + @SerialName("created_at") val createdAt: Double? = null +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/updates/UpdateItem.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/updates/UpdateItem.kt new file mode 100644 index 0000000..737f74f --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/updates/UpdateItem.kt @@ -0,0 +1,10 @@ +package ru.fromchat.api.schema.updates + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class UpdateItem( + val type: String, + val data: JsonElement? = null +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/updates/UpdatesMessage.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/updates/UpdatesMessage.kt new file mode 100644 index 0000000..2066195 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/updates/UpdatesMessage.kt @@ -0,0 +1,10 @@ +package ru.fromchat.api.schema.updates + +import kotlinx.serialization.Serializable + +@Serializable +data class UpdatesMessage( + val type: String, + val seq: Int, + val updates: List +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/ChangePasswordApiRequest.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/ChangePasswordApiRequest.kt new file mode 100644 index 0000000..2b87e29 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/ChangePasswordApiRequest.kt @@ -0,0 +1,10 @@ +package ru.fromchat.api.schema.user + +import kotlinx.serialization.Serializable + +@Serializable +data class ChangePasswordApiRequest( + val currentPasswordDerived: String, + val newPasswordDerived: String, + val logoutAllExceptCurrent: Boolean = false +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/FcmTokenRequest.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/FcmTokenRequest.kt new file mode 100644 index 0000000..61cd025 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/FcmTokenRequest.kt @@ -0,0 +1,8 @@ +package ru.fromchat.api.schema.user + +import kotlinx.serialization.Serializable + +@Serializable +data class FcmTokenRequest( + val token: String +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/User.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/User.kt new file mode 100644 index 0000000..55e392c --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/User.kt @@ -0,0 +1,20 @@ +package ru.fromchat.api.schema.user + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class User( + val id: Int, + val created_at: String, + val last_seen: String, + val online: Boolean, + val username: String, + @SerialName("display_name") val displayName: String? = null, + val admin: Boolean? = null, + val bio: String? = null, + val profile_picture: String? = null, + val suspended: Boolean? = null, + @SerialName("suspension_reason") val suspensionReason: String? = null +) + diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/auth/CheckAuthResponse.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/auth/CheckAuthResponse.kt new file mode 100644 index 0000000..f4b51ca --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/auth/CheckAuthResponse.kt @@ -0,0 +1,10 @@ +package ru.fromchat.api.schema.user.auth + +import kotlinx.serialization.Serializable + +@Serializable +data class CheckAuthResponse( + val authenticated: Boolean = false, + val username: String? = null, + val admin: Boolean? = null, +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/auth/LoginRequest.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/auth/LoginRequest.kt new file mode 100644 index 0000000..d151d31 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/auth/LoginRequest.kt @@ -0,0 +1,9 @@ +package ru.fromchat.api.schema.user.auth + +import kotlinx.serialization.Serializable + +@Serializable +data class LoginRequest( + val username: String, + val password: String +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/auth/LoginResponse.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/auth/LoginResponse.kt new file mode 100644 index 0000000..fa28ff7 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/auth/LoginResponse.kt @@ -0,0 +1,10 @@ +package ru.fromchat.api.schema.user.auth + +import kotlinx.serialization.Serializable +import ru.fromchat.api.schema.user.User + +@Serializable +data class LoginResponse( + val user: User, + val token: String +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/auth/RegisterRequest.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/auth/RegisterRequest.kt new file mode 100644 index 0000000..08b8367 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/auth/RegisterRequest.kt @@ -0,0 +1,11 @@ +package ru.fromchat.api.schema.user.auth + +import kotlinx.serialization.Serializable + +@Serializable +data class RegisterRequest( + val username: String, + val display_name: String, + val password: String, + val confirm_password: String +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/devices/DeviceSessionInfo.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/devices/DeviceSessionInfo.kt new file mode 100644 index 0000000..6317020 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/devices/DeviceSessionInfo.kt @@ -0,0 +1,21 @@ +package ru.fromchat.api.schema.user.devices + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class DeviceSessionInfo( + @SerialName("session_id") val sessionId: String, + @SerialName("device_name") val deviceName: String? = null, + @SerialName("device_type") val deviceType: String? = null, + @SerialName("os_name") val osName: String? = null, + @SerialName("os_version") val osVersion: String? = null, + @SerialName("browser_name") val browserName: String? = null, + @SerialName("browser_version") val browserVersion: String? = null, + val brand: String? = null, + val model: String? = null, + @SerialName("created_at") val createdAt: String? = null, + @SerialName("last_seen") val lastSeen: String? = null, + val revoked: Boolean? = null, + val current: Boolean = false +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/devices/DevicesListResponse.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/devices/DevicesListResponse.kt new file mode 100644 index 0000000..ae9b076 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/devices/DevicesListResponse.kt @@ -0,0 +1,8 @@ +package ru.fromchat.api.schema.user.devices + +import kotlinx.serialization.Serializable + +@Serializable +data class DevicesListResponse( + val devices: List = emptyList() +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/keys/BackupBlobRequest.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/keys/BackupBlobRequest.kt new file mode 100644 index 0000000..906cf97 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/keys/BackupBlobRequest.kt @@ -0,0 +1,8 @@ +package ru.fromchat.api.schema.user.keys + +import kotlinx.serialization.Serializable + +@Serializable +data class BackupBlobRequest( + val blob: String +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/keys/BackupBlobResponse.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/keys/BackupBlobResponse.kt new file mode 100644 index 0000000..79af637 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/keys/BackupBlobResponse.kt @@ -0,0 +1,8 @@ +package ru.fromchat.api.schema.user.keys + +import kotlinx.serialization.Serializable + +@Serializable +data class BackupBlobResponse( + val blob: String? +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/keys/PublicKeyResponse.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/keys/PublicKeyResponse.kt new file mode 100644 index 0000000..34d86e3 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/keys/PublicKeyResponse.kt @@ -0,0 +1,8 @@ +package ru.fromchat.api.schema.user.keys + +import kotlinx.serialization.Serializable + +@Serializable +data class PublicKeyResponse( + val publicKey: String? = null +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/ProfileDialogData.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/ProfileDialogData.kt new file mode 100644 index 0000000..e047b58 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/ProfileDialogData.kt @@ -0,0 +1,20 @@ +package ru.fromchat.api.schema.user.profile + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ProfileDialogData( + @SerialName("user_id") val userId: Int? = null, + val username: String? = null, + @SerialName("display_name") val displayName: String? = null, + @SerialName("profile_picture") val profilePicture: String? = null, + val bio: String? = null, + @SerialName("member_since") val memberSince: String? = null, + val online: Boolean? = null, + @SerialName("is_own_profile") val isOwnProfile: Boolean = false, + val verified: Boolean? = null, + val suspended: Boolean? = null, + @SerialName("suspension_reason") val suspensionReason: String? = null, + val deleted: Boolean? = null +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/SimilarityResult.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/SimilarityResult.kt new file mode 100644 index 0000000..578b143 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/SimilarityResult.kt @@ -0,0 +1,9 @@ +package ru.fromchat.api.schema.user.profile + +import kotlinx.serialization.Serializable + +@Serializable +data class SimilarityResult( + val isSimilar: Boolean, + val similarTo: String? = null +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/UserProfile.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/UserProfile.kt new file mode 100644 index 0000000..ef64042 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/UserProfile.kt @@ -0,0 +1,25 @@ +package ru.fromchat.api.schema.user.profile + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UserProfile( + val id: Int, + val username: String, + @SerialName("display_name") val displayName: String? = null, + @SerialName("profile_picture") val profilePicture: String? = null, + val bio: String? = null, + val online: Boolean = false, + @SerialName("last_seen") val lastSeen: String? = null, + @SerialName("created_at") val createdAt: String? = null, + val verified: Boolean? = null, + val suspended: Boolean? = null, + @SerialName("suspension_reason") val suspensionReason: String? = null, + val deleted: Boolean? = null, + /** + * Client-only: true when this row was built from public-chat message metadata, not a full + * `/user/...` response. The backend does not send this key. + */ + @SerialName("client_preview_only") val isClientPreviewOnly: Boolean = false +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/VerifyResponse.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/VerifyResponse.kt new file mode 100644 index 0000000..2e384f3 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/VerifyResponse.kt @@ -0,0 +1,8 @@ +package ru.fromchat.api.schema.user.profile + +import kotlinx.serialization.Serializable + +@Serializable +data class VerifyResponse( + val verified: Boolean +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/WebSocketCredentials.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/WebSocketCredentials.kt new file mode 100644 index 0000000..67e666d --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/WebSocketCredentials.kt @@ -0,0 +1,9 @@ +package ru.fromchat.api.schema.websocket + +import kotlinx.serialization.Serializable + +@Serializable +data class WebSocketCredentials( + val scheme: String, + val credentials: String +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/WebSocketError.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/WebSocketError.kt new file mode 100644 index 0000000..8aadc84 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/WebSocketError.kt @@ -0,0 +1,9 @@ +package ru.fromchat.api.schema.websocket + +import kotlinx.serialization.Serializable + +@Serializable +data class WebSocketError( + val code: Int, + val detail: String +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/WebSocketMessage.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/WebSocketMessage.kt new file mode 100644 index 0000000..2b8f1c7 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/WebSocketMessage.kt @@ -0,0 +1,12 @@ +package ru.fromchat.api.schema.websocket + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class WebSocketMessage( + val type: String, + val credentials: WebSocketCredentials? = null, + val data: JsonElement? = null, + val error: WebSocketError? = null +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/requests/GetUpdatesRequest.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/requests/GetUpdatesRequest.kt new file mode 100644 index 0000000..6f82414 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/requests/GetUpdatesRequest.kt @@ -0,0 +1,9 @@ +package ru.fromchat.api.schema.websocket.requests + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class GetUpdatesRequest( + @SerialName("lastSeq") val lastSeq: Int +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/requests/GetUpdatesResponse.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/requests/GetUpdatesResponse.kt new file mode 100644 index 0000000..68f2d20 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/requests/GetUpdatesResponse.kt @@ -0,0 +1,11 @@ +package ru.fromchat.api.schema.websocket.requests + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class GetUpdatesResponse( + val status: String, + @SerialName("lastSeq") val lastSeq: Int, + @SerialName("missedCount") val missedCount: Int +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/requests/WebSocketDeleteDmRequest.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/requests/WebSocketDeleteDmRequest.kt new file mode 100644 index 0000000..f2502a8 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/requests/WebSocketDeleteDmRequest.kt @@ -0,0 +1,10 @@ +package ru.fromchat.api.schema.websocket.requests + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class WebSocketDeleteDmRequest( + val id: Int, + @SerialName("recipientId") val recipientId: Int, +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/requests/WebSocketDeleteMessageRequest.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/requests/WebSocketDeleteMessageRequest.kt new file mode 100644 index 0000000..39970c2 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/requests/WebSocketDeleteMessageRequest.kt @@ -0,0 +1,8 @@ +package ru.fromchat.api.schema.websocket.requests + +import kotlinx.serialization.Serializable + +@Serializable +data class WebSocketDeleteMessageRequest( + val message_id: Int +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/requests/WebSocketEditMessageRequest.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/requests/WebSocketEditMessageRequest.kt new file mode 100644 index 0000000..4282b04 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/requests/WebSocketEditMessageRequest.kt @@ -0,0 +1,9 @@ +package ru.fromchat.api.schema.websocket.requests + +import kotlinx.serialization.Serializable + +@Serializable +data class WebSocketEditMessageRequest( + val message_id: Int, + val content: String +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/requests/WebSocketSendMessageRequest.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/requests/WebSocketSendMessageRequest.kt new file mode 100644 index 0000000..894c705 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/requests/WebSocketSendMessageRequest.kt @@ -0,0 +1,10 @@ +package ru.fromchat.api.schema.websocket.requests + +import kotlinx.serialization.Serializable + +@Serializable +data class WebSocketSendMessageRequest( + val content: String, + val reply_to_id: Int? = null, + val client_message_id: String? = null +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/DmDeletedData.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/DmDeletedData.kt new file mode 100644 index 0000000..c3cc581 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/DmDeletedData.kt @@ -0,0 +1,11 @@ +package ru.fromchat.api.schema.websocket.types + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class DmDeletedData( + val id: Int, + @SerialName("senderId") val senderId: Int, + @SerialName("recipientId") val recipientId: Int? = null, +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/DmTypingData.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/DmTypingData.kt new file mode 100644 index 0000000..8b67a8c --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/DmTypingData.kt @@ -0,0 +1,9 @@ +package ru.fromchat.api.schema.websocket.types + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class DmTypingData( + @SerialName("recipientId") val recipientId: Int +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/MessageDeletedData.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/MessageDeletedData.kt new file mode 100644 index 0000000..60db2eb --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/MessageDeletedData.kt @@ -0,0 +1,8 @@ +package ru.fromchat.api.schema.websocket.types + +import kotlinx.serialization.Serializable + +@Serializable +data class MessageDeletedData( + val message_id: Int +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/MessageEditedData.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/MessageEditedData.kt new file mode 100644 index 0000000..303d56d --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/MessageEditedData.kt @@ -0,0 +1,9 @@ +package ru.fromchat.api.schema.websocket.types + +import kotlinx.serialization.Serializable +import ru.fromchat.api.schema.messages.Message + +@Serializable +data class MessageEditedData( + val message: Message +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/NewMessageData.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/NewMessageData.kt new file mode 100644 index 0000000..52aaa14 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/NewMessageData.kt @@ -0,0 +1,9 @@ +package ru.fromchat.api.schema.websocket.types + +import kotlinx.serialization.Serializable +import ru.fromchat.api.schema.messages.Message + +@Serializable +data class NewMessageData( + val message: Message +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/ReactionData.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/ReactionData.kt new file mode 100644 index 0000000..226f00c --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/ReactionData.kt @@ -0,0 +1,11 @@ +package ru.fromchat.api.schema.websocket.types + +import kotlinx.serialization.Serializable +import ru.fromchat.api.schema.messages.ReactionUser + +@Serializable +data class ReactionData( + val emoji: String, + val count: Int, + val users: List +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/ReactionUpdateData.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/ReactionUpdateData.kt new file mode 100644 index 0000000..7bfd321 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/ReactionUpdateData.kt @@ -0,0 +1,13 @@ +package ru.fromchat.api.schema.websocket.types + +import kotlinx.serialization.Serializable + +@Serializable +data class ReactionUpdateData( + val message_id: Int, + val emoji: String, + val action: String, + val user_id: Int, + val username: String, + val reactions: List +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/SubscribeStatusData.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/SubscribeStatusData.kt new file mode 100644 index 0000000..b2e155d --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/SubscribeStatusData.kt @@ -0,0 +1,9 @@ +package ru.fromchat.api.schema.websocket.types + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SubscribeStatusData( + @SerialName("userId") val userId: Int +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/TypingData.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/TypingData.kt new file mode 100644 index 0000000..340aed7 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/TypingData.kt @@ -0,0 +1,9 @@ +package ru.fromchat.api.schema.websocket.types + +import kotlinx.serialization.Serializable + +@Serializable +data class TypingData( + val userId: Int, + val username: String +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/TypingUpdateData.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/TypingUpdateData.kt new file mode 100644 index 0000000..3419b0c --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/TypingUpdateData.kt @@ -0,0 +1,9 @@ +package ru.fromchat.api.schema.websocket.types + +import kotlinx.serialization.Serializable + +@Serializable +data class TypingUpdateData( + val userId: Int, + val username: String +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/WebSocketUpdatesData.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/WebSocketUpdatesData.kt new file mode 100644 index 0000000..62a461d --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/WebSocketUpdatesData.kt @@ -0,0 +1,10 @@ +package ru.fromchat.api.schema.websocket.types + +import kotlinx.serialization.Serializable +import ru.fromchat.api.schema.websocket.WebSocketMessage + +@Serializable +data class WebSocketUpdatesData( + val seq: Int, + val updates: List +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/config/Config.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/config/ServerConfig.kt similarity index 81% rename from app/shared/src/commonMain/kotlin/ru/fromchat/core/config/Config.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/config/ServerConfig.kt index f8b6e82..f426412 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/core/config/Config.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/config/ServerConfig.kt @@ -1,15 +1,10 @@ -package ru.fromchat.core.config +package ru.fromchat.config import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import ru.fromchat.core.ServerConfigData -import ru.fromchat.core.Settings -/** - * Application configuration - */ -object Config { +object ServerConfig { private val _serverConfig = MutableStateFlow(null) val serverConfig: StateFlow = _serverConfig.asStateFlow() @@ -39,7 +34,7 @@ object Config { val callsEnabled: Boolean get() = config.callsEnabled - /** Calls port used for LiveKit WS / reachability (defaults to [ru.fromchat.core.DEFAULT_CALLS_PORT]). */ + /** Calls port used for LiveKit WS / reachability (defaults to [DEFAULT_CALLS_PORT]). */ val callsPort: Int get() = config.callsPort @@ -81,3 +76,18 @@ object Config { return "$scheme://${config.serverIp}:${config.apiPort}/api/chat/ws" } } + +/** Default WebRTC / calls port when none is stored or the field is left empty in UI. */ +const val DEFAULT_CALLS_PORT = 8303 + +/** + * Server configuration: [serverIp], HTTP(S) [apiPort], [callsPort] for WebRTC / HAProxy (defaults to [DEFAULT_CALLS_PORT]). + * [callsEnabled] is updated when saving server settings (probe on the calls port); defaults to true when unset. + */ +data class ServerConfigData( + val serverIp: String, + val apiPort: Int, + val callsPort: Int, + val httpsEnabled: Boolean, + val callsEnabled: Boolean = true, +) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/Settings.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/config/Settings.kt similarity index 91% rename from app/shared/src/commonMain/kotlin/ru/fromchat/core/Settings.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/config/Settings.kt index 04cc448..453b601 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/core/Settings.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/config/Settings.kt @@ -1,6 +1,5 @@ -package ru.fromchat.core +package ru.fromchat.config -import com.pr0gramm3r101.utils.settings.Settings as PlatformSettings import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -9,23 +8,11 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.json.Json -import ru.fromchat.api.DeviceSessionInfo +import ru.fromchat.api.schema.user.devices.DeviceSessionInfo +import ru.fromchat.config.Settings.API_PORT_KEY +import ru.fromchat.config.Settings.SERVER_IP_KEY import ru.fromchat.ui.Theme - -/** Default WebRTC / calls port when none is stored or the field is left empty in UI. */ -const val DEFAULT_CALLS_PORT = 8303 - -/** - * Server configuration: [serverIp], HTTP(S) [apiPort], [callsPort] for WebRTC / HAProxy (defaults to [DEFAULT_CALLS_PORT]). - * [callsEnabled] is updated when saving server settings (probe on the calls port); defaults to true when unset. - */ -data class ServerConfigData( - val serverIp: String, - val apiPort: Int, - val callsPort: Int, - val httpsEnabled: Boolean, - val callsEnabled: Boolean = true, -) +import com.pr0gramm3r101.utils.settings.Settings as PlatformSettings object Settings { private const val MATERIAL_YOU_KEY = "materialYou" diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/debug/DebugLogger.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/debug/DebugLogger.kt deleted file mode 100644 index 7671ef4..0000000 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/debug/DebugLogger.kt +++ /dev/null @@ -1,16 +0,0 @@ -package ru.fromchat.debug - -data class DebugLogPayload( - val sessionId: String = "9be525", - val runId: String, - val hypothesisId: String, - val location: String, - val message: String, - val data: String, - val timestamp: Long, -) - -expect object DebugLogger { - fun log(payload: DebugLogPayload) -} - diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/platform/CurrentDeviceInfo.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/platform/CurrentDeviceInfo.kt deleted file mode 100644 index 24fedba..0000000 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/platform/CurrentDeviceInfo.kt +++ /dev/null @@ -1,12 +0,0 @@ -package ru.fromchat.platform - -data class CurrentDeviceInfo( - val osName: String? = null, - val osVersion: String? = null, - val deviceType: String? = null, - val deviceName: String? = null, - val brand: String? = null, - val model: String? = null -) - -expect fun currentDeviceInfo(): CurrentDeviceInfo diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/platform/OpenAppNotificationSettings.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/platform/OpenAppNotificationSettings.kt deleted file mode 100644 index 0dc1c05..0000000 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/platform/OpenAppNotificationSettings.kt +++ /dev/null @@ -1,12 +0,0 @@ -package ru.fromchat.platform - -/** - * Opens the system screen where the user can change notification permission and channels for this app. - * @return true if an intent/URL was fired (best effort). - */ -expect fun openAppNotificationSettings(): Boolean - -/** - * Returns true when notifications are currently enabled for this app, including runtime permission. - */ -expect fun areAppNotificationsEnabled(): Boolean diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt index 5b443f6..895803f 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -25,11 +25,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.unit.IntOffset import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.navigation.NavBackStackEntry import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder @@ -41,6 +39,8 @@ import androidx.navigation.navArgument import coil3.ImageLoader import coil3.compose.setSingletonImageLoaderFactory import coil3.svg.SvgDecoder +import com.pr0gramm3r101.utils.LocalSystemBarsVisibility +import com.pr0gramm3r101.utils.rememberSystemBarsController import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch @@ -52,43 +52,42 @@ import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import ru.fromchat.AppForeground +import ru.fromchat.Logger import ru.fromchat.api.ApiClient -import ru.fromchat.api.AttachmentDownloadNotifier -import ru.fromchat.api.ProfileCache +import ru.fromchat.api.DeferredStartupNetwork import ru.fromchat.api.UpdateSyncManager -import ru.fromchat.api.UserStatusStore -import ru.fromchat.api.WebSocketManager -import ru.fromchat.api.WebSocketMessage -import ru.fromchat.api.WebSocketUpdatesData -import ru.fromchat.api.outbox.OutgoingMessageCoordinator -import ru.fromchat.api.outbox.scheduleOutboxProcessing -import ru.fromchat.core.cache.CacheContext -import ru.fromchat.core.Logger -import ru.fromchat.core.cache.ensureFromChatCacheGeneration -import ru.fromchat.core.config.Config -import ru.fromchat.net.NetworkConnectivity +import ru.fromchat.api.calls.CallStore +import ru.fromchat.api.instance.bootstrapSessionOnStartup +import ru.fromchat.api.instance.logoutIfInstanceUnsupported +import ru.fromchat.api.local.WebSocketManager +import ru.fromchat.api.local.cache.CacheContext +import ru.fromchat.api.local.cache.ensureFromChatCacheGeneration +import ru.fromchat.api.local.db.store.ProfileCache +import ru.fromchat.api.local.db.store.UserStatusStore +import ru.fromchat.api.local.send.OutgoingMessageCoordinator +import ru.fromchat.api.local.send.scheduleOutboxProcessing +import ru.fromchat.api.schema.websocket.WebSocketMessage +import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData +import ru.fromchat.config.ServerConfig import ru.fromchat.ui.auth.LoginScreen import ru.fromchat.ui.auth.RegisterScreen -import ru.fromchat.ui.chat.PublicChatScreen -import ru.fromchat.ui.debug.DebugApiScreen -import ru.fromchat.ui.debug.DebugSearchBarDocsScreen -import ru.fromchat.ui.dm.DmChatRoute -import ru.fromchat.ui.dm.DmNav -import ru.fromchat.ui.dm.DmProfileRoute -import ru.fromchat.ui.main.ChatsSearchScreen +import ru.fromchat.ui.calls.CallOverlay +import ru.fromchat.ui.chat.panels.dm.DmChatRoute +import ru.fromchat.ui.chat.panels.dm.DmNav +import ru.fromchat.ui.chat.panels.dm.DmProfileRoute +import ru.fromchat.ui.chat.panels.publicchat.PublicChatScreen import ru.fromchat.ui.main.MainScreen -import ru.fromchat.ui.main.settings.SettingsAccountScreen -import ru.fromchat.ui.main.settings.SettingsAppearanceScreen -import ru.fromchat.ui.main.settings.SettingsDevicesScreen -import ru.fromchat.ui.main.settings.SettingsNotificationsScreen +import ru.fromchat.ui.main.chats.ChatsSearchScreen +import ru.fromchat.ui.main.settings.AboutScreen +import ru.fromchat.ui.main.settings.AppearanceScreen +import ru.fromchat.ui.main.settings.DevicesScreen +import ru.fromchat.ui.main.settings.NotificationsScreen import ru.fromchat.ui.main.settings.SettingsRoutes -import ru.fromchat.ui.main.settings.SettingsSecurityHubScreen -import ru.fromchat.ui.main.settings.SettingsSecurityPasswordFlowScreen -import ru.fromchat.ui.main.settings.SettingsServerToolsScreen +import ru.fromchat.ui.main.settings.account.AccountScreen +import ru.fromchat.ui.main.settings.account.SettingsSecurityPasswordFlowScreen +import ru.fromchat.ui.main.settings.server.ServerConfigScreen import ru.fromchat.ui.profile.ProfileScreen -import ru.fromchat.calls.CallOverlay -import ru.fromchat.calls.CallStore -import ru.fromchat.ui.setup.ServerConfigScreen +import ru.fromchat.utils.NetworkConnectivity val LocalNavController = compositionLocalOf { error("NavController not provided") } @@ -195,7 +194,7 @@ fun App( LaunchedEffect(Unit) { kotlinx.coroutines.withContext(Dispatchers.Default) { - runCatching { Config.initialize() } + runCatching { ServerConfig.initialize() } runCatching { ensureFromChatCacheGeneration() } runCatching { NetworkConnectivity.ensureStarted() } runCatching { ApiClient.loadPersistedData() } @@ -213,13 +212,13 @@ fun App( UpdateSyncManager.initializeFromStorage(ApiClient.user?.id) } - ru.fromchat.core.DeferredStartupNetwork.scheduleAfterUiVisible() + DeferredStartupNetwork.scheduleAfterUiVisible() if (!hasToken) return@LaunchedEffect launch(Dispatchers.Default) { runCatching { - ru.fromchat.core.instance.bootstrapSessionOnStartup( + bootstrapSessionOnStartup( hasToken = true, onLogoutRequired = { sessionLogoutRequired = true }, ) @@ -233,7 +232,7 @@ fun App( LaunchedEffect(sessionLogoutRequired) { if (!sessionLogoutRequired) return@LaunchedEffect - ru.fromchat.core.instance.logoutIfInstanceUnsupported() + logoutIfInstanceUnsupported() startDestination = "login" sessionLogoutRequired = false } @@ -256,7 +255,7 @@ fun App( val instanceId = CacheContext.activeInstanceId.value.trim() if (instanceId.isNotEmpty()) { scheduleOutboxProcessing(instanceId) - kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Default) { + kotlinx.coroutines.withContext(Dispatchers.Default) { OutgoingMessageCoordinator.drainOutboxForInstance(instanceId) } } @@ -389,255 +388,238 @@ fun App( ) } ) { - composable("serverConfig") { - ServerConfigScreen() - } + composable("serverConfig") { + ServerConfigScreen() + } - composable("login") { - LoginScreen( - onLoginSuccess = { - WebSocketManager.connect(forceRestart = true) - navController.navigate("chat") { - popUpTo("login") { inclusive = true } + composable("login") { + LoginScreen( + onLoginSuccess = { + WebSocketManager.connect(forceRestart = true) + navController.navigate("chat") { + popUpTo("login") { inclusive = true } + } + }, + onNavigateToRegister = { navController.navigate("register") } + ) + } + + composable("register") { + RegisterScreen( + onRegistered = { + navController.navigate("chat") { + popUpTo("login") { inclusive = true } + } + } + ) + } + + composable("chat") { + MainScreen( + sharedTransitionScope = this@SharedTransitionLayout, + animatedVisibilityScope = this, + snackbarHostState = profileLookupSnackbarHostState + ) + } + + composable("chats/publicChat") { + PublicChatScreen( + scrollToMessageId = scrollToMessageId, + sharedTransitionScope = this@SharedTransitionLayout, + animatedContentScope = this@composable + ) + } + + val searchScreenFade = tween(260) + composable( + route = "search/conversations", + enterTransition = { fadeIn(animationSpec = searchScreenFade) }, + exitTransition = { fadeOut(animationSpec = searchScreenFade) }, + popEnterTransition = { fadeIn(animationSpec = searchScreenFade) }, + popExitTransition = { fadeOut(animationSpec = searchScreenFade) } + ) { + ChatsSearchScreen( + onBack = { navController.popBackStack() }, + sharedTransitionScope = this@SharedTransitionLayout, + animatedVisibilityScope = this, + onOpenProfile = { userId: Int -> + if (userId != 0) { + navController.navigate("profile/$userId") + } + }, + onOpenConversation = { userId: Int -> + if (userId != 0) { + navController.navigate(DmNav.chatRoute(userId)) + } + } + ) + } + + composable( + route = "profile/{userId}?fromDeepLink={fromDeepLink}&useSharedElement={useSharedElement}&sourceMessageId={sourceMessageId}", + arguments = listOf( + navArgument("userId") { type = NavType.StringType }, + navArgument("fromDeepLink") { + type = NavType.BoolType + defaultValue = false + }, + navArgument("useSharedElement") { + type = NavType.StringType + defaultValue = "false" + }, + navArgument("sourceMessageId") { + type = NavType.StringType + defaultValue = "-1" + } + ) + ) { backStackEntry -> + val args = backStackEntry.savedStateHandle + val userIdParam = args.get("userId") + val parsedUserId = userIdParam?.toIntOrNull() + val userId = if ((parsedUserId ?: 0) > 0) parsedUserId else null + val profileUsername = if (userId == null) { + userIdParam?.trim()?.takeIf { it.isNotBlank() } + } else { + null + } + val useSharedElement = when (val rawUseSharedElement = args.get("useSharedElement")) { + is Boolean -> rawUseSharedElement + is String -> rawUseSharedElement == "true" + else -> false + } + val sourceMessageId = when (val rawSourceMessageId = args.get("sourceMessageId")) { + is Int -> rawSourceMessageId + is String -> rawSourceMessageId.toIntOrNull() ?: -1 + is Long -> rawSourceMessageId.toInt() + else -> -1 + } + val fromDeepLink = when (val rawFromDeepLink = args.get("fromDeepLink")) { + is Boolean -> rawFromDeepLink + is String -> rawFromDeepLink == "true" + else -> false + } + + Logger.d( + "ProfileRoute", + "profile entry args: rawUserId=$userIdParam parsedUserId=$parsedUserId resolvedUserId=$userId " + + "resolvedUsername=$profileUsername sourceMessageId=$sourceMessageId fromDeepLink=$fromDeepLink " + + "useSharedElement=$useSharedElement currentRoute=${backStackEntry.destination.route}" + ) + + ProfileScreen( + userId = userId, + username = profileUsername, + onBack = { navController.navigateUp() }, + onChat = { navController.navigate(DmNav.chatRoute(it)) }, + sharedTransitionScope = this@SharedTransitionLayout, + animatedVisibilityScope = this@composable, + useSharedElementFromNavigation = useSharedElement, + sharedSourceMessageId = sourceMessageId, + showErrorAsToast = fromDeepLink + ) + } + + val dmChatProfileFade = tween(durationMillis = 280) + + composable( + route = DmNav.CHAT_ROUTE, + arguments = listOf( + navArgument("otherUserId") { type = NavType.StringType }, + navArgument("sourceMessageId") { type = NavType.IntType; defaultValue = -1 }, + ), + enterTransition = { + when (initialState.destination.route) { + DmNav.PROFILE_ROUTE -> fadeIn(animationSpec = dmChatProfileFade) + else -> slideIntoContainer(Start, animationSpec = rootNavMotion) } }, - onNavigateToRegister = { navController.navigate("register") } - ) - } - - composable("register") { - RegisterScreen( - onRegistered = { - navController.navigate("chat") { - popUpTo("login") { inclusive = true } - } - } - ) - } - - composable("chat") { - MainScreen( - sharedTransitionScope = this@SharedTransitionLayout, - animatedVisibilityScope = this, - snackbarHostState = profileLookupSnackbarHostState - ) - } - - composable("chats/publicChat") { - PublicChatScreen( - scrollToMessageId = scrollToMessageId, - sharedTransitionScope = this@SharedTransitionLayout, - animatedContentScope = this@composable - ) - } - - val searchScreenFade = tween(260) - composable( - route = "search/conversations", - enterTransition = { fadeIn(animationSpec = searchScreenFade) }, - exitTransition = { fadeOut(animationSpec = searchScreenFade) }, - popEnterTransition = { fadeIn(animationSpec = searchScreenFade) }, - popExitTransition = { fadeOut(animationSpec = searchScreenFade) } - ) { - ChatsSearchScreen( - onBack = { navController.popBackStack() }, - sharedTransitionScope = this@SharedTransitionLayout, - animatedVisibilityScope = this, - onOpenProfile = { userId: Int -> - if (userId != 0) { - navController.navigate("profile/$userId") + exitTransition = { + when (targetState.destination.route) { + DmNav.PROFILE_ROUTE -> fadeOut(animationSpec = dmChatProfileFade) + else -> slideOutOfContainer(Start, animationSpec = rootNavMotion) } }, - onOpenConversation = { userId: Int -> - if (userId != 0) { - navController.navigate(DmNav.chatRoute(userId)) + popEnterTransition = { + when (initialState.destination.route) { + DmNav.PROFILE_ROUTE -> fadeIn(animationSpec = dmChatProfileFade) + else -> slideIntoContainer(End, animationSpec = rootNavMotion) } - } - ) - } - - composable("debug") { - DebugApiScreen() - } - composable("debug/searchbar-docs") { - DebugSearchBarDocsScreen() - } - - composable( - route = "profile/{userId}?fromDeepLink={fromDeepLink}&useSharedElement={useSharedElement}&sourceMessageId={sourceMessageId}", - arguments = listOf( - navArgument("userId") { type = NavType.StringType }, - navArgument("fromDeepLink") { - type = NavType.BoolType - defaultValue = false }, - navArgument("useSharedElement") { - type = NavType.StringType - defaultValue = "false" + popExitTransition = { + when (targetState.destination.route) { + DmNav.PROFILE_ROUTE -> fadeOut(animationSpec = dmChatProfileFade) + else -> slideOutOfContainer(End, animationSpec = rootNavMotion) + } }, - navArgument("sourceMessageId") { - type = NavType.StringType - defaultValue = "-1" - } - ) - ) { backStackEntry -> - val args = backStackEntry.savedStateHandle - val userIdParam = args.get("userId") - val parsedUserId = userIdParam?.toIntOrNull() - val userId = if ((parsedUserId ?: 0) > 0) parsedUserId else null - val profileUsername = if (userId == null) { - userIdParam?.trim()?.takeIf { it.isNotBlank() } - } else { - null - } - val useSharedElement = when (val rawUseSharedElement = args.get("useSharedElement")) { - is Boolean -> rawUseSharedElement - is String -> rawUseSharedElement == "true" - else -> false - } - val sourceMessageId = when (val rawSourceMessageId = args.get("sourceMessageId")) { - is Int -> rawSourceMessageId - is String -> rawSourceMessageId.toIntOrNull() ?: -1 - is Long -> rawSourceMessageId.toInt() - else -> -1 - } - val fromDeepLink = when (val rawFromDeepLink = args.get("fromDeepLink")) { - is Boolean -> rawFromDeepLink - is String -> rawFromDeepLink == "true" - else -> false + ) { entry -> + val otherUserId = entry.savedStateHandle.get("otherUserId")?.toIntOrNull() ?: 0 + val sourceMessageId = entry.savedStateHandle.get("sourceMessageId") ?: -1 + if (otherUserId <= 0) return@composable + DmChatRoute( + otherUserId = otherUserId, + scrollToMessageId = if (sourceMessageId > 0) sourceMessageId else null, + navController = navController, + sharedTransitionScope = this@SharedTransitionLayout, + animatedVisibilityScope = this, + ) } - Logger.d( - "ProfileRoute", - "profile entry args: rawUserId=$userIdParam parsedUserId=$parsedUserId resolvedUserId=$userId " + - "resolvedUsername=$profileUsername sourceMessageId=$sourceMessageId fromDeepLink=$fromDeepLink " + - "useSharedElement=$useSharedElement currentRoute=${backStackEntry.destination.route}" - ) + composable( + route = DmNav.PROFILE_ROUTE, + arguments = listOf(navArgument("otherUserId") { type = NavType.StringType }), + enterTransition = { fadeIn(animationSpec = dmChatProfileFade) }, + exitTransition = { fadeOut(animationSpec = dmChatProfileFade) }, + popEnterTransition = { fadeIn(animationSpec = dmChatProfileFade) }, + popExitTransition = { fadeOut(animationSpec = dmChatProfileFade) }, + ) { entry -> + val otherUserId = entry.savedStateHandle.get("otherUserId")?.toIntOrNull() ?: 0 + if (otherUserId <= 0) return@composable + DmProfileRoute( + otherUserId = otherUserId, + navController = navController, + sharedTransitionScope = this@SharedTransitionLayout, + animatedVisibilityScope = this, + ) + } - ProfileScreen( - userId = userId, - username = profileUsername, - onBack = { navController.navigateUp() }, - onChat = { navController.navigate(DmNav.chatRoute(it)) }, - sharedTransitionScope = this@SharedTransitionLayout, - animatedVisibilityScope = this@composable, - useSharedElementFromNavigation = useSharedElement, - sharedSourceMessageId = sourceMessageId, - showErrorAsToast = fromDeepLink - ) - } + settingsSlideComposable("about", rootNavMotion) { + AboutScreen() + } - val dmChatProfileFade = tween(durationMillis = 280) + settingsSlideComposable(SettingsRoutes.Appearance, rootNavMotion) { + AppearanceScreen(onBack = { navController.navigateUp() }) + } - composable( - route = DmNav.CHAT_ROUTE, - arguments = listOf( - navArgument("otherUserId") { type = NavType.StringType }, - navArgument("sourceMessageId") { type = NavType.IntType; defaultValue = -1 }, - ), - enterTransition = { - when (initialState.destination.route) { - DmNav.PROFILE_ROUTE -> fadeIn(animationSpec = dmChatProfileFade) - else -> slideIntoContainer(Start, animationSpec = rootNavMotion) - } - }, - exitTransition = { - when (targetState.destination.route) { - DmNav.PROFILE_ROUTE -> fadeOut(animationSpec = dmChatProfileFade) - else -> slideOutOfContainer(Start, animationSpec = rootNavMotion) - } - }, - popEnterTransition = { - when (initialState.destination.route) { - DmNav.PROFILE_ROUTE -> fadeIn(animationSpec = dmChatProfileFade) - else -> slideIntoContainer(End, animationSpec = rootNavMotion) - } - }, - popExitTransition = { - when (targetState.destination.route) { - DmNav.PROFILE_ROUTE -> fadeOut(animationSpec = dmChatProfileFade) - else -> slideOutOfContainer(End, animationSpec = rootNavMotion) - } - }, - ) { entry -> - val otherUserId = entry.savedStateHandle.get("otherUserId")?.toIntOrNull() ?: 0 - val sourceMessageId = entry.savedStateHandle.get("sourceMessageId") ?: -1 - if (otherUserId <= 0) return@composable - DmChatRoute( - otherUserId = otherUserId, - scrollToMessageId = if (sourceMessageId > 0) sourceMessageId else null, - navController = navController, - sharedTransitionScope = this@SharedTransitionLayout, - animatedVisibilityScope = this, - ) - } + settingsSlideComposable(SettingsRoutes.Notifications, rootNavMotion) { + NotificationsScreen(onBack = { navController.navigateUp() }) + } - composable( - route = DmNav.PROFILE_ROUTE, - arguments = listOf(navArgument("otherUserId") { type = NavType.StringType }), - enterTransition = { fadeIn(animationSpec = dmChatProfileFade) }, - exitTransition = { fadeOut(animationSpec = dmChatProfileFade) }, - popEnterTransition = { fadeIn(animationSpec = dmChatProfileFade) }, - popExitTransition = { fadeOut(animationSpec = dmChatProfileFade) }, - ) { entry -> - val otherUserId = entry.savedStateHandle.get("otherUserId")?.toIntOrNull() ?: 0 - if (otherUserId <= 0) return@composable - DmProfileRoute( - otherUserId = otherUserId, - navController = navController, - sharedTransitionScope = this@SharedTransitionLayout, - animatedVisibilityScope = this, - ) - } + settingsSlideComposable(SettingsRoutes.Devices, rootNavMotion) { + DevicesScreen(onBack = { navController.navigateUp() }) + } - settingsSlideComposable("about", rootNavMotion) { - AboutScreen() - } + settingsSlideComposable(SettingsRoutes.SecurityPasswordFlow, rootNavMotion) { + SettingsSecurityPasswordFlowScreen( + onBack = { navController.navigateUp() }, + onDonePopToHub = { + navController.popBackStack() + } + ) + } - val navigateToLoginClearingChat = { - navController.navigate("login") { - popUpTo("chat") { inclusive = true } + settingsSlideComposable(SettingsRoutes.Account, rootNavMotion) { + AccountScreen( + onBack = { navController.navigateUp() }, + onLogout = { + navController.navigate("login") { + popUpTo("chat") { inclusive = true } + } + }, + onChangePassword = { navController.navigate(SettingsRoutes.SecurityPasswordFlow) } + ) } } - settingsSlideComposable(SettingsRoutes.Appearance, rootNavMotion) { - SettingsAppearanceScreen(onBack = { navController.navigateUp() }) - } - settingsSlideComposable(SettingsRoutes.ServerTools, rootNavMotion) { - SettingsServerToolsScreen( - onBack = { navController.navigateUp() }, - outerNav = navController - ) - } - settingsSlideComposable(SettingsRoutes.Notifications, rootNavMotion) { - SettingsNotificationsScreen(onBack = { navController.navigateUp() }) - } - settingsSlideComposable(SettingsRoutes.Devices, rootNavMotion) { - SettingsDevicesScreen(onBack = { navController.navigateUp() }) - } - settingsSlideComposable(SettingsRoutes.Security, rootNavMotion) { - SettingsSecurityHubScreen( - onBack = { navController.navigateUp() }, - onChangePassword = { navController.navigate(SettingsRoutes.SecurityPasswordFlow) } - ) - } - settingsSlideComposable(SettingsRoutes.SecurityPasswordFlow, rootNavMotion) { - SettingsSecurityPasswordFlowScreen( - onBack = { navController.navigateUp() }, - onDonePopToHub = { - navController.popBackStack() - } - ) - } - settingsSlideComposable(SettingsRoutes.Account, rootNavMotion) { - SettingsAccountScreen( - onBack = { navController.navigateUp() }, - onLogout = navigateToLoginClearingChat, - onChangePassword = { navController.navigate(SettingsRoutes.SecurityPasswordFlow) } - ) - } - } - // Register only after NavHost has attached a graph; otherwise 401 during startup/call can crash. DisposableEffect(navController) { ApiClient.onAuthError = { Logger.d("App", "Global auth error handler triggered, navigating to login") @@ -653,6 +635,7 @@ fun App( ApiClient.onAuthError = null } } + CallOverlay(Modifier.fillMaxSize()) } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/BackHandler.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/BackHandler.kt deleted file mode 100644 index 91eaa79..0000000 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/BackHandler.kt +++ /dev/null @@ -1,14 +0,0 @@ -package ru.fromchat.ui - -import androidx.compose.runtime.Composable - -/** - * Handles system back when [enabled]. When enabled, [onBack] is invoked instead of default back. - * On Android: uses BackHandler (supports predictive back when app opts in). - * On iOS: no-op. - */ -@Composable -expect fun BackHandler(enabled: Boolean, onBack: () -> Unit) - -@Composable -expect fun rememberHapticFeedbackInternal(): (Int) -> Unit diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/GoogleSansTypography.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/GoogleSansTypography.kt deleted file mode 100644 index c3dcb51..0000000 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/GoogleSansTypography.kt +++ /dev/null @@ -1,56 +0,0 @@ -package ru.fromchat.ui - -import androidx.compose.material3.Typography -import androidx.compose.runtime.Composable -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight -import org.jetbrains.compose.resources.Font -import ru.fromchat.Res -import ru.fromchat.google_sans_bold -import ru.fromchat.google_sans_bold_italic -import ru.fromchat.google_sans_italic -import ru.fromchat.google_sans_medium -import ru.fromchat.google_sans_medium_italic -import ru.fromchat.google_sans_regular -import ru.fromchat.google_sans_semibold -import ru.fromchat.google_sans_semibold_italic - -/** - * Google Sans (static TTFs from Downloads bundle) for app-wide [MaterialTheme.typography]. - * The chats header brand title keeps its own Montserrat stack in [ru.fromchat.ui.branding.FromChatBrandTitle]. - */ -@Composable -fun googleSansFontFamily(): FontFamily = FontFamily( - Font(Res.font.google_sans_regular, FontWeight.Normal, FontStyle.Normal), - Font(Res.font.google_sans_italic, FontWeight.Normal, FontStyle.Italic), - Font(Res.font.google_sans_medium, FontWeight.Medium, FontStyle.Normal), - Font(Res.font.google_sans_medium_italic, FontWeight.Medium, FontStyle.Italic), - Font(Res.font.google_sans_semibold, FontWeight.SemiBold, FontStyle.Normal), - Font(Res.font.google_sans_semibold_italic, FontWeight.SemiBold, FontStyle.Italic), - Font(Res.font.google_sans_bold, FontWeight.Bold, FontStyle.Normal), - Font(Res.font.google_sans_bold_italic, FontWeight.Bold, FontStyle.Italic), -) - -@Composable -fun googleSansMaterialTypography(): Typography { - val family = googleSansFontFamily() - val base = Typography() - return base.copy( - displayLarge = base.displayLarge.copy(fontFamily = family), - displayMedium = base.displayMedium.copy(fontFamily = family), - displaySmall = base.displaySmall.copy(fontFamily = family), - headlineLarge = base.headlineLarge.copy(fontFamily = family), - headlineMedium = base.headlineMedium.copy(fontFamily = family), - headlineSmall = base.headlineSmall.copy(fontFamily = family), - titleLarge = base.titleLarge.copy(fontFamily = family), - titleMedium = base.titleMedium.copy(fontFamily = family), - titleSmall = base.titleSmall.copy(fontFamily = family), - bodyLarge = base.bodyLarge.copy(fontFamily = family), - bodyMedium = base.bodyMedium.copy(fontFamily = family), - bodySmall = base.bodySmall.copy(fontFamily = family), - labelLarge = base.labelLarge.copy(fontFamily = family), - labelMedium = base.labelMedium.copy(fontFamily = family), - labelSmall = base.labelSmall.copy(fontFamily = family), - ) -} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/ImeScrollWithKeyboard.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/ImeScrollWithKeyboard.kt deleted file mode 100644 index ca3965e..0000000 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/ImeScrollWithKeyboard.kt +++ /dev/null @@ -1,9 +0,0 @@ -package ru.fromchat.ui - -import androidx.compose.ui.Modifier - -/** - * Android: ties scroll to IME insets for smoother keyboard transitions (see Compose keyboard animations). - * Other platforms: no-op. - */ -expect fun Modifier.imeScrollWithKeyboard(): Modifier diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/NotificationUtils.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/NotificationUtils.kt deleted file mode 100644 index bde43be..0000000 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/NotificationUtils.kt +++ /dev/null @@ -1,3 +0,0 @@ -package ru.fromchat.ui - -var isPublicChatVisible = false \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/Theme.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/Theme.kt index 88b3364..05d1a2f 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/Theme.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/Theme.kt @@ -4,12 +4,12 @@ import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Typography import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import ru.fromchat.core.Settings +import ru.fromchat.config.Settings +import ru.fromchat.ui.components.googleSansMaterialTypography enum class Theme { AsSystem, @@ -114,11 +114,9 @@ fun FromChatTheme( surfaceContainerHighest = surfaceContainerHighest ) - val typography: Typography = googleSansMaterialTypography() - MaterialTheme( colorScheme = colorScheme, - typography = typography, + typography = googleSansMaterialTypography(), content = content ) } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/TouchEffects.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/TouchEffects.kt deleted file mode 100644 index dc66e82..0000000 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/TouchEffects.kt +++ /dev/null @@ -1,86 +0,0 @@ -package ru.fromchat.ui - -import androidx.compose.animation.core.AnimationSpec -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring -import androidx.compose.foundation.Indication -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.PressInteraction -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.composed -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.TransformOrigin -import androidx.compose.ui.graphics.graphicsLayer -import com.pr0gramm3r101.utils.conditional -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -/** - * Scale-down touch effect: scales to [scale] on press and back to 1f on release/cancel. - * When [interactionSource] is null: optionally wraps with [clickable] when [onClick] is non-null. - * When [interactionSource] is non-null: uses it for scale only (caller adds clickable on child so ripple scales with content). - * [indication] when non-null is used for the clickable (e.g. ripple); null = no indication. - * [clipShape] when non-null clips the scaled result. - * [animationSpec] drives the scale transition (default is a spring). - */ -fun Modifier.scaleOnPress( - scale: Float = 0.96f, - onClick: (() -> Unit)? = null, - indication: Indication? = null, - clipShape: Shape? = null, - interactionSource: MutableInteractionSource? = null, - animationSpec: AnimationSpec = spring() -): Modifier = composed { - val source = interactionSource ?: remember { MutableInteractionSource() } - var pressed by remember { mutableStateOf(false) } - val scope = rememberCoroutineScope() - - LaunchedEffect(source) { - var releaseJob: Job? = null - source.interactions.collect { interaction -> - when (interaction) { - is PressInteraction.Press -> { - releaseJob?.cancel() - releaseJob = null - pressed = true - } - is PressInteraction.Release, is PressInteraction.Cancel -> { - releaseJob = scope.launch { - delay(80) - pressed = false - } - } - } - } - } - - val scaleValue by animateFloatAsState( - targetValue = if (pressed) scale else 1f, - animationSpec = animationSpec, - label = "scaleOnPress" - ) - - Modifier - .conditional(onClick != null && interactionSource == null) { - clickable( - interactionSource = source, - indication = indication, - onClick = onClick!! - ) - } - .conditional(clipShape != null) { clip(clipShape!!) } - .graphicsLayer( - scaleX = scaleValue, - scaleY = scaleValue, - transformOrigin = TransformOrigin.Center - ) -} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/LoginScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/LoginScreen.kt index 7268531..7218af0 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/LoginScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/LoginScreen.kt @@ -24,7 +24,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text +import ru.fromchat.ui.components.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -41,10 +41,10 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res import ru.fromchat.api.ApiClient -import ru.fromchat.api.LoginRequest import ru.fromchat.api.apiRequest -import ru.fromchat.crypto.IdentityKeyManager +import ru.fromchat.api.schema.user.auth.LoginRequest import ru.fromchat.change_server +import ru.fromchat.api.crypto.IdentityKeyManager import ru.fromchat.error_unexpected import ru.fromchat.fill_all_fields import ru.fromchat.login @@ -53,7 +53,7 @@ import ru.fromchat.more import ru.fromchat.password import ru.fromchat.register_button import ru.fromchat.ui.LocalNavController -import ru.fromchat.ui.RowHeader +import ru.fromchat.ui.components.RowHeader import ru.fromchat.username import ru.fromchat.welcome @@ -181,7 +181,12 @@ fun LoginScreen( onLoginSuccess() } ) { - val response = ApiClient.loginRequest(LoginRequest(trimmedUsername, derived)) + val response = ApiClient.loginRequest( + LoginRequest( + trimmedUsername, + derived + ) + ) ApiClient.bindSession(response) try { IdentityKeyManager.ensureKeysOnLogin( diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/RegisterScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/RegisterScreen.kt index 59c8565..ead0997 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/RegisterScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/RegisterScreen.kt @@ -18,7 +18,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text +import ru.fromchat.ui.components.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -34,11 +34,11 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res import ru.fromchat.api.ApiClient -import ru.fromchat.api.RegisterRequest import ru.fromchat.api.apiRequest -import ru.fromchat.crypto.IdentityKeyManager +import ru.fromchat.api.schema.user.auth.RegisterRequest import ru.fromchat.back import ru.fromchat.confirm_password +import ru.fromchat.api.crypto.IdentityKeyManager import ru.fromchat.display_name import ru.fromchat.display_name_error import ru.fromchat.error_unexpected @@ -50,7 +50,7 @@ import ru.fromchat.register import ru.fromchat.register_button import ru.fromchat.register_d import ru.fromchat.ui.LocalNavController -import ru.fromchat.ui.RowHeader +import ru.fromchat.ui.components.RowHeader import ru.fromchat.username import ru.fromchat.username_length_error diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallMediaLayer.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/calls/CallMediaLayer.kt similarity index 81% rename from app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallMediaLayer.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/ui/calls/CallMediaLayer.kt index 3ed561f..cfdea24 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallMediaLayer.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/calls/CallMediaLayer.kt @@ -1,7 +1,8 @@ -package ru.fromchat.calls +package ru.fromchat.ui.calls import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import ru.fromchat.api.calls.LiveKitConnectSession @Composable expect fun CallMediaLayer( diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallOverlay.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/calls/CallOverlay.kt similarity index 92% rename from app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallOverlay.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/ui/calls/CallOverlay.kt index 41fcafa..2f43480 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallOverlay.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/calls/CallOverlay.kt @@ -1,4 +1,4 @@ -package ru.fromchat.calls +package ru.fromchat.ui.calls import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Spring @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -26,18 +25,27 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.dp +import com.pr0gramm3r101.utils.LocalSystemBarsVisibility import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res -import ru.fromchat.* import ru.fromchat.api.ApiClient -import ru.fromchat.api.ProfileCache -import ru.fromchat.api.visibleDisplayName -import ru.fromchat.ui.LocalSystemBarsVisibility +import ru.fromchat.api.calls.CallStore +import ru.fromchat.api.calls.CallUiState +import ru.fromchat.api.local.db.store.ProfileCache +import ru.fromchat.api.local.db.store.visibleDisplayName +import ru.fromchat.call_accept +import ru.fromchat.call_decline +import ru.fromchat.call_dismiss +import ru.fromchat.call_failed_title +import ru.fromchat.call_incoming_subtitle +import ru.fromchat.ui.components.Text +import ru.fromchat.user_fallback @Composable fun CallOverlay(modifier: Modifier = Modifier) { val state by CallStore.ui.collectAsState() val systemBars = LocalSystemBarsVisibility.current + LaunchedEffect(state) { when (state) { is CallUiState.InCall, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/calls/InlineCallChrome.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/calls/InlineCallChrome.kt similarity index 59% rename from app/shared/src/commonMain/kotlin/ru/fromchat/calls/InlineCallChrome.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/ui/calls/InlineCallChrome.kt index b0b6f38..b4ea10a 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/calls/InlineCallChrome.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/calls/InlineCallChrome.kt @@ -1,3 +1,3 @@ -package ru.fromchat.calls +package ru.fromchat.ui.calls expect val UseInlineInCallChrome: Boolean diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt index f99bb84..2ef9ba4 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalHazeMaterialsApi::class) + package ru.fromchat.ui.chat import androidx.compose.animation.AnimatedContent @@ -30,16 +32,15 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.AttachFile import androidx.compose.material.icons.rounded.Download -import androidx.compose.material.icons.rounded.InsertDriveFile import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.TextButton import androidx.compose.material3.WavyProgressIndicatorDefaults -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -51,15 +52,8 @@ import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.material3.TextButton -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Rect @@ -69,30 +63,49 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage -import coil3.compose.rememberAsyncImagePainter import com.pr0gramm3r101.utils.conditional import com.pr0gramm3r101.utils.crypto.Base64 import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.HazeMaterials +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res import ru.fromchat.api.ApiClient -import ru.fromchat.api.AttachmentDownloadNotifier -import ru.fromchat.ui.scaleOnPress -import ru.fromchat.api.DmEnvelope -import ru.fromchat.api.DmFile +import ru.fromchat.api.local.AttachmentMediaLog +import ru.fromchat.api.local.cache.DecryptedImageCache +import ru.fromchat.api.local.cache.UPLOAD_ERROR_FILE_TOO_LARGE +import ru.fromchat.api.local.download.AttachmentDownloadNotifier +import ru.fromchat.api.local.download.AttachmentDownloadScheduler +import ru.fromchat.api.local.download.ChatPreviewDecodeSize +import ru.fromchat.api.local.download.LocalDecodedImageCache +import ru.fromchat.api.local.download.rememberChatPreviewDecodeSize +import ru.fromchat.api.local.send.previewSeedDecodeSize +import ru.fromchat.api.schema.messages.dm.DmEnvelope +import ru.fromchat.api.schema.messages.dm.DmFile import ru.fromchat.attachment_image_load_failed import ru.fromchat.attachment_retry import ru.fromchat.attachment_upload_failed import ru.fromchat.attachment_upload_failed_too_large import ru.fromchat.cd_attachment_retry import ru.fromchat.cd_attachment_upload_retry -import ru.fromchat.core.cache.UPLOAD_ERROR_FILE_TOO_LARGE +import ru.fromchat.ui.chat.components.AttachmentLeadingTransitionMs +import ru.fromchat.ui.chat.components.CancellableAttachmentProgressIndicator +import ru.fromchat.ui.chat.components.ChatFileAttachmentTile +import ru.fromchat.ui.chat.components.FileAttachmentLeadingSlot +import ru.fromchat.ui.chat.utils.attachmentImageCornerShape +import ru.fromchat.ui.chat.utils.coalesceDecodeTarget +import ru.fromchat.ui.chat.utils.decodeSizeChangedMeaningfully +import ru.fromchat.ui.components.Text +import com.pr0gramm3r101.utils.scaleOnPress private val IMAGE_SIZE = 160.dp private const val BLUR_FADE_MS = 450 @@ -367,7 +380,7 @@ private fun ChatImageTileContent( val thumbnailBytes = remember(thumbnailBase64) { thumbnailBase64?.let { decodeAttachmentThumbnailBase64(it) } } - val thumbBitmap by produceState( + val thumbBitmap by produceState( initialValue = LocalDecodedImageCache.peekThumb(decryptCacheKey), decryptCacheKey, thumbnailBytes, @@ -397,14 +410,13 @@ private fun ChatImageTileContent( cacheClientId, loadAttempt, ) { - val target = decodeSize AttachmentMediaLog.tileLoad( "load_start", "key" to decryptCacheKey, "msgId" to messageId, "pending" to isOutboundPending, "localUri" to (localUri?.take(48) ?: "null"), - "target" to "${target.widthPx}x${target.heightPx}", + "target" to "${decodeSize.widthPx}x${decodeSize.heightPx}", ) val diskUri = DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId) val localPaths = buildList { @@ -418,7 +430,7 @@ private fun ChatImageTileContent( LocalDecodedImageCache.loadFull( decryptCacheKey, path.removePrefix("file://"), - target, + decodeSize, ) } } @@ -451,9 +463,7 @@ private fun ChatImageTileContent( if (fullBitmap != null || thumbBitmap != null) onFullyLoaded(true) return@LaunchedEffect } - val file = serverFile - val env = envelope - if (file == null || env == null) { + if (serverFile == null || envelope == null) { decryptFinished = true AttachmentMediaLog.tileLoad("load_skip_no_envelope", "key" to decryptCacheKey) return@LaunchedEffect @@ -461,7 +471,9 @@ private fun ChatImageTileContent( if (diskUri != null) { cachedPath = diskUri val loaded = withContext(Dispatchers.Default) { - LocalDecodedImageCache.loadFull(decryptCacheKey, diskUri.removePrefix("file://"), target) + LocalDecodedImageCache.loadFull(decryptCacheKey, diskUri.removePrefix("file://"), + decodeSize + ) } if (loaded != null) { AttachmentMediaLog.tileLoad( @@ -489,15 +501,15 @@ private fun ChatImageTileContent( AttachmentMediaLog.tileLoad( "load_network_decrypt", "key" to decryptCacheKey, - "file" to file.path, + "file" to serverFile.path, ) isAwaitingNetworkFull = true val uri = try { DecryptedImageCache.getOrDecrypt( messageId = messageId, fileIndex = fileIndex, - file = file, - envelope = env, + file = serverFile, + envelope = envelope, currentUserId = currentUserId, clientMessageId = cacheClientId, messageLabel = messageLabel, @@ -516,7 +528,9 @@ private fun ChatImageTileContent( cachedPath = uri if (uri != null) { fullBitmap = withContext(Dispatchers.Default) { - LocalDecodedImageCache.loadFull(decryptCacheKey, uri.removePrefix("file://"), target) + LocalDecodedImageCache.loadFull(decryptCacheKey, uri.removePrefix("file://"), + decodeSize + ) } } decryptFinished = true @@ -745,7 +759,7 @@ private fun ChatImageTileContent( } if (showOutboundBlurOverlay && outboundOverlayAlpha.value > 0.01f) { UploadingImageOverlay( - model = localUri!!, + model = localUri, uploadProgress = if (isUploading || awaitingServerAck) uploadProgress else null, clipShape = clipShape, contentScale = imageContentScale, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/Avatar.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/Avatar.kt index f3df4db..96d4e24 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/Avatar.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/Avatar.kt @@ -1,7 +1,6 @@ package ru.fromchat.ui.chat import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.shape.CircleShape @@ -20,10 +19,11 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.drawText -import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage -import ru.fromchat.core.config.Config +import ru.fromchat.config.ServerConfig +import ru.fromchat.ui.chat.components.generateGradientFromName +import ru.fromchat.ui.chat.components.getInitials @Composable fun Avatar( @@ -46,7 +46,7 @@ fun Avatar( val fullUrl = if (profilePictureUrl.startsWith("http")) { profilePictureUrl } else { - "${Config.apiBaseUrl}$profilePictureUrl" + "${ServerConfig.apiBaseUrl}$profilePictureUrl" } AsyncImage( diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt index e409149..19e3e97 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt @@ -49,7 +49,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text +import ru.fromchat.ui.components.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -77,7 +77,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res -import ru.fromchat.api.Message +import ru.fromchat.api.schema.messages.Message import ru.fromchat.cd_close import ru.fromchat.cd_emoji import ru.fromchat.cd_pick_file @@ -89,6 +89,11 @@ import ru.fromchat.message_editing_title import ru.fromchat.message_placeholder import ru.fromchat.message_replying_to import ru.fromchat.suspend_chat_banner_message +import ru.fromchat.ui.chat.utils.SelectedAttachment +import ru.fromchat.ui.chat.utils.TypingHandler +import ru.fromchat.ui.chat.utils.getFilenameFromUri +import ru.fromchat.ui.chat.utils.rememberFilePicker +import ru.fromchat.ui.chat.utils.rememberImagePicker import kotlin.time.Clock private val ChatInputChromeHeight = 54.dp diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt index 6ba5ca2..b8964d3 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt @@ -9,15 +9,20 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.Serializable import ru.fromchat.api.ApiClient -import ru.fromchat.api.Message -import ru.fromchat.api.generateClientMessageId -import ru.fromchat.api.nowMessageTimestampIso -import ru.fromchat.api.sortMessagesForChatDisplay -import ru.fromchat.api.WebSocketMessage -import ru.fromchat.core.Logger -import ru.fromchat.ui.chat.dedupeMessagesByClientId -import ru.fromchat.ui.chat.dropSupersededOptimisticMessages -import kotlin.time.Clock +import ru.fromchat.api.local.messages.generateClientMessageId +import ru.fromchat.api.local.messages.nowMessageTimestampIso +import ru.fromchat.api.local.messages.sortMessagesForChatDisplay +import ru.fromchat.api.local.send.OutgoingMessageCoordinator +import ru.fromchat.api.schema.messages.Message +import ru.fromchat.api.schema.websocket.WebSocketMessage +import ru.fromchat.Logger +import ru.fromchat.api.local.send.clearOutboundFileCaches +import ru.fromchat.api.local.send.clearOutboundImageCaches +import ru.fromchat.ui.chat.utils.TypingHandler +import ru.fromchat.ui.chat.utils.TypingUser +import ru.fromchat.ui.chat.utils.dedupeMessagesByClientId +import ru.fromchat.ui.chat.utils.dropSupersededOptimisticMessages +import ru.fromchat.ui.chat.utils.mergeDatabaseMessagesWithPanelState import kotlin.time.ExperimentalTime /** @@ -238,7 +243,7 @@ abstract class ChatPanel( clearOutboundFileCaches(cid, message.id) } removeMessage(message.id) - ru.fromchat.api.outbox.OutgoingMessageCoordinator.cancelOutboundMessage(cid, outboxConversationId()) + OutgoingMessageCoordinator.cancelOutboundMessage(cid, outboxConversationId()) } suspend fun cancelQueuedMessageByClientId(clientMessageId: String) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt index 2e84144..81db430 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt @@ -43,6 +43,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import com.pr0gramm3r101.utils.supportClipboardManagerImpl import dev.chrisbanes.haze.HazeProgressive import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeSource @@ -58,38 +59,54 @@ import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import org.jetbrains.compose.resources.stringResource +import ru.fromchat.Logger import ru.fromchat.Res import ru.fromchat.api.ApiClient -import ru.fromchat.api.AttachmentUploadNotifier -import ru.fromchat.api.AttachmentUploadProgress -import ru.fromchat.api.generateClientMessageId -import ru.fromchat.api.nowMessageTimestampIso -import ru.fromchat.api.optimisticMessageIdForClientMessageId -import ru.fromchat.api.outbox.OutgoingMessageCoordinator -import com.pr0gramm3r101.utils.supportClipboardManagerImpl -import ru.fromchat.api.ConnectionStateStore -import ru.fromchat.api.ConnectionStatus -import ru.fromchat.api.Message -import ru.fromchat.api.ProfileCache -import ru.fromchat.api.UserStatusStore -import ru.fromchat.api.WebSocketManager -import ru.fromchat.api.WebSocketMessage -import ru.fromchat.api.WebSocketUpdatesData +import ru.fromchat.api.calls.CallStore +import ru.fromchat.api.local.WebSocketManager +import ru.fromchat.api.local.db.store.ConnectionStateStore +import ru.fromchat.api.local.db.store.ConnectionStatus +import ru.fromchat.api.local.db.store.ProfileCache +import ru.fromchat.api.local.db.store.UserStatusStore +import ru.fromchat.api.local.download.SavableMessageImage +import ru.fromchat.api.local.download.ensureFileDownloadedForSave +import ru.fromchat.api.local.download.isMessageImageFullyLoaded +import ru.fromchat.api.local.download.mimeTypeForImageFilename +import ru.fromchat.api.local.download.rememberSaveMessageFile +import ru.fromchat.api.local.download.rememberSaveMessageImage +import ru.fromchat.api.local.download.resolveImageSourceUri +import ru.fromchat.api.local.download.resolveSavableMessageFile +import ru.fromchat.api.local.download.resolveSavableMessageImage +import ru.fromchat.api.local.messages.generateClientMessageId +import ru.fromchat.api.local.messages.nowMessageTimestampIso +import ru.fromchat.api.local.messages.optimisticMessageIdForClientMessageId +import ru.fromchat.api.local.send.OutgoingMessageCoordinator +import ru.fromchat.api.local.send.prepareOutboundFileForSend +import ru.fromchat.api.local.send.prepareOutboundImageForSend +import ru.fromchat.api.local.workers.AttachmentUploadNotifier +import ru.fromchat.api.local.workers.AttachmentUploadProgress +import ru.fromchat.api.schema.messages.Message +import ru.fromchat.api.schema.websocket.WebSocketMessage +import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData import ru.fromchat.back -import ru.fromchat.calls.CallStore import ru.fromchat.cd_call import ru.fromchat.chat_group_label -import ru.fromchat.core.Logger -import ru.fromchat.net.NetworkConnectivity import ru.fromchat.status_connecting import ru.fromchat.status_updating -import ru.fromchat.ui.HapticFeedbackEvent import ru.fromchat.ui.LocalNavController -import ru.fromchat.ui.rememberHapticFeedback -import ru.fromchat.ui.suspension.SuspendedAccountSupportSheet +import ru.fromchat.ui.chat.panels.publicchat.publicChatProfileSharedAvatarKey +import ru.fromchat.ui.chat.utils.AttachmentDownloadVisibility +import ru.fromchat.ui.chat.utils.getImageAspectRatio +import ru.fromchat.ui.chat.utils.getImageDimensions +import ru.fromchat.ui.chat.utils.imageAttachmentKey +import ru.fromchat.ui.chat.utils.visibleMessageIdsInChatList +import ru.fromchat.ui.components.SuspendedAccountSupportSheet +import ru.fromchat.utils.NetworkConnectivity import ru.fromchat.utils.formatLastSeen +import ru.fromchat.utils.haptic.HapticFeedbackEvent +import ru.fromchat.utils.haptic.rememberHapticFeedback import ru.fromchat.utils.rememberLastSeenFormatStrings -import kotlin.time.Clock +import kotlin.time.Duration.Companion.milliseconds @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @Composable @@ -137,7 +154,6 @@ fun ChatScreen( val hazeState = rememberHazeState( blurEnabled = !(panelState.isLoading && panelState.messages.isEmpty()) ) - val chatBottomHazeStyle = rememberChatSurfaceContainerHazeStyle() val currentTypingUsers = panelState.typingUsers // Directly use from panelState val statusMap by UserStatusStore.status.collectAsState() @@ -386,9 +402,9 @@ fun ChatScreen( val isNearBottom = minVisibleIndex <= 2 if (lastIsOurs || isNearBottom) { if (listState.layoutInfo.visibleItemsInfo.isNotEmpty()) { - delay(100) + delay(100.milliseconds) listState.animateScrollToItem(0) - delay(150) + delay(150.milliseconds) listState.animateScrollToItem(0) } } @@ -428,21 +444,23 @@ fun ChatScreen( panel.updateMessageByClientMessageId(progress.jobId) { it.copy(uploadProgress = 0, uploadError = null) } + is AttachmentUploadProgress.InProgress -> panel.updateMessageByClientMessageId(progress.jobId) { it.copy(uploadProgress = progress.percent, uploadError = null) } + is AttachmentUploadProgress.Success -> panel.updateMessageByClientMessageId(progress.jobId) { it.copy(uploadProgress = null) } + is AttachmentUploadProgress.Failed -> { if (progress.error == "Cancelled") return@collect panel.updateMessageByClientMessageId(progress.jobId) { it.copy(uploadProgress = null, uploadError = progress.error) } } - else -> Unit } } } @@ -709,8 +727,8 @@ fun ChatScreen( panel.updateMessageByClientMessageId(cid) { it.copy(uploadError = null, uploadProgress = 0) } - ru.fromchat.api.outbox.OutgoingMessageCoordinator - .retryDmAttachmentUpload(cid) + + OutgoingMessageCoordinator.retryDmAttachmentUpload(cid) } }, sharedAvatarNavKey = @@ -735,7 +753,7 @@ fun ChatScreen( item { Spacer(Modifier.height(floatingHeaderClearance)) } } - ChatFloatingHeaderBox( + ChatTopBar( hazeState = hazeState, onBack = { navController.navigateUp() }, backContentDescription = stringResource(Res.string.back), @@ -748,7 +766,7 @@ fun ChatScreen( }, callContentDescription = cdCall, titleChrome = { - ChatFloatingTitleChrome( + ChatTopBarInner( title = panelState.title, titleAvatar = panelState.titleAvatar, profileUserId = profileUserId, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreenChrome.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt similarity index 81% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreenChrome.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt index 9c09ce8..0850bfa 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreenChrome.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt @@ -29,7 +29,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable @@ -42,6 +41,7 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Outline import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.luminance import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.layout.onGloballyPositioned @@ -61,37 +61,17 @@ import dev.chrisbanes.haze.hazeEffect import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res import ru.fromchat.chat_members_count -import ru.fromchat.ui.ConnectingEllipsis -import ru.fromchat.ui.scaleOnPress +import ru.fromchat.ui.chat.utils.TypingUser +import ru.fromchat.ui.components.ConnectingEllipsis +import ru.fromchat.ui.components.Text +import com.pr0gramm3r101.utils.scaleOnPress +import kotlin.math.PI +import kotlin.math.tan -/** - * Bottom-corner arc radius for [ChatFloatingHeaderBox] silhouette. - * Matches the inset of the horizontal bottom segment (flat part starts at [this] dx from edges). - * The scallop’s vertical depth below the flat bottom is **this** value px; [ChatFloatingHeaderBox] - * adds exactly that much height below the measured [TopAppBar] so the arc is not painted over content. - */ val ChatFloatingHeaderBottomArcRadius: Dp = 35.dp -/** - * Telegram-style floating chrome: iOS-like frosted pill (Haze "thin" material + translucent fill), - * circular back control, and a full-width progressive blur plate behind the header row. - * [ChatFloatingHeaderBox] uses a plain [Box] (not a toolbar / TopAppBar) with side rails so the pill stays centered. - */ @Composable -fun ChatFloatingBackButton( - contentDescription: String, - onClick: () -> Unit, -) { - IconButton(onClick = onClick) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = contentDescription - ) - } -} - -@Composable -fun ChatFloatingTitleChrome( +fun ChatTopBarInner( title: String, titleAvatar: AvatarInfo?, profileUserId: Int?, @@ -108,26 +88,24 @@ fun ChatFloatingTitleChrome( chatGroupLabel: String, modifier: Modifier = Modifier, ) { - val pillClickable = profileUserId != null && onTitleClick != null - Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = modifier.conditional( - pillClickable, - `if` = { - Modifier.scaleOnPress( - scale = 0.96f, - onClick = onTitleClick - ) - } - ), + profileUserId != null && onTitleClick != null + ) { + Modifier.scaleOnPress( + scale = 0.96f, + onClick = onTitleClick + ) + }, ) { when { sharedAvatarKey != null && sharedTransitionScope != null && animatedVisibilityScope != null -> { val displayName = titleAvatar?.displayName?.takeIf { it.isNotBlank() } ?: title.takeIf { it.isNotBlank() } ?: "" + with(sharedTransitionScope) { Avatar( profilePictureUrl = titleAvatar?.profilePictureUrl, @@ -140,6 +118,7 @@ fun ChatFloatingTitleChrome( .size(40.dp), ) } + Spacer(modifier = Modifier.width(6.dp)) } @@ -150,6 +129,7 @@ fun ChatFloatingTitleChrome( displayName = avatar.displayName, modifier = Modifier.size(40.dp), ) + Spacer(modifier = Modifier.width(6.dp)) } } @@ -171,6 +151,7 @@ fun ChatFloatingTitleChrome( ) }, ) + Spacer(modifier = Modifier.width(8.dp)) } @@ -287,7 +268,7 @@ fun ChatFloatingTitleChrome( @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ChatFloatingHeaderBox( +fun ChatTopBar( hazeState: HazeState, onBack: () -> Unit, backContentDescription: String, @@ -297,14 +278,10 @@ fun ChatFloatingHeaderBox( titleChrome: @Composable () -> Unit, modifier: Modifier = Modifier, ) { - val containerColor = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.91f) - val headerHazeStyle = rememberChatSurfaceContainerHazeStyle() val bottomCornerRadius = ChatFloatingHeaderBottomArcRadius - val density = LocalDensity.current - val shape = remember(bottomCornerRadius) { BottomInsetTopBarShape(bottomCornerRadius) } // [BottomInsetTopBarShape] draws the arc in the strip from y = (content height) .. (content + r); // stack measured bar height + that depth so the scallop is never overlapped by TopAppBar children. - val arcExtentPx = with(density) { bottomCornerRadius.roundToPx() } + val arcExtentPx = with(LocalDensity.current) { bottomCornerRadius.roundToPx() } SubcomposeLayout(modifier = modifier.fillMaxWidth()) { constraints -> val heightUnbounded = constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity) @@ -318,10 +295,12 @@ fun ChatFloatingHeaderBox( titleContentColor = MaterialTheme.colorScheme.onSurface, ), navigationIcon = { - ChatFloatingBackButton( - contentDescription = backContentDescription, - onClick = onBack, - ) + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = backContentDescription + ) + } }, title = { titleChrome() }, actions = { @@ -346,9 +325,13 @@ fun ChatFloatingHeaderBox( Box( modifier = Modifier .fillMaxSize() - .clip(shape) - .background(containerColor) - .hazeEffect(state = hazeState, style = headerHazeStyle), + .clip( + remember(bottomCornerRadius) { + BottomInsetTopBarShape(bottomCornerRadius) + } + ) + .background(MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.91f)) + .hazeEffect(state = hazeState, style = rememberChatSurfaceContainerHazeStyle()), ) }.first().measure(Constraints.fixed(layoutWidth, layoutHeight)) @@ -359,8 +342,11 @@ fun ChatFloatingHeaderBox( } } -/** Cubic-fit constant for a quarter circle: `4/3 * tan(pi/8)` (~0.5522847498). */ -private const val CircularCornerBezierKappa = 0.5522847498f +/** lol, i dont even know what this means because this file was vibecoded + * + * Cubic-fit constant for a quarter circle: `4/3 * tan(pi/8)` (~0.5522847498). + * */ +private val CircularCornerBezierKappa = (4/3 * tan(PI/8)).toFloat() /** * Bottom edge: inset horizontal segment (`y = height − r`), then quarter-circle corners with @@ -369,9 +355,7 @@ private const val CircularCornerBezierKappa = 0.5522847498f * Compose's [Path.arcTo] uses an ellipse inscribed in `Rect` whose **center is rect center**, so it * cannot express these corners reliably; cubic segments match the arcs instead. */ -private class BottomInsetTopBarShape( - private val cornerRadius: Dp, -) : androidx.compose.ui.graphics.Shape { +private class BottomInsetTopBarShape(private val cornerRadius: Dp) : Shape { override fun createOutline( size: Size, layoutDirection: LayoutDirection, @@ -383,34 +367,34 @@ private class BottomInsetTopBarShape( val straightY = (h - r).coerceAtLeast(0f) val k = CircularCornerBezierKappa * r - val path = Path().apply { - moveTo(r, straightY) - lineTo(w - r, straightY) - // Bottom-right: center (w - r, h), arc from (w - r, straightY) to (w, h). - cubicTo( - x1 = w - r + k, - y1 = straightY, - x2 = w, - y2 = h - k, - x3 = w, - y3 = h, - ) - lineTo(w, 0f) - lineTo(0f, 0f) - lineTo(0f, h) - // Bottom-left: center (r, h), arc from (0, h) to (r, straightY). - cubicTo( - x1 = 0f, - y1 = h - k, - x2 = r - k, - y2 = straightY, - x3 = r, - y3 = straightY, - ) - close() - } - - return Outline.Generic(path) + return Outline.Generic( + Path().apply { + moveTo(r, straightY) + lineTo(w - r, straightY) + // Bottom-right: center (w - r, h), arc from (w - r, straightY) to (w, h). + cubicTo( + x1 = w - r + k, + y1 = straightY, + x2 = w, + y2 = h - k, + x3 = w, + y3 = h, + ) + lineTo(w, 0f) + lineTo(0f, 0f) + lineTo(0f, h) + // Bottom-left: center (r, h), arc from (0, h) to (r, straightY). + cubicTo( + x1 = 0f, + y1 = h - k, + x2 = r - k, + y2 = straightY, + x3 = r, + y3 = straightY, + ) + close() + } + ) } } @@ -421,12 +405,12 @@ private class BottomInsetTopBarShape( @Composable fun rememberChatSurfaceContainerHazeStyle(): HazeStyle { val surface = MaterialTheme.colorScheme.surfaceContainer - val tintAlpha = if (surface.luminance() >= 0.5f) 0.74f else 0.79f + return remember(surface) { HazeStyle( blurRadius = 24.dp, backgroundColor = surface, - tint = HazeTint(surface.copy(alpha = tintAlpha)), + tint = HazeTint(surface.copy(alpha = if (surface.luminance() >= 0.5f) 0.74f else 0.79f)), ) } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt index be1af6c..7714fa5 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt @@ -15,15 +15,14 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.layout.systemGestures import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material.icons.Icons @@ -37,7 +36,6 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -53,33 +51,44 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import com.pr0gramm3r101.utils.LocalSystemBarsVisibility import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.withContext import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res -import ru.fromchat.api.Message -import ru.fromchat.api.formatMessageDateTimeLocal -import ru.fromchat.* -import ru.fromchat.ui.BackHandler -import ru.fromchat.ui.LocalSystemBarsVisibility +import ru.fromchat.action_delete +import ru.fromchat.action_reply +import ru.fromchat.action_save +import ru.fromchat.api.local.cache.DecryptedImageCache +import ru.fromchat.api.local.download.ChatPreviewDecodeSize +import ru.fromchat.api.local.download.LocalDecodedImageCache +import ru.fromchat.api.local.download.isMessageImageFullyLoaded +import ru.fromchat.api.local.messages.formatMessageDateTimeLocal +import ru.fromchat.api.schema.messages.Message +import ru.fromchat.back +import ru.fromchat.more +import ru.fromchat.ui.chat.utils.imageAspectRatioForMessage +import ru.fromchat.ui.components.BackHandler +import ru.fromchat.ui.components.Text import kotlin.math.abs import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt -private val MENU_BG_ALPHA = 0.5f + +private const val MENU_BG_ALPHA = 0.5f private data class InitialTransform( val scale: Float, @@ -113,7 +122,6 @@ fun ImageFullscreenPreview( val labelDelete = stringResource(Res.string.action_delete) val headerName = messageDisplayUsername(message, currentUserId) val envelope = message.dmEnvelope - val thumbnailBase64 = message.fileThumbnails?.getOrNull(fileIndex) val cacheClientId = message.client_message_id?.trim()?.takeIf { it.isNotEmpty() } val decryptCacheKey = remember(message.id, fileIndex, cacheClientId) { @@ -159,6 +167,7 @@ fun ImageFullscreenPreview( } val systemBarsVisibility = LocalSystemBarsVisibility.current + LaunchedEffect(menusVisible) { systemBarsVisibility?.invoke(menusVisible) } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt index 313c676..60c1f31 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt @@ -26,7 +26,6 @@ import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.SaveAlt import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -51,10 +50,18 @@ import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res -import ru.fromchat.api.Message -import ru.fromchat.api.isQueuedOutbound -import ru.fromchat.* -import ru.fromchat.ui.scaleOnPress +import ru.fromchat.action_cancel_send +import ru.fromchat.action_copy +import ru.fromchat.action_delete +import ru.fromchat.action_edit +import ru.fromchat.action_reply +import ru.fromchat.action_save +import ru.fromchat.api.local.download.resolveSavableMessageFile +import ru.fromchat.api.local.download.resolveSavableMessageImage +import ru.fromchat.api.local.messages.isQueuedOutbound +import ru.fromchat.api.schema.messages.Message +import ru.fromchat.ui.components.Text +import com.pr0gramm3r101.utils.scaleOnPress data class ContextMenuState( val isOpen: Boolean = false, @@ -82,7 +89,6 @@ internal fun messageContextMenuFingerprint( } } -@Suppress("AssignedValueIsNeverRead") @Composable fun MessageContextMenu( state: ContextMenuState, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageDisplayName.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageDisplayName.kt index 9412334..213af5b 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageDisplayName.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageDisplayName.kt @@ -3,10 +3,11 @@ package ru.fromchat.ui.chat import androidx.compose.runtime.Composable import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res -import ru.fromchat.api.ProfileCache -import ru.fromchat.api.visibleUsername -import ru.fromchat.api.Message -import ru.fromchat.* +import ru.fromchat.api.local.db.store.ProfileCache +import ru.fromchat.api.schema.messages.Message +import ru.fromchat.api.local.db.store.visibleUsername +import ru.fromchat.message_sender_you +import ru.fromchat.user_fallback private val userIdUsernamePattern = Regex("^User (\\d+)$") diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt index 0ab0221..20d23bb 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt @@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -53,10 +52,17 @@ import androidx.compose.ui.unit.sp import com.pr0gramm3r101.utils.conditional import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res -import ru.fromchat.api.Message -import ru.fromchat.api.isQueuedOutbound -import ru.fromchat.api.formatMessageTimeLocal -import ru.fromchat.* +import ru.fromchat.api.local.cache.DecryptedImageCache +import ru.fromchat.api.local.messages.formatMessageTimeLocal +import ru.fromchat.api.local.messages.isQueuedOutbound +import ru.fromchat.api.schema.messages.Message +import ru.fromchat.message_corrupted +import ru.fromchat.message_edited_suffix +import ru.fromchat.ui.chat.components.getMessageGradient +import ru.fromchat.ui.chat.components.getReplyMessageGradient +import ru.fromchat.ui.chat.utils.imageAspectRatioForMessage +import ru.fromchat.ui.chat.utils.imageAttachmentKey +import ru.fromchat.ui.components.Text /** True when [Message.content] is only a filename placeholder (no real caption). */ internal fun isFilenameOnlyMessageCaption(message: Message): Boolean { @@ -558,7 +564,7 @@ fun MessageItem( fileIndex = index, confirmed = message.id > 0, hasLocalPreview = index == 0 && - DecryptedImageCache.isDecryptedImageCacheUri(message.pendingFileUri), + DecryptedImageCache.isDecryptedImageCacheUri(message.pendingFileUri), ), fileSizeBytes = message.fileSizes?.getOrNull(index), messageId = message.id, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatProfileSharedTransition.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatProfileSharedTransition.kt deleted file mode 100644 index 27ae61b..0000000 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatProfileSharedTransition.kt +++ /dev/null @@ -1,9 +0,0 @@ -package ru.fromchat.ui.chat - -/** - * Stable shared-element keys for NavHost predictive back–compatible transitions from - * a public-chat message row to [ru.fromchat.ui.profile.ProfileScreen]. - * One key per message so LazyColumn rows never duplicate keys for the same user. - */ -fun publicChatProfileSharedAvatarKey(userId: Int, sourceMessageId: Int): String = - "public-chat-profile-avatar-$userId-$sourceMessageId" diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/TypingIndicator.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/TypingIndicator.kt index 9b71dd9..e386e01 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/TypingIndicator.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/TypingIndicator.kt @@ -14,7 +14,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import androidx.compose.material3.Text +import ru.fromchat.ui.components.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/CancellableAttachmentProgress.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/components/CancellableAttachmentProgress.kt similarity index 95% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/CancellableAttachmentProgress.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/components/CancellableAttachmentProgress.kt index d69ebed..68aa9ca 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/CancellableAttachmentProgress.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/components/CancellableAttachmentProgress.kt @@ -1,4 +1,4 @@ -package ru.fromchat.ui.chat +package ru.fromchat.ui.chat.components import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -18,7 +18,8 @@ import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res import ru.fromchat.cd_close -import ru.fromchat.ui.scaleOnPress +import ru.fromchat.ui.chat.ExpressiveUploadIndicator +import com.pr0gramm3r101.utils.scaleOnPress @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatFileAttachmentTile.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/components/ChatFileAttachmentTile.kt similarity index 93% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatFileAttachmentTile.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/components/ChatFileAttachmentTile.kt index 13c6bb7..d1b1f64 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatFileAttachmentTile.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/components/ChatFileAttachmentTile.kt @@ -1,4 +1,4 @@ -package ru.fromchat.ui.chat +package ru.fromchat.ui.chat.components import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -26,15 +25,24 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res +import ru.fromchat.api.local.cache.DecryptedFileCache +import ru.fromchat.api.local.cache.UPLOAD_ERROR_FILE_TOO_LARGE +import ru.fromchat.api.local.download.AttachmentDownloadNotifier +import ru.fromchat.api.local.download.AttachmentDownloadScheduler +import ru.fromchat.api.local.download.DmFileDownloader +import ru.fromchat.api.local.download.DownloadedFileRegistry +import ru.fromchat.api.local.download.openCachedAttachmentFile +import ru.fromchat.api.local.mimeTypeForFilename +import ru.fromchat.api.schema.messages.dm.DmEnvelope +import ru.fromchat.api.schema.messages.dm.DmFile import ru.fromchat.attachment_open_failed import ru.fromchat.attachment_retry import ru.fromchat.attachment_upload_failed import ru.fromchat.attachment_upload_failed_too_large import ru.fromchat.cd_attachment_upload_retry -import ru.fromchat.core.cache.UPLOAD_ERROR_FILE_TOO_LARGE -import ru.fromchat.api.AttachmentDownloadNotifier -import ru.fromchat.api.DmEnvelope -import ru.fromchat.api.DmFile +import ru.fromchat.ui.chat.ExpressiveFileAttachmentRow +import ru.fromchat.ui.chat.utils.showAttachmentOpenFailed +import ru.fromchat.ui.components.Text @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -232,7 +240,7 @@ fun ChatFileAttachmentTile( isUploading = showProgressing, uploadProgress = when { isUploading && showProgressing -> displayUploadProgress - isDownloading || showPausedProgress -> downloadProgress ?: 0 + isDownloading || showPausedProgress -> downloadProgress else -> null }, isDownloaded = showAsDownloadedIcon, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatGradients.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/components/ChatGradients.kt similarity index 98% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatGradients.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/components/ChatGradients.kt index 618a905..25cf76c 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatGradients.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/components/ChatGradients.kt @@ -1,4 +1,4 @@ -package ru.fromchat.ui.chat +package ru.fromchat.ui.chat.components import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/FileAttachmentLeadingSlot.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/components/FileAttachmentLeadingSlot.kt similarity index 95% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/FileAttachmentLeadingSlot.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/components/FileAttachmentLeadingSlot.kt index 4d0c8f2..1f6ef3f 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/FileAttachmentLeadingSlot.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/components/FileAttachmentLeadingSlot.kt @@ -1,4 +1,4 @@ -package ru.fromchat.ui.chat +package ru.fromchat.ui.chat.components import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.FastOutSlowInEasing @@ -13,8 +13,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.InsertDriveFile import androidx.compose.material.icons.rounded.Download -import androidx.compose.material.icons.rounded.InsertDriveFile import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -93,7 +93,7 @@ internal fun FileAttachmentLeadingSlot( } FileLeadingVisual.File -> { LeadingIconCircle( - icon = Icons.Rounded.InsertDriveFile, + icon = Icons.AutoMirrored.Rounded.InsertDriveFile, containerColor = containerColor, iconTint = iconOnContainer, ) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmNav.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmNav.kt similarity index 94% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmNav.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmNav.kt index 38453c7..758b0ed 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmNav.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmNav.kt @@ -1,4 +1,4 @@ -package ru.fromchat.ui.dm +package ru.fromchat.ui.chat.panels.dm import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.SharedTransitionScope @@ -7,12 +7,12 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import ru.fromchat.core.cache.CacheContext +import ru.fromchat.api.local.cache.CacheContext import androidx.compose.ui.Modifier import androidx.navigation.NavController -import ru.fromchat.ui.HapticFeedbackEvent +import ru.fromchat.utils.haptic.HapticFeedbackEvent import ru.fromchat.ui.profile.ProfileScreen -import ru.fromchat.ui.rememberHapticFeedback +import ru.fromchat.utils.haptic.rememberHapticFeedback /** Route patterns and builders for DM chat + in-DM profile (stacked for predictive / system back). */ object DmNav { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt similarity index 92% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt index ea46791..f13e191 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt @@ -1,4 +1,4 @@ -package ru.fromchat.ui.dm +package ru.fromchat.ui.chat.panels.dm import io.ktor.client.plugins.ClientRequestException import kotlinx.coroutines.CoroutineScope @@ -8,47 +8,44 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import ru.fromchat.api.ApiClient -import ru.fromchat.api.DmEnvelope -import ru.fromchat.api.DmDeletedData -import ru.fromchat.api.Message -import ru.fromchat.api.sortMessagesForChatDisplay -import ru.fromchat.api.ProfileCache -import ru.fromchat.api.WebSocketMessage -import ru.fromchat.api.db.MessageCacheStore -import ru.fromchat.api.AttachmentDownloadNotifier -import ru.fromchat.api.AttachmentDownloadProgress -import ru.fromchat.api.db.parseDmMessageContent -import ru.fromchat.api.db.resolveLocalPreviewUri -import ru.fromchat.api.db.MessageRepository -import ru.fromchat.api.db.conversationIdForDm -import ru.fromchat.api.outbox.OutgoingMessageCoordinator -import ru.fromchat.api.visibleDisplayName -import ru.fromchat.core.Logger -import ru.fromchat.core.config.Config -import ru.fromchat.crypto.CorruptedDmMessagePlaceholder -import ru.fromchat.crypto.DmCiphertextCorruptedException -import ru.fromchat.crypto.decryptEnvelope +import ru.fromchat.api.local.cache.CacheContext +import ru.fromchat.api.local.db.store.ProfileCache +import ru.fromchat.api.local.db.store.MessageCacheStore +import ru.fromchat.api.local.db.store.MessageRepository +import ru.fromchat.api.local.messages.conversationIdForDm +import ru.fromchat.api.local.db.parseDmMessageContent +import ru.fromchat.api.local.db.resolveLocalPreviewUri +import ru.fromchat.api.local.download.AttachmentDownloadNotifier +import ru.fromchat.api.local.download.AttachmentDownloadProgress +import ru.fromchat.api.local.send.OutgoingMessageCoordinator +import ru.fromchat.api.schema.messages.Message +import ru.fromchat.api.schema.messages.dm.DmEnvelope +import ru.fromchat.api.schema.websocket.WebSocketMessage +import ru.fromchat.api.schema.websocket.types.DmDeletedData +import ru.fromchat.api.local.db.store.visibleDisplayName +import ru.fromchat.Logger +import ru.fromchat.config.ServerConfig +import ru.fromchat.api.crypto.CorruptedDmMessagePlaceholder +import ru.fromchat.api.crypto.DmCiphertextCorruptedException +import ru.fromchat.api.crypto.decryptEnvelope +import ru.fromchat.api.local.AttachmentMediaLog import ru.fromchat.ui.chat.AvatarInfo import ru.fromchat.ui.chat.ChatPanel -import ru.fromchat.ui.chat.DecryptedFileCache -import ru.fromchat.ui.chat.DecryptedImageCache -import ru.fromchat.ui.chat.isImageFilename -import ru.fromchat.ui.chat.seedOutboundFileAsDownloaded -import ru.fromchat.ui.chat.DownloadedFileRegistry -import ru.fromchat.ui.chat.DmTypingHandler -import ru.fromchat.ui.chat.TypingHandler -import ru.fromchat.ui.chat.dedupeMessagesByClientId -import ru.fromchat.ui.chat.dropSupersededOptimisticMessages -import ru.fromchat.ui.chat.imageAspectRatioForMessage -import ru.fromchat.ui.chat.AttachmentMediaLog +import ru.fromchat.api.local.cache.DecryptedFileCache +import ru.fromchat.api.local.cache.DecryptedImageCache +import ru.fromchat.ui.chat.utils.DmTypingHandler +import ru.fromchat.api.local.download.DownloadedFileRegistry +import ru.fromchat.ui.chat.utils.TypingHandler +import ru.fromchat.ui.chat.utils.dedupeMessagesByClientId +import ru.fromchat.ui.chat.utils.dropSupersededOptimisticMessages +import ru.fromchat.ui.chat.utils.imageAspectRatioForMessage import ru.fromchat.ui.chat.isImageFilename +import ru.fromchat.api.local.send.seedOutboundFileAsDownloaded class DmPanel( private val otherUserId: Int, @@ -174,7 +171,7 @@ class DmPanel( setLoading(true) try { OutgoingMessageCoordinator.pruneStaleAttachmentOutboxForInstance( - ru.fromchat.core.cache.CacheContext.requireActiveInstanceId(), + CacheContext.requireActiveInstanceId(), ) val cached = runCatching { MessageCacheStore.loadDmMessages(otherUserId) }.getOrDefault(emptyList()) if (cached.isNotEmpty()) { @@ -318,7 +315,6 @@ class DmPanel( private fun mergeConfirmedOwnMessage(envelope: DmEnvelope, plaintext: String, isContentCorrupted: Boolean) { val confirmed = createMessage(envelope, plaintext, isContentCorrupted) - val hasAttachments = !envelope.files.isNullOrEmpty() val cid = envelope.clientMessageId?.trim().orEmpty() val stateSourceBeforeMerge = _state.messages.firstOrNull { message -> message.user_id == currentUserId && @@ -337,7 +333,7 @@ class DmPanel( ) scope.launch(Dispatchers.Default) { - if (isImageAttachment && localUri != null && dmFile != null) { + if (isImageAttachment && localUri != null) { DecryptedImageCache.seedFromLocalFile( messageId = envelope.id, fileIndex = 0, @@ -548,7 +544,7 @@ class DmPanel( } } - override fun showCallButton(): Boolean = Config.callsEnabled + override fun showCallButton(): Boolean = ServerConfig.callsEnabled override fun getTypingHandler(): TypingHandler = typingHandler diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanelCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanelCache.kt similarity index 95% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanelCache.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanelCache.kt index 411a970..0bbdd4e 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanelCache.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanelCache.kt @@ -1,10 +1,10 @@ -package ru.fromchat.ui.dm +package ru.fromchat.ui.chat.panels.dm import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import ru.fromchat.api.ApiClient -import ru.fromchat.core.cache.CacheContext +import ru.fromchat.api.local.cache.CacheContext /** * Cache of DM panels by otherUserId so that when navigating back from profile diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmScreen.kt similarity index 87% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmScreen.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmScreen.kt index a34fbfe..6f5fead 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmScreen.kt @@ -1,4 +1,4 @@ -package ru.fromchat.ui.dm +package ru.fromchat.ui.chat.panels.dm import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.SharedTransitionScope @@ -9,13 +9,14 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Rect -import ru.fromchat.api.ApiClient import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import ru.fromchat.api.db.MessageRepository -import ru.fromchat.api.outbox.OutgoingMessageCoordinator -import ru.fromchat.api.outbox.scheduleOutboxProcessing -import ru.fromchat.core.cache.CacheContext +import ru.fromchat.api.ApiClient +import ru.fromchat.api.local.db.store.MessageRepository +import ru.fromchat.api.local.send.OutgoingMessageCoordinator +import ru.fromchat.api.local.send.scheduleOutboxProcessing +import ru.fromchat.api.local.cache.CacheContext +import ru.fromchat.ui.chat.AvatarInfo import ru.fromchat.ui.chat.ChatScreen @Composable @@ -26,7 +27,7 @@ fun DmScreen( onTitleClick: (() -> Unit)? = null, hideTitleBarAvatar: Boolean = false, onAvatarSlotBounds: ((Rect) -> Unit)? = null, - onTitleAvatarChange: ((ru.fromchat.ui.chat.AvatarInfo?) -> Unit)? = null, + onTitleAvatarChange: ((AvatarInfo?) -> Unit)? = null, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, sharedAvatarKey: Any? = null diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatPanel.kt similarity index 93% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatPanel.kt index f5db6ac..d452395 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatPanel.kt @@ -1,29 +1,34 @@ -package ru.fromchat.ui.chat +package ru.fromchat.ui.chat.panels.publicchat +import io.ktor.client.plugins.ClientRequestException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import io.ktor.client.plugins.ClientRequestException import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import ru.fromchat.Logger import ru.fromchat.api.ApiClient -import ru.fromchat.api.Message -import ru.fromchat.api.MessageDeletedData -import ru.fromchat.api.ReactionUpdateData -import ru.fromchat.api.SendMessageResponse -import ru.fromchat.api.TypingUpdateData -import ru.fromchat.api.WebSocketMessage -import ru.fromchat.api.WebSocketUpdatesData -import ru.fromchat.api.db.MessageCacheStore -import ru.fromchat.api.db.MessageRepository -import ru.fromchat.api.db.conversationIdForGroup -import ru.fromchat.api.db.GENERAL_PUBLIC_GROUP_ID -import ru.fromchat.api.outbox.OutgoingMessageCoordinator -import ru.fromchat.core.Logger +import ru.fromchat.api.local.cache.DecryptedImageCache +import ru.fromchat.api.local.db.store.MessageCacheStore +import ru.fromchat.api.local.db.store.MessageRepository +import ru.fromchat.api.local.messages.GENERAL_PUBLIC_GROUP_ID +import ru.fromchat.api.local.messages.conversationIdForGroup +import ru.fromchat.api.local.send.OutgoingMessageCoordinator +import ru.fromchat.api.schema.messages.Message +import ru.fromchat.api.schema.messages.publicchat.SendMessageResponse +import ru.fromchat.api.schema.websocket.WebSocketMessage +import ru.fromchat.api.schema.websocket.types.MessageDeletedData +import ru.fromchat.api.schema.websocket.types.ReactionUpdateData +import ru.fromchat.api.schema.websocket.types.TypingUpdateData +import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData +import ru.fromchat.ui.chat.AvatarInfo +import ru.fromchat.ui.chat.ChatPanel +import ru.fromchat.ui.chat.utils.PublicChatTypingHandler +import ru.fromchat.ui.chat.utils.TypingHandler class PublicChatPanel( - /** Stable cache / panel id (not localized; hardcoded in [PublicChatPanelCache]). */ + /** Stable cache / panel id (not localized; hardcoded in [ru.fromchat.ui.chat.utils.PublicChatPanelCache]). */ panelKey: String, /** Shown in the app bar and avatars. */ displayTitle: String, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatScreen.kt similarity index 73% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatScreen.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatScreen.kt index 230f9cb..e13e09d 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatScreen.kt @@ -1,4 +1,4 @@ -package ru.fromchat.ui.chat +package ru.fromchat.ui.chat.panels.publicchat import androidx.compose.animation.AnimatedContentScope import androidx.compose.animation.SharedTransitionScope @@ -11,10 +11,11 @@ import androidx.compose.runtime.remember import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res import ru.fromchat.api.ApiClient -import ru.fromchat.api.db.MessageRepository -import ru.fromchat.core.cache.CacheContext -import ru.fromchat.ui.isPublicChatVisible -import ru.fromchat.* +import ru.fromchat.api.local.cache.CacheContext +import ru.fromchat.api.local.db.store.MessageRepository +import ru.fromchat.public_chat +import ru.fromchat.ui.chat.ChatScreen +import ru.fromchat.ui.chat.utils.PublicChatPanelCache @Composable fun PublicChatScreen( @@ -63,3 +64,13 @@ fun PublicChatScreen( sharedAvatarKey = null, ) } + +/** + * Stable shared-element keys for NavHost predictive back–compatible transitions from + * a public-chat message row to [ru.fromchat.ui.profile.ProfileScreen]. + * One key per message so LazyColumn rows never duplicate keys for the same user. + */ +fun publicChatProfileSharedAvatarKey(userId: Int, sourceMessageId: Int): String = + "public-chat-profile-avatar-$userId-$sourceMessageId" + +var isPublicChatVisible = false \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentDownloadVisibility.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentDownloadVisibility.kt similarity index 90% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentDownloadVisibility.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentDownloadVisibility.kt index 2c1ffa5..0e19e61 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentDownloadVisibility.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentDownloadVisibility.kt @@ -1,10 +1,11 @@ -package ru.fromchat.ui.chat +package ru.fromchat.ui.chat.utils import androidx.compose.foundation.lazy.LazyListState import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import ru.fromchat.api.Message +import ru.fromchat.api.local.download.AttachmentDownloadScheduler +import ru.fromchat.api.schema.messages.Message /** Visible chat message ids (from [LazyListState]); used to prioritize attachment downloads. */ object AttachmentDownloadVisibility { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentFileDownload.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentFileDownload.kt similarity index 93% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentFileDownload.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentFileDownload.kt index c270c35..910a42e 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentFileDownload.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentFileDownload.kt @@ -1,4 +1,4 @@ -package ru.fromchat.ui.chat +package ru.fromchat.ui.chat.utils import androidx.compose.runtime.Composable diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentImageGeometry.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentImageGeometry.kt similarity index 91% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentImageGeometry.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentImageGeometry.kt index 6d67751..e9cf37c 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentImageGeometry.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentImageGeometry.kt @@ -1,8 +1,9 @@ -package ru.fromchat.ui.chat +package ru.fromchat.ui.chat.utils import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import ru.fromchat.api.local.db.aspectRatioFromDimensionPair +import ru.fromchat.api.local.download.ChatPreviewDecodeSize /** Bubble top radius (must match [ru.fromchat.ui.chat.MessageItem] bubble shape). */ private val BUBBLE_TOP = 20.dp @@ -36,7 +37,7 @@ internal fun imageAspectRatioForMessage( ): Float? { fileDimensions?.getOrNull(fileIndex)?.let { (w, h) -> if (w > 0 && h > 0) { - return ru.fromchat.api.db.aspectRatioFromDimensionPair(w, h) + return aspectRatioFromDimensionPair(w, h) } } fileAspectRatios?.getOrNull(fileIndex)?.takeIf { it > 0f }?.let { return it } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentOpenFeedback.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentOpenFeedback.kt similarity index 64% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentOpenFeedback.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentOpenFeedback.kt index 5354870..0bb5919 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentOpenFeedback.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentOpenFeedback.kt @@ -1,3 +1,3 @@ -package ru.fromchat.ui.chat +package ru.fromchat.ui.chat.utils internal expect fun showAttachmentOpenFailed(message: String) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentPickers.kt similarity index 97% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentPickers.kt index d1209e4..a4c13a7 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentPickers.kt @@ -1,4 +1,4 @@ -package ru.fromchat.ui.chat +package ru.fromchat.ui.chat.utils import androidx.compose.runtime.Composable diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageListDedup.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/MessageListDedup.kt similarity index 93% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageListDedup.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/MessageListDedup.kt index c10948d..2ab5651 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageListDedup.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/MessageListDedup.kt @@ -1,7 +1,8 @@ -package ru.fromchat.ui.chat +package ru.fromchat.ui.chat.utils -import ru.fromchat.api.Message -import ru.fromchat.api.parseMessageTimestampMillis +import ru.fromchat.api.local.messages.parseMessageTimestampMillis +import ru.fromchat.api.schema.messages.Message +import kotlin.math.abs /** One row per [Message.client_message_id]; prefers confirmed (id > 0) over optimistic. */ internal fun dedupeMessagesByClientId(messages: List): List { @@ -52,7 +53,7 @@ internal fun dropSupersededOptimisticMessages( val nearOwnConfirmed = confirmed.filter { it.user_id == self }.any { confirmedMsg -> val confirmedTime = parseMessageTimestampMillis(confirmedMsg.timestamp) msgTime != null && confirmedTime != null && - kotlin.math.abs(msgTime - confirmedTime) <= NEAR_DUPLICATE_MS + abs(msgTime - confirmedTime) <= NEAR_DUPLICATE_MS } if (!nearOwnConfirmed) return@filter true val pendingIsAttachment = !msg.files.isNullOrEmpty() diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageUiMerge.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/MessageUiMerge.kt similarity index 90% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageUiMerge.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/MessageUiMerge.kt index 33cbd79..a4abb9b 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageUiMerge.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/MessageUiMerge.kt @@ -1,9 +1,10 @@ -package ru.fromchat.ui.chat +package ru.fromchat.ui.chat.utils -import ru.fromchat.api.Message -import ru.fromchat.api.db.aspectRatioFromDimensionPair -import ru.fromchat.api.sortMessagesForChatDisplay -import ru.fromchat.ui.chat.DecryptedImageCache +import ru.fromchat.api.ApiClient +import ru.fromchat.api.local.cache.DecryptedImageCache +import ru.fromchat.api.local.db.aspectRatioFromDimensionPair +import ru.fromchat.api.local.messages.sortMessagesForChatDisplay +import ru.fromchat.api.schema.messages.Message /** * SQLDelight rows omit optimistic attachment fields; merge DB snapshot with in-memory UI state. @@ -35,7 +36,7 @@ internal fun mergeDatabaseMessagesWithPanelState( } return dedupeMessagesByClientId( - dropSupersededOptimisticMessages(mergedDb + extraPanel, ru.fromchat.api.ApiClient.user?.id), + dropSupersededOptimisticMessages(mergedDb + extraPanel, ApiClient.user?.id), ).let { sortMessagesForChatDisplay(it) } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanelCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/PublicChatPanelCache.kt similarity index 83% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanelCache.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/PublicChatPanelCache.kt index d900041..26cc8f1 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanelCache.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/PublicChatPanelCache.kt @@ -1,24 +1,26 @@ -package ru.fromchat.ui.chat +package ru.fromchat.ui.chat.utils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import ru.fromchat.core.cache.CacheContext +import ru.fromchat.api.local.cache.CacheContext +import ru.fromchat.ui.chat.panels.publicchat.PublicChatPanel /** - * Single retained [PublicChatPanel] for the app session (same idea as [ru.fromchat.ui.dm.DmPanelCache]). + * Single retained [PublicChatPanel] for the app session (same idea as [ru.fromchat.ui.chat.panels.dm.DmPanelCache]). * Navigating away from public chat used to dispose [remember] and recreate the panel, so every open * paid for cache + network + full list layout again; DM reuses cached panels and only [loadMessages] * when the list is still empty. * - * Uses a [SupervisorJob] scope instead of [androidx.compose.runtime.rememberCoroutineScope] so typing - * collectors and [ChatPanel] state callbacks keep working after the composable is left and re-entered. + * Uses a [kotlinx.coroutines.SupervisorJob] scope instead of [androidx.compose.runtime.rememberCoroutineScope] so typing + * collectors and [ru.fromchat.ui.chat.ChatPanel] state callbacks keep working after the composable is left and re-entered. */ object PublicChatPanelCache { private const val GeneralPublicPanelKey = "general" private var supervisorJob = SupervisorJob() - private var panelScope: CoroutineScope = CoroutineScope(supervisorJob + Dispatchers.Main.immediate) + private var panelScope: CoroutineScope = + CoroutineScope(supervisorJob + Dispatchers.Main.immediate) private var panel: PublicChatPanel? = null private var cachedPanelKey: String? = null @@ -83,4 +85,4 @@ object PublicChatPanelCache { cachedUserId = null supervisorJob.cancel() } -} +} \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/TypingHandler.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/TypingHandler.kt similarity index 99% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/TypingHandler.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/TypingHandler.kt index 199f095..1be11f7 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/TypingHandler.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/TypingHandler.kt @@ -1,4 +1,4 @@ -package ru.fromchat.ui.chat +package ru.fromchat.ui.chat.utils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ActionButton.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ActionButton.kt new file mode 100644 index 0000000..6a4e987 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ActionButton.kt @@ -0,0 +1,119 @@ +package ru.fromchat.ui.components + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun ActionButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + loading: Boolean = false, + interactionSource: MutableInteractionSource? = null, + content: @Composable (RowScope.() -> Unit) +) { + val showCtaAsPrimary = enabled || loading + val ctaTargetContainer = + if (showCtaAsPrimary) + MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.surfaceContainerHigh + val ctaTargetContent = + if (showCtaAsPrimary) + MaterialTheme.colorScheme.onPrimary + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + val ctaContainer by animateColorAsState( + ctaTargetContainer, + animationSpec = tween(durationMillis = 220), + label = "serverConfigCtaContainer", + ) + val ctaContent by animateColorAsState( + ctaTargetContent, + animationSpec = tween(durationMillis = 220), + label = "serverConfigCtaContent", + ) + + Button( + onClick = { + if (enabled) { + onClick() + } + }, + enabled = enabled, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 52.dp) + .then(modifier), + shape = CtaShape, + colors = ButtonDefaults.buttonColors( + containerColor = ctaContainer, + contentColor = ctaContent, + disabledContainerColor = ctaContainer, + disabledContentColor = ctaContent, + ), + elevation = ButtonDefaults.buttonElevation( + defaultElevation = 0.dp, + pressedElevation = 0.dp, + focusedElevation = 0.dp, + hoveredElevation = 0.dp, + disabledElevation = 0.dp, + ), + interactionSource = interactionSource + ) { + Box( + Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 24.dp), + contentAlignment = Alignment.Center, + ) { + AnimatedContent( + targetState = loading, + transitionSpec = { + (fadeIn(tween(200)) + slideInVertically { it / 4 }) togetherWith + (fadeOut(tween(200)) + slideOutVertically { -it / 4 }) + }, + label = "cta", + ) { loading -> + if (loading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + color = ctaContent, + ) + } else { + Row { + CompositionLocalProvider( + LocalTextAlign provides TextAlign.Center + ) { + content() + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/BackHandler.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/BackHandler.kt new file mode 100644 index 0000000..f7c788d --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/BackHandler.kt @@ -0,0 +1,28 @@ +package ru.fromchat.ui.components + +import androidx.compose.runtime.Composable + +/** + * Cross-platform hook for Android predictive back progress on the security password flow screen. + * + * - On Android, this ties into androidx.activity.compose.PredictiveBackHandler. + * - On other platforms, it is a no-op. + */ +@Composable +expect fun PredictiveBackHandler( + enabled: Boolean, + onProgress: (Float) -> Unit, + onCommit: () -> Unit, + onCancel: () -> Unit, +) + +/** + * Handles system back when [enabled]. When enabled, [onBack] is invoked instead of default back. + * On Android: uses BackHandler (supports predictive back when app opts in). + * On iOS: no-op. + */ +@Composable +expect fun BackHandler(enabled: Boolean, onBack: () -> Unit) + +@Composable +expect fun rememberHapticFeedbackInternal(): (Int) -> Unit diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/branding/FromChatBrandTitle.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/BrandTitle.kt similarity index 51% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/branding/FromChatBrandTitle.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/BrandTitle.kt index 2cc786d..e194793 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/branding/FromChatBrandTitle.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/BrandTitle.kt @@ -1,7 +1,7 @@ -package ru.fromchat.ui.branding +package ru.fromchat.ui.components import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.material3.Text +import ru.fromchat.ui.components.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -21,6 +21,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.pr0gramm3r101.utils.toPx import org.jetbrains.compose.resources.Font import ru.fromchat.Res import ru.fromchat.montserrat_cyrillic @@ -30,20 +31,7 @@ import ru.fromchat.montserrat_latin_ext import ru.fromchat.montserrat_vietnamese import kotlin.math.abs -/** - * Left → right gradient (like the web header reference), not stretched to parent width: - * [Modifier.wrapContentWidth] + brush [end] = measured text width from [onTextLayout]. - * - * Palette aligned with [Web/frontend/src/pages/chat/css/left-panel.module.scss] `.productName`. - */ -private val fromChatTitleGradientStops = arrayOf( - // 0f to Color(0xFF6366F1), - // 0.18f to Color(0xFF3B82F6), - // 0.38f to Color(0xFF9333EA), - // 0.55f to Color(0xFFA855F7), - // 0.72f to Color(0xFFD946EF), - // 0.88f to Color(0xFFEC4899), - // 1f to Color(0xFFC084FC), +val titleGradientStops = arrayOf( 0f to Color(0xFF6366F1), 0.2f to Color(0xFF3B82F6), 0.4f to Color(0xFF9333EA), @@ -53,55 +41,46 @@ private val fromChatTitleGradientStops = arrayOf( ) @Composable -private fun montserratBoldBrandFamily(): FontFamily = FontFamily( - // Latin first: Cyrillic-only subsets have no ASCII glyphs, so "FromChat" never used Montserrat. - Font(Res.font.montserrat_latin, FontWeight.Bold, FontStyle.Normal), - Font(Res.font.montserrat_latin_ext, FontWeight.Bold, FontStyle.Normal), - Font(Res.font.montserrat_cyrillic, FontWeight.Bold, FontStyle.Normal), - Font(Res.font.montserrat_cyrillic_ext, FontWeight.Bold, FontStyle.Normal), - Font(Res.font.montserrat_vietnamese, FontWeight.Bold, FontStyle.Normal), -) - -@Composable -fun FromChatBrandTitle( - text: String = "FromChat", - modifier: Modifier = Modifier, -) { +fun BrandTitle(modifier: Modifier = Modifier) { val density = LocalDensity.current - val fontFamily = montserratBoldBrandFamily() - var textDrawWidthPx by remember(text) { mutableFloatStateOf(0f) } - val gradientBrush = remember(text, textDrawWidthPx) { - val w = if (textDrawWidthPx > 1f) textDrawWidthPx else with(density) { 180.dp.toPx() } + var drawnTextWidth by remember { mutableFloatStateOf(0f) } + val brush = remember(drawnTextWidth) { Brush.linearGradient( - colorStops = fromChatTitleGradientStops, + colorStops = titleGradientStops, start = Offset.Zero, - end = Offset(w, 0f), + end = Offset(if (drawnTextWidth > 1f) drawnTextWidth else 180.dp.toPx(density), 0f), ) } - val shadowBlur = with(density) { 20.dp.toPx() } + Text( - text = text, + text = "FromChat", modifier = modifier.wrapContentWidth(align = Alignment.Start), maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false, onTextLayout = { layout -> val w = layout.size.width.toFloat() - if (abs(w - textDrawWidthPx) > 0.5f) { - textDrawWidthPx = w + if (abs(w - drawnTextWidth) > 0.5f) { + drawnTextWidth = w } }, style = TextStyle( - fontFamily = fontFamily, + fontFamily = FontFamily( + Font(Res.font.montserrat_latin, FontWeight.Bold, FontStyle.Normal), + Font(Res.font.montserrat_latin_ext, FontWeight.Bold, FontStyle.Normal), + Font(Res.font.montserrat_cyrillic, FontWeight.Bold, FontStyle.Normal), + Font(Res.font.montserrat_cyrillic_ext, FontWeight.Bold, FontStyle.Normal), + Font(Res.font.montserrat_vietnamese, FontWeight.Bold, FontStyle.Normal), + ), fontWeight = FontWeight.SemiBold, fontSize = 29.sp, lineHeight = 34.sp, - brush = gradientBrush, + brush = brush, shadow = Shadow( color = Color(0x809333EA), offset = Offset.Zero, - blurRadius = shadowBlur, - ), - ), + blurRadius = 20.dp.toPx(density), + ) + ) ) } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/ConnectingEllipsis.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ConnectingEllipsis.kt similarity index 74% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/ConnectingEllipsis.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ConnectingEllipsis.kt index a9f9e6f..34e1ccf 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/ConnectingEllipsis.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ConnectingEllipsis.kt @@ -1,11 +1,11 @@ -package ru.fromchat.ui +package ru.fromchat.ui.components import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Row import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.Text +import ru.fromchat.ui.components.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -32,21 +32,24 @@ fun ConnectingEllipsis( baseStyle: TextStyle = LocalTextStyle.current, stepMs: Long = 440, ) { - val merged = remember(fontSize, color, baseStyle) { - baseStyle.merge(TextStyle(fontSize = fontSize, color = color)) - } - var visibleDots by remember { mutableIntStateOf(0) } - LaunchedEffect(Unit) { - while (isActive) { - for (n in 1..3) { - visibleDots = n - delay(stepMs) - } - visibleDots = 0 - delay(stepMs / 2) - } - } Row(modifier = modifier, verticalAlignment = Alignment.Bottom) { + val merged = remember(fontSize, color, baseStyle) { + baseStyle.merge(TextStyle(fontSize = fontSize, color = color)) + } + + var visibleDots by remember { mutableIntStateOf(0) } + + LaunchedEffect(Unit) { + while (isActive) { + for (n in 1..3) { + visibleDots = n + delay(stepMs) + } + visibleDots = 0 + delay(stepMs / 2) + } + } + repeat(3) { i -> AnimatedVisibility( visible = i < visibleDots, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ExpressiveIconFrame.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ExpressiveIconFrame.kt new file mode 100644 index 0000000..a1cda80 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ExpressiveIconFrame.kt @@ -0,0 +1,52 @@ +package ru.fromchat.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.toShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.graphics.shapes.RoundedPolygon + +/** + * Large icon clipped with a Material expressive polygon ([MaterialShapes] + [toShape]). + * Default is a circle; pass another library polygon (e.g. cookie-sided) to match adjacent flows. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ExpressiveIconFrame( + icon: ImageVector, + modifier: Modifier = Modifier, + containerSize: Dp = 112.dp, + iconSize: Dp = 52.dp, + containerColor: Color = MaterialTheme.colorScheme.primaryContainer, + contentColor: Color = MaterialTheme.colorScheme.onPrimaryContainer, + materialPolygon: RoundedPolygon = MaterialShapes.Circle, +) { + val frameShape = materialPolygon.normalized().toShape() + Box( + modifier = modifier + .size(containerSize) + .clip(frameShape) + .background(containerColor), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(iconSize), + tint = contentColor + ) + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/HazeActionButton.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/HazeActionButton.kt new file mode 100644 index 0000000..42dac84 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/HazeActionButton.kt @@ -0,0 +1,63 @@ +package ru.fromchat.ui.components + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.chrisbanes.haze.HazeProgressive +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import dev.chrisbanes.haze.materials.HazeMaterials +import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding + +@OptIn(ExperimentalHazeMaterialsApi::class) +@Composable +fun HazeActionButton( + onClick: () -> Unit, + hazeState: HazeState, + modifier: Modifier = Modifier, + innerModifier: Modifier = Modifier, + enabled: Boolean = true, + loading: Boolean = false, + interactionSource: MutableInteractionSource? = null, + content: @Composable (RowScope.() -> Unit) +) { + Column( + modifier = Modifier + .windowInsetsPadding(WindowInsets.ime) + .fillMaxWidth() + .hazeEffect(state = hazeState, style = HazeMaterials.thin()) { + progressive = HazeProgressive.verticalGradient( + startIntensity = 0f, + endIntensity = 1f, + ) + } + .then(modifier), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = SettingsStepHorizontalPadding) + .padding(top = 0.dp, bottom = 16.dp), + ) { + ActionButton( + onClick = onClick, + modifier = innerModifier, + enabled = enabled, + loading = loading, + interactionSource = interactionSource, + content = content + ) + } + } +} \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/Components.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/RowHeader.kt similarity index 78% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/Components.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/RowHeader.kt index 5a42809..ff09032 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/Components.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/RowHeader.kt @@ -1,10 +1,13 @@ -package ru.fromchat.ui +package ru.fromchat.ui.components import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import ru.fromchat.ui.components.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp @@ -15,20 +18,22 @@ fun RowHeader( title: String, subtitle: String ) { - Column(horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally) { - androidx.compose.material3.Icon( + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( imageVector = icon, contentDescription = null, modifier = Modifier .padding(bottom = 8.dp) .size(30.dp), ) - androidx.compose.material3.Text( + + Text( text = title, style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(bottom = 8.dp) ) - androidx.compose.material3.Text( + + Text( text = subtitle, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(bottom = 16.dp) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/M3SearchBar.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/SearchBar.kt similarity index 80% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/M3SearchBar.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/SearchBar.kt index f891e1a..e55ca09 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/M3SearchBar.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/SearchBar.kt @@ -1,4 +1,4 @@ -package ru.fromchat.ui +package ru.fromchat.ui.components import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.SharedTransitionScope @@ -18,23 +18,24 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import androidx.compose.material3.Text +import ru.fromchat.ui.components.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp +import com.pr0gramm3r101.utils.conditional internal const val SearchBarSharedElement = "search_bar_shared_element" @Composable -fun M3SearchBar( +fun SearchBar( query: String, onQueryChange: (String) -> Unit, onSearch: () -> Unit, @@ -51,45 +52,43 @@ fun M3SearchBar( trailingIcon: @Composable () -> Unit = {}, ) { val focusRequester = remember { FocusRequester() } + val interactionSource = remember { MutableInteractionSource() } + val typography = MaterialTheme.typography.bodyLarge + LaunchedEffect(autoFocus, readOnly) { if (autoFocus && !readOnly) { focusRequester.requestFocus() } } - val interactionSource = remember { MutableInteractionSource() } - val onReadOnlyActivateOrNull = onReadOnlyActivate - val sharedTransitionModifier = if ( - sharedTransitionScope != null && - animatedVisibilityScope != null && - sharedElementKey != null - ) { - with(sharedTransitionScope) { - Modifier.sharedElement( - rememberSharedContentState(key = sharedElementKey), - animatedVisibilityScope = animatedVisibilityScope - ) - } - } else { - Modifier - } - - val containerModifier = if (readOnly && onReadOnlyActivateOrNull != null) { - modifier.clickable( - interactionSource = interactionSource, - indication = null, - onClick = onReadOnlyActivateOrNull - ) - } else { - modifier - } - val finalModifier = containerModifier - .then(sharedTransitionModifier) - .height(56.dp) - - val typography = MaterialTheme.typography.bodyLarge Surface( - modifier = finalModifier, + modifier = modifier + .conditional( + readOnly && onReadOnlyActivate != null, + `if` = { + clickable( + interactionSource = interactionSource, + indication = null, + onClick = onReadOnlyActivate!! + ) + } + ) + .conditional( + ( + sharedTransitionScope != null && + animatedVisibilityScope != null && + sharedElementKey != null + ), + `if` = { + with(sharedTransitionScope!!) { + sharedElement( + rememberSharedContentState(key = sharedElementKey!!), + animatedVisibilityScope = animatedVisibilityScope!! + ) + } + } + ) + .height(56.dp), shape = shape, color = MaterialTheme.colorScheme.surfaceContainerHigh, tonalElevation = 0.dp, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/FromChatSnackbarHost.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/SnackbarHost.kt similarity index 69% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/FromChatSnackbarHost.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/SnackbarHost.kt index 313fb73..38e5e89 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/FromChatSnackbarHost.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/SnackbarHost.kt @@ -1,4 +1,4 @@ -package ru.fromchat.ui +package ru.fromchat.ui.components import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Snackbar @@ -19,7 +19,6 @@ fun FromChatSnackbarHost( snackbarModifier: Modifier = Modifier, shape: Shape = SnackbarDefaults.shape, ) { - val scheme = MaterialTheme.colorScheme SnackbarHost( hostState = hostState, modifier = modifier, @@ -28,11 +27,11 @@ fun FromChatSnackbarHost( snackbarData = data, modifier = snackbarModifier, shape = shape, - containerColor = scheme.surfaceContainerHigh, - contentColor = scheme.onSurface, - actionColor = scheme.primary, - actionContentColor = scheme.primary, - dismissActionContentColor = scheme.onSurfaceVariant, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = MaterialTheme.colorScheme.onSurface, + actionColor = MaterialTheme.colorScheme.primary, + actionContentColor = MaterialTheme.colorScheme.primary, + dismissActionContentColor = MaterialTheme.colorScheme.onSurfaceVariant, ) } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/suspension/SuspendedAccountNotice.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/SuspendedAccountNotice.kt similarity index 99% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/suspension/SuspendedAccountNotice.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/SuspendedAccountNotice.kt index 47ff80b..c3d55cf 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/suspension/SuspendedAccountNotice.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/SuspendedAccountNotice.kt @@ -1,4 +1,4 @@ -package ru.fromchat.ui.suspension +package ru.fromchat.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -21,7 +21,7 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface import androidx.compose.material3.Icon import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.material3.Text +import ru.fromchat.ui.components.Text import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/TabBase.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/TabBase.kt similarity index 97% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/TabBase.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/TabBase.kt index f47be7e..e4cc8df 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/TabBase.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/TabBase.kt @@ -1,4 +1,4 @@ -package ru.fromchat.ui.main +package ru.fromchat.ui.components import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/Text.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/Text.kt new file mode 100644 index 0000000..c9a189b --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/Text.kt @@ -0,0 +1,199 @@ +package ru.fromchat.ui.components + +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorProducer +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.TextUnit + +val LocalTextAlign = compositionLocalOf { TextAlign.Start } + +@Composable +fun Text( + text: String, + color: ColorProducer, + modifier: Modifier = Modifier, + autoSize: TextAutoSize? = null, + fontSize: TextUnit = TextUnit.Unspecified, + fontStyle: FontStyle? = null, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + letterSpacing: TextUnit = TextUnit.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign? = null, + lineHeight: TextUnit = TextUnit.Unspecified, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + minLines: Int = 1, + onTextLayout: ((TextLayoutResult) -> Unit)? = null, + style: TextStyle = LocalTextStyle.current +) { + Text( + text = text, + color = color, + modifier = modifier, + autoSize = autoSize, + fontSize = fontSize, + fontStyle = fontStyle, + fontWeight = fontWeight, + fontFamily = fontFamily, + letterSpacing = letterSpacing, + textDecoration = textDecoration, + textAlign = textAlign ?: LocalTextAlign.current, + lineHeight = lineHeight, + overflow = overflow, + softWrap = softWrap, + maxLines = maxLines, + minLines = minLines, + onTextLayout = onTextLayout, + style = style + ) +} + +@Composable +fun Text( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + autoSize: TextAutoSize? = null, + fontSize: TextUnit = TextUnit.Unspecified, + fontStyle: FontStyle? = null, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + letterSpacing: TextUnit = TextUnit.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign? = null, + lineHeight: TextUnit = TextUnit.Unspecified, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + minLines: Int = 1, + onTextLayout: ((TextLayoutResult) -> Unit)? = null, + style: TextStyle = LocalTextStyle.current +) { + Text( + text = text, + modifier = modifier, + color = color, + autoSize = autoSize, + fontSize = fontSize, + fontStyle = fontStyle, + fontWeight = fontWeight, + fontFamily = fontFamily, + letterSpacing = letterSpacing, + textDecoration = textDecoration, + textAlign = textAlign ?: LocalTextAlign.current, + lineHeight = lineHeight, + overflow = overflow, + softWrap = softWrap, + maxLines = maxLines, + minLines = minLines, + onTextLayout = onTextLayout, + style = style + ) +} + +@Composable +fun Text( + text: AnnotatedString, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + autoSize: TextAutoSize? = null, + fontSize: TextUnit = TextUnit.Unspecified, + fontStyle: FontStyle? = null, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + letterSpacing: TextUnit = TextUnit.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign? = null, + lineHeight: TextUnit = TextUnit.Unspecified, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + minLines: Int = 1, + inlineContent: Map = mapOf(), + onTextLayout: (TextLayoutResult) -> Unit = {}, + style: TextStyle = LocalTextStyle.current, +) { + Text( + text = text, + modifier = modifier, + color = color, + autoSize = autoSize, + fontSize = fontSize, + fontStyle = fontStyle, + fontWeight = fontWeight, + fontFamily = fontFamily, + letterSpacing = letterSpacing, + textDecoration = textDecoration, + textAlign = textAlign ?: LocalTextAlign.current, + lineHeight = lineHeight, + overflow = overflow, + softWrap = softWrap, + maxLines = maxLines, + minLines = minLines, + inlineContent = inlineContent, + onTextLayout = onTextLayout, + style = style + ) +} + +@Composable +fun Text( + text: AnnotatedString, + color: ColorProducer, + modifier: Modifier = Modifier, + autoSize: TextAutoSize? = null, + fontSize: TextUnit = TextUnit.Unspecified, + fontStyle: FontStyle? = null, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + letterSpacing: TextUnit = TextUnit.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign? = null, + lineHeight: TextUnit = TextUnit.Unspecified, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + minLines: Int = 1, + inlineContent: Map = mapOf(), + onTextLayout: (TextLayoutResult) -> Unit = {}, + style: TextStyle = LocalTextStyle.current +) { + Text( + text = text, + color = color, + modifier = modifier, + autoSize = autoSize, + fontSize = fontSize, + fontStyle = fontStyle, + fontWeight = fontWeight, + fontFamily = fontFamily, + letterSpacing = letterSpacing, + textDecoration = textDecoration, + textAlign = textAlign ?: LocalTextAlign.current, + lineHeight = lineHeight, + overflow = overflow, + softWrap = softWrap, + maxLines = maxLines, + minLines = minLines, + inlineContent = inlineContent, + onTextLayout = onTextLayout, + style = style + ) +} \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/Typography.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/Typography.kt new file mode 100644 index 0000000..671db1e --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/Typography.kt @@ -0,0 +1,49 @@ +package ru.fromchat.ui.components + +import androidx.compose.material3.Typography +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import org.jetbrains.compose.resources.Font +import ru.fromchat.Res +import ru.fromchat.google_sans_bold +import ru.fromchat.google_sans_bold_italic +import ru.fromchat.google_sans_italic +import ru.fromchat.google_sans_medium +import ru.fromchat.google_sans_medium_italic +import ru.fromchat.google_sans_regular +import ru.fromchat.google_sans_semibold +import ru.fromchat.google_sans_semibold_italic + +@Composable +fun googleSansMaterialTypography() = Typography().let { + val family = FontFamily( + Font(Res.font.google_sans_regular, FontWeight.Normal, FontStyle.Normal), + Font(Res.font.google_sans_italic, FontWeight.Normal, FontStyle.Italic), + Font(Res.font.google_sans_medium, FontWeight.Medium, FontStyle.Normal), + Font(Res.font.google_sans_medium_italic, FontWeight.Medium, FontStyle.Italic), + Font(Res.font.google_sans_semibold, FontWeight.SemiBold, FontStyle.Normal), + Font(Res.font.google_sans_semibold_italic, FontWeight.SemiBold, FontStyle.Italic), + Font(Res.font.google_sans_bold, FontWeight.Bold, FontStyle.Normal), + Font(Res.font.google_sans_bold_italic, FontWeight.Bold, FontStyle.Italic), + ) + + it.copy( + displayLarge = it.displayLarge.copy(fontFamily = family), + displayMedium = it.displayMedium.copy(fontFamily = family), + displaySmall = it.displaySmall.copy(fontFamily = family), + headlineLarge = it.headlineLarge.copy(fontFamily = family), + headlineMedium = it.headlineMedium.copy(fontFamily = family), + headlineSmall = it.headlineSmall.copy(fontFamily = family), + titleLarge = it.titleLarge.copy(fontFamily = family), + titleMedium = it.titleMedium.copy(fontFamily = family), + titleSmall = it.titleSmall.copy(fontFamily = family), + bodyLarge = it.bodyLarge.copy(fontFamily = family), + bodyMedium = it.bodyMedium.copy(fontFamily = family), + bodySmall = it.bodySmall.copy(fontFamily = family), + labelLarge = it.labelLarge.copy(fontFamily = family), + labelMedium = it.labelMedium.copy(fontFamily = family), + labelSmall = it.labelSmall.copy(fontFamily = family), + ) +} \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/Variables.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/Variables.kt new file mode 100644 index 0000000..b5806e6 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/Variables.kt @@ -0,0 +1,5 @@ +package ru.fromchat.ui.components + +import androidx.compose.foundation.shape.RoundedCornerShape + +val CtaShape = RoundedCornerShape(percent = 38) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.kt deleted file mode 100644 index 07f934f..0000000 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.kt +++ /dev/null @@ -1,7 +0,0 @@ -package ru.fromchat.ui.debug - -import androidx.compose.runtime.Composable - -@Composable -expect fun DebugApiScreen() - diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/debug/DebugApiScreenContent.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/debug/DebugApiScreenContent.kt deleted file mode 100644 index d38adff..0000000 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/debug/DebugApiScreenContent.kt +++ /dev/null @@ -1,465 +0,0 @@ -package ru.fromchat.ui.debug - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.BugReport -import androidx.compose.material3.Button -import androidx.compose.material3.DividerDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.pr0gramm3r101.utils.crypto.Base64 -import com.pr0gramm3r101.utils.settings.settings -import kotlinx.coroutines.launch -import ru.fromchat.api.ApiClient -import ru.fromchat.api.SendDmFile -import ru.fromchat.crypto.IdentityKeyManager -import ru.fromchat.crypto.decryptEnvelope -import ru.fromchat.crypto.transport.TransportCrypto -import ru.fromchat.ui.LocalNavController -import ru.fromchat.ui.dm.DmNav - -/** - * English-only copy: the Debug API screen is not for end users; strings are not in compose - * resources (see project i18n rules). - */ -private object DebugScreenText { - const val ScreenTitle = "Debug API" - const val NavSection = "Quick navigation" - const val OpenProfile = "Profile screen" - const val OpenProfileDesc = "Your profile with chat and link actions" - const val OpenDmUser2 = "DM (user 2)" - const val OpenDmUser2Desc = "Open a direct message with user ID 2" - const val SearchBarDocs = "SearchBar docs" - const val SearchBarDocsDesc = "Official Material3 SearchBar snippet in an isolated screen" - const val BugIconCd = "Debug" - const val StatusPlaceholder = "Status will appear here" - const val UnknownError = "Something went wrong. Please try again." - const val ProfileLoaded = "Profile loaded" - const val LoadOwnProfile = "Load own profile" - const val NotLoaded = "No data loaded yet" - const val ConversationsLoaded = "Conversations loaded" - const val LoadConversations = "Load DM conversations" - const val HistoryUserIdLabel = "History user ID" - const val InvalidUserId = "Enter a valid user ID" - const val HistoryLoaded = "DM history loaded" - const val LoadHistory = "Load DM history" - const val DmSendTest = "DM send test" - const val RecipientUserId = "Recipient user ID" - const val MessageText = "Message text" - const val InvalidRecipient = "Enter a valid recipient ID" - const val EnterMessage = "Enter a message to send" - const val DmSent = "DM sent" - const val DmSendFailed = "Failed to send DM" - const val SendDm = "Send DM" - const val Phase1FileProtocol = "Phase 1: file protocol test" - const val SendTestFileBtn = "Send test.txt to user 2" - const val ProtocolSent = "Protocol test file sent to user 2" - const val ProtocolFailed = "Failed to send protocol test file" - const val DecryptTest = "DM decryption test" - const val DecryptFirstDm = "Decrypt first DM" - const val NoMessages = "No messages" - const val CurrentUserMissing = "Current user ID not found" - const val DecryptedPrefix = "Decrypted: " - const val DecryptSuccess = "Decryption successful" - const val DecryptFailedPrefix = "Decryption failed: " - const val ErrorLabel = "Error: " - const val NoDecryptedYet = "No decrypted message yet" - const val OpenAction = "Open" -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun DebugApiScreenContent() { - val navController = LocalNavController.current - val scope = rememberCoroutineScope() - val scrollState = rememberScrollState() - val currentUserId = ApiClient.user?.id ?: 0 - - var statusMessage by rememberSaveable { mutableStateOf("") } - var profileResult by rememberSaveable { mutableStateOf(null) } - var conversationsResult by rememberSaveable { mutableStateOf(null) } - var historyResult by rememberSaveable { mutableStateOf(null) } - var historyUserId by rememberSaveable { mutableStateOf("0") } - var decryptedMessage by rememberSaveable { mutableStateOf(null) } - var sendRecipientId by rememberSaveable { mutableStateOf("") } - var sendMessageText by rememberSaveable { mutableStateOf("") } - - Scaffold( - topBar = { - TopAppBar( - navigationIcon = { - IconButton(onClick = { navController.navigateUp() }) { - Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) - } - }, - title = { - Text(text = DebugScreenText.ScreenTitle) - }, - actions = { - IconButton(onClick = {}) { - Icon( - imageVector = Icons.Filled.BugReport, - contentDescription = DebugScreenText.BugIconCd - ) - } - } - ) - } - ) { innerPadding -> - Column( - modifier = Modifier - .padding(innerPadding) - .verticalScroll(scrollState) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - text = DebugScreenText.NavSection, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.fillMaxWidth() - ) - Text( - text = DebugScreenText.OpenProfile, - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.fillMaxWidth() - ) - Text( - text = DebugScreenText.OpenProfileDesc, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.fillMaxWidth() - ) - Button( - onClick = { - if (currentUserId != 0) { - navController.navigate("profile/$currentUserId") - } - }, - enabled = currentUserId != 0, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = DebugScreenText.OpenAction) - } - Text( - text = DebugScreenText.OpenDmUser2, - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.fillMaxWidth() - ) - Text( - text = DebugScreenText.OpenDmUser2Desc, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.fillMaxWidth() - ) - Button( - onClick = { navController.navigate(DmNav.chatRoute(2)) }, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = DebugScreenText.OpenAction) - } - - Text( - text = DebugScreenText.SearchBarDocs, - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.fillMaxWidth() - ) - Text( - text = DebugScreenText.SearchBarDocsDesc, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.fillMaxWidth() - ) - Button( - onClick = { navController.navigate("debug/searchbar-docs") }, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = DebugScreenText.OpenAction) - } - - HorizontalDivider( - modifier = Modifier.padding(vertical = 8.dp), - thickness = DividerDefaults.Thickness, - color = DividerDefaults.color - ) - - Text( - text = statusMessage.takeIf { it.isNotBlank() } ?: DebugScreenText.StatusPlaceholder, - style = MaterialTheme.typography.bodyMedium - ) - - Button( - onClick = { - scope.launch { - runCatching { ApiClient.getOwnProfile() } - .onSuccess { - profileResult = ApiClient.json.encodeToString(it) - statusMessage = DebugScreenText.ProfileLoaded - } - .onFailure { - statusMessage = it.message ?: DebugScreenText.UnknownError - } - } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = DebugScreenText.LoadOwnProfile) - } - - Text( - text = profileResult ?: DebugScreenText.NotLoaded, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.fillMaxWidth() - ) - - Button( - onClick = { - scope.launch { - runCatching { ApiClient.getDmConversations() } - .onSuccess { - conversationsResult = ApiClient.json.encodeToString(it) - statusMessage = DebugScreenText.ConversationsLoaded - } - .onFailure { - statusMessage = it.message ?: DebugScreenText.UnknownError - } - } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = DebugScreenText.LoadConversations) - } - - Text( - text = conversationsResult ?: DebugScreenText.NotLoaded, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.fillMaxWidth() - ) - - OutlinedTextField( - value = historyUserId, - onValueChange = { historyUserId = it }, - label = { Text(text = DebugScreenText.HistoryUserIdLabel) }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - - Button( - onClick = { - val userId = historyUserId.toIntOrNull() - if (userId == null) { - statusMessage = DebugScreenText.InvalidUserId - return@Button - } - - scope.launch { - runCatching { ApiClient.getDmHistory(userId) } - .onSuccess { - historyResult = ApiClient.json.encodeToString(it) - statusMessage = DebugScreenText.HistoryLoaded - } - .onFailure { - statusMessage = it.message ?: DebugScreenText.UnknownError - } - } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = DebugScreenText.LoadHistory) - } - - Text( - text = historyResult ?: DebugScreenText.NotLoaded, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.fillMaxWidth() - ) - - HorizontalDivider( - modifier = Modifier.padding(vertical = 8.dp), - thickness = DividerDefaults.Thickness, - color = DividerDefaults.color - ) - - Text( - text = DebugScreenText.DmSendTest, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.fillMaxWidth() - ) - - OutlinedTextField( - value = sendRecipientId, - onValueChange = { sendRecipientId = it }, - label = { Text(text = DebugScreenText.RecipientUserId) }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - - OutlinedTextField( - value = sendMessageText, - onValueChange = { sendMessageText = it }, - label = { Text(text = DebugScreenText.MessageText) }, - singleLine = false, - modifier = Modifier.fillMaxWidth() - ) - - Button( - onClick = { - val userId = sendRecipientId.toIntOrNull() - if (userId == null) { - statusMessage = DebugScreenText.InvalidRecipient - return@Button - } - if (sendMessageText.isBlank()) { - statusMessage = DebugScreenText.EnterMessage - return@Button - } - - scope.launch { - runCatching { - ApiClient.sendDm( - recipientId = userId, - plaintext = sendMessageText.trim(), - replyToId = null - ) - }.onSuccess { - statusMessage = DebugScreenText.DmSent - }.onFailure { - statusMessage = it.message ?: DebugScreenText.DmSendFailed - } - } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = DebugScreenText.SendDm) - } - - Text( - text = DebugScreenText.Phase1FileProtocol, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.fillMaxWidth() - ) - - Button( - onClick = { - scope.launch { - runCatching { - val fileBytes = "test file".encodeToByteArray() - val transportKey = ApiClient.getTransportPublicKey() - val (msgCipher, secret) = TransportCrypto.encryptWithTransportKeyWithEphemeralSecret( - plaintext = "test file", - transportPublicKeyB64 = transportKey.publicKeyB64 - ) - try { - val transportBlob = TransportCrypto.encryptFileForTransport( - fileBytes = fileBytes, - transportPublicKeyB64 = transportKey.publicKeyB64, - ephemeralSecretKey = secret - ) - val sendFile = SendDmFile( - encryptedFileDataB64 = Base64.encode(transportBlob), - filename = "test.txt", - fileSize = fileBytes.size.toLong() - ) - ApiClient.sendDm( - recipientId = 2, - plaintext = "test file", - transportFiles = listOf(sendFile), - preparedTransport = msgCipher - ) - } finally { - secret.fill(0) - } - }.onSuccess { - statusMessage = DebugScreenText.ProtocolSent - }.onFailure { - statusMessage = it.message ?: DebugScreenText.ProtocolFailed - } - } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = DebugScreenText.SendTestFileBtn) - } - - HorizontalDivider( - modifier = Modifier.padding(vertical = 8.dp), - thickness = DividerDefaults.Thickness, - color = DividerDefaults.color - ) - - Text( - text = DebugScreenText.DecryptTest, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.fillMaxWidth() - ) - - Button( - onClick = { - val userId = historyUserId.toIntOrNull() - if (userId == null) { - statusMessage = DebugScreenText.InvalidUserId - return@Button - } - - scope.launch { - try { - IdentityKeyManager.restoreFromLocal() - - val history = ApiClient.getDmHistory(userId) - if (history.messages.isEmpty()) { - statusMessage = DebugScreenText.NoMessages - decryptedMessage = null - return@launch - } - - val currentId = settings.getInt("current_user_id", 0) - if (currentId == 0) { - statusMessage = DebugScreenText.CurrentUserMissing - decryptedMessage = null - return@launch - } - - val firstEnvelope = history.messages.first() - val plaintext = decryptEnvelope(firstEnvelope, currentId) - decryptedMessage = DebugScreenText.DecryptedPrefix + plaintext - statusMessage = DebugScreenText.DecryptSuccess - } catch (e: Exception) { - statusMessage = DebugScreenText.DecryptFailedPrefix + (e.message.orEmpty()) - decryptedMessage = - DebugScreenText.ErrorLabel + (e.message.orEmpty()) + "\n" + e.stackTraceToString() - } - } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = DebugScreenText.DecryptFirstDm) - } - - Text( - text = decryptedMessage ?: DebugScreenText.NoDecryptedYet, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.fillMaxWidth() - ) - } - } -} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/debug/DebugApiSearchBarDocsScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/debug/DebugApiSearchBarDocsScreen.kt deleted file mode 100644 index b8b5d57..0000000 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/debug/DebugApiSearchBarDocsScreen.kt +++ /dev/null @@ -1,112 +0,0 @@ -package ru.fromchat.ui.debug - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Clear -import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.compose.ui.tooling.preview.Preview - -import ru.fromchat.ui.M3SearchBar - -@Composable -fun DebugSearchBarDocsScreen() { - SearchBarExamples() -} - -@Preview(showBackground = true) -@Composable -fun SearchBarExamples() { - val allItems = remember { - listOf( - "Cupcake", - "Donut", - "Eclair", - "Froyo", - "Honeycomb", - "Ice Cream Sandwich", - "Jelly Bean", - "KitKat", - "Lollipop", - "Marshmallow", - "Nougat", - "Oreo", - "Pie" - ) - } - - var query by remember { mutableStateOf("") } - val filtered by remember { - derivedStateOf { - val searchText = query.trim().lowercase() - if (searchText.isEmpty()) { - allItems - } else { - allItems.filter { it.lowercase().contains(searchText) } - } - } - } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - M3SearchBar( - query = query, - onQueryChange = { query = it }, - onSearch = {}, - placeholder = "Search", - leadingIcon = { - Icon( - imageVector = Icons.Default.Search, - contentDescription = null - ) - }, - trailingIcon = { - if (query.isNotBlank()) { - IconButton(onClick = { query = "" }) { - Icon( - imageVector = Icons.Default.Clear, - contentDescription = "Clear" - ) - } - } - }, - modifier = Modifier.fillMaxWidth() - ) - - Text("Results") - - LazyColumn( - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(bottom = 16.dp) - ) { - items(filtered) { item -> - Text( - text = item, - modifier = Modifier.padding(vertical = 6.dp) - ) - } - } - } -} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ContactsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ContactsTab.kt index 93d5bae..3325429 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ContactsTab.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ContactsTab.kt @@ -19,7 +19,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface -import androidx.compose.material3.Text +import ru.fromchat.ui.components.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt index a30013d..ce8f6e0 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt @@ -2,9 +2,9 @@ package ru.fromchat.ui.main import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding @@ -16,27 +16,31 @@ import androidx.compose.material.icons.automirrored.filled.Chat import androidx.compose.material.icons.filled.Contacts import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text import androidx.compose.material3.SnackbarHostState +import ru.fromchat.ui.components.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource -import ru.fromchat.* +import ru.fromchat.Res import ru.fromchat.api.ApiClient -import ru.fromchat.ui.FromChatSnackbarHost +import ru.fromchat.chats +import ru.fromchat.contacts +import ru.fromchat.profile +import ru.fromchat.settings import ru.fromchat.ui.LocalNavController -import ru.fromchat.utils.exclude +import ru.fromchat.ui.components.FromChatSnackbarHost +import ru.fromchat.ui.main.chats.ChatsTab import ru.fromchat.ui.main.settings.SettingsTab import ru.fromchat.ui.profile.ProfileScreen -import kotlinx.coroutines.launch +import com.pr0gramm3r101.utils.exclude private const val PAGE_CHATS = 0 private const val PAGE_CONTACTS = 1 @@ -44,7 +48,6 @@ private const val PAGE_SETTINGS = 2 private const val PAGE_PROFILE = 3 private const val PAGE_COUNT = 4 -@Suppress("AssignedValueIsNeverRead") @OptIn(ExperimentalMaterial3Api::class) @Composable fun MainScreen( @@ -127,9 +130,8 @@ fun MainScreen( PAGE_CONTACTS -> ContactsTab() PAGE_SETTINGS -> SettingsTab() PAGE_PROFILE -> { - val currentUserId = ApiClient.user?.id ProfileScreen( - userId = currentUserId, + userId = ApiClient.user?.id, onBack = {}, onChat = { _ -> }, modifier = Modifier.fillMaxSize(), diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsSearchScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSearchScreen.kt similarity index 92% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsSearchScreen.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSearchScreen.kt index 82d1426..deb9b80 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsSearchScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSearchScreen.kt @@ -1,4 +1,4 @@ -package ru.fromchat.ui.main +package ru.fromchat.ui.main.chats import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibilityScope @@ -26,14 +26,13 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Search import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.material3.toShape import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -58,19 +57,20 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res import ru.fromchat.api.ApiClient -import ru.fromchat.api.ProfileCache -import ru.fromchat.api.UserStatusStore -import ru.fromchat.api.db.CachedConversation -import ru.fromchat.api.db.MessageCacheStore +import ru.fromchat.api.local.db.store.CachedConversation +import ru.fromchat.api.local.db.store.MessageCacheStore +import ru.fromchat.api.local.db.store.ProfileCache +import ru.fromchat.api.local.db.store.UserStatusStore +import ru.fromchat.api.local.db.store.visibleUsername import ru.fromchat.chat_last_mesaage -import ru.fromchat.resolveSearchHintResource -import ru.fromchat.resolveSearchTitleResource -import ru.fromchat.resolveSearchNotFoundMessageResource -import ru.fromchat.resolveSearchNotFoundTitleResource -import ru.fromchat.api.visibleUsername -import ru.fromchat.ui.BackHandler -import ru.fromchat.ui.M3SearchBar -import ru.fromchat.ui.SearchBarSharedElement +import ru.fromchat.search_hint +import ru.fromchat.search_not_found +import ru.fromchat.search_not_found_message +import ru.fromchat.search_title +import ru.fromchat.ui.components.BackHandler +import ru.fromchat.ui.components.SearchBar +import ru.fromchat.ui.components.SearchBarSharedElement +import ru.fromchat.ui.components.Text @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -85,8 +85,8 @@ fun ChatsSearchScreen( val searchListState = rememberLazyListState() val statusMap by UserStatusStore.status.collectAsState() val defaultLastMessage = stringResource(Res.string.chat_last_mesaage) - val searchHint = stringResource(resolveSearchHintResource()) - val searchBarHint = stringResource(resolveSearchTitleResource()) + val searchHint = stringResource(Res.string.search_hint) + val searchBarHint = stringResource(Res.string.search_title) var dmConversations by remember { mutableStateOf>(emptyList()) } val statusSubscriptionScope = rememberCoroutineScope() var subscribedDmUserIds by remember { mutableStateOf>(emptySet()) } @@ -180,7 +180,7 @@ fun ChatsSearchScreen( .windowInsetsPadding(WindowInsets.safeDrawing), contentWindowInsets = WindowInsets.safeDrawing, topBar = { - M3SearchBar( + SearchBar( query = searchText, onQueryChange = { searchText = it }, onSearch = {}, @@ -226,7 +226,7 @@ fun ChatsSearchScreen( when (state) { SearchScreenState.EmptyQuery -> { SearchScreenInfo( - title = stringResource(resolveSearchTitleResource()), + title = stringResource(Res.string.search_title), subtitle = searchHint, showSearchIcon = true ) @@ -234,8 +234,8 @@ fun ChatsSearchScreen( SearchScreenState.NotFound -> { SearchScreenInfo( - title = stringResource(resolveSearchNotFoundTitleResource()), - subtitle = stringResource(resolveSearchNotFoundMessageResource()), + title = stringResource(Res.string.search_not_found), + subtitle = stringResource(Res.string.search_not_found_message), showSearchIcon = false ) } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt similarity index 92% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt index d2d97e9..d0ad335 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt @@ -1,8 +1,8 @@ -package ru.fromchat.ui.main +package ru.fromchat.ui.main.chats +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.SharedTransitionScope -import androidx.compose.animation.AnimatedContent import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically @@ -28,12 +28,11 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -57,37 +56,38 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res import ru.fromchat.api.ApiClient -import ru.fromchat.api.ConnectionStateStore -import ru.fromchat.api.ConnectionStatus -import ru.fromchat.api.ProfileCache -import ru.fromchat.api.UserStatus -import ru.fromchat.api.UserStatusStore -import ru.fromchat.api.db.CachedConversation -import ru.fromchat.api.db.MessageCacheStore -import ru.fromchat.api.db.MessageRepository -import ru.fromchat.core.cache.CacheContext -import ru.fromchat.core.config.Config -import ru.fromchat.app_name +import ru.fromchat.api.local.cache.CacheContext +import ru.fromchat.api.local.db.store.CachedConversation +import ru.fromchat.api.local.db.store.ConnectionStateStore +import ru.fromchat.api.local.db.store.ConnectionStatus +import ru.fromchat.api.local.db.store.MessageRepository +import ru.fromchat.api.local.db.store.ProfileCache +import ru.fromchat.api.local.db.store.UserStatus +import ru.fromchat.api.local.db.store.UserStatusStore +import ru.fromchat.api.local.db.store.visibleUsername import ru.fromchat.chat_last_mesaage -import ru.fromchat.net.NetworkConnectivity -import ru.fromchat.resolveSearchTitleResource +import ru.fromchat.config.ServerConfig import ru.fromchat.presence_online import ru.fromchat.public_chat +import ru.fromchat.search_title import ru.fromchat.status_connecting import ru.fromchat.status_updating -import ru.fromchat.ui.ConnectingEllipsis +import ru.fromchat.suspend_chat_banner_message +import ru.fromchat.suspended_default_reason import ru.fromchat.ui.LocalNavController -import ru.fromchat.ui.M3SearchBar -import ru.fromchat.ui.SearchBarSharedElement -import ru.fromchat.ui.branding.FromChatBrandTitle import ru.fromchat.ui.chat.Avatar import ru.fromchat.ui.chat.TypingIndicator -import ru.fromchat.ui.suspension.* -import ru.fromchat.ui.dm.DmNav +import ru.fromchat.ui.chat.panels.dm.DmNav +import ru.fromchat.ui.components.BrandTitle +import ru.fromchat.ui.components.ConnectingEllipsis +import ru.fromchat.ui.components.SearchBar +import ru.fromchat.ui.components.SearchBarSharedElement +import ru.fromchat.ui.components.SuspendedAccountBannerStyle +import ru.fromchat.ui.components.SuspendedAccountNoticeHost +import ru.fromchat.ui.components.Text import ru.fromchat.unread_count import ru.fromchat.user_fallback -import ru.fromchat.api.visibleUsername -import ru.fromchat.* +import ru.fromchat.utils.NetworkConnectivity @Composable private fun ChatRowAvatar( @@ -127,7 +127,7 @@ fun ChatsTab( val online by NetworkConnectivity.isOnline.collectAsState(initial = true) var dmConversations by remember { mutableStateOf>(emptyList()) } var publicLastMessagePreview by remember { mutableStateOf(null) } - val searchBarHint = stringResource(resolveSearchTitleResource()) + val searchBarHint = stringResource(Res.string.search_title) val tabListState = rememberLazyListState() val statusMap by UserStatusStore.status.collectAsState() var subscribedDmUserIds by remember { mutableStateOf>(emptySet()) } @@ -138,7 +138,7 @@ fun ChatsTab( LaunchedEffect(dmConversations, tabListState, isVisible, onOpenSearch) { snapshotFlow { if (!isVisible) { - emptySet() + emptySet() } else { tabListState.layoutInfo.visibleItemsInfo .mapNotNull { item -> @@ -175,7 +175,7 @@ fun ChatsTab( } } - val serverConfig by Config.serverConfig.collectAsState() + val serverConfig by ServerConfig.serverConfig.collectAsState() val activeInstanceId by CacheContext.activeInstanceId.collectAsState() LaunchedEffect(serverConfig, activeInstanceId) { @@ -207,7 +207,6 @@ fun ChatsTab( val connectingTitle = stringResource(Res.string.status_connecting) val updatingTitle = stringResource(Res.string.status_updating) - val brandTitle = stringResource(Res.string.app_name) val defaultLastMessage = stringResource(Res.string.chat_last_mesaage) val suspendBannerTitle = stringResource(Res.string.suspend_chat_banner_message) val suspendDefaultReason = stringResource(Res.string.suspended_default_reason) @@ -292,7 +291,7 @@ fun ChatsTab( } } else -> { - FromChatBrandTitle(text = brandTitle) + BrandTitle() } } } @@ -307,7 +306,7 @@ fun ChatsTab( .fillMaxSize() .padding(innerPadding) ) { - M3SearchBar( + SearchBar( query = "", onQueryChange = {}, onSearch = {}, @@ -523,17 +522,4 @@ private fun DmConversationRow( onOpenConversation(conversation.otherUserId) } ) -} - -private fun matchesDmSearch(conv: CachedConversation, normalizedQuery: String): Boolean { - val cached = ProfileCache.get(conv.otherUserId) - val candidates = buildList { - add(conv.displayName) - cached?.displayName?.let { add(it) } - cached?.visibleUsername(ApiClient.user?.id)?.let { add(it) } - } - - return candidates.any { candidate -> - candidate.lowercase().contains(normalizedQuery) - } } \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTabBannerHost.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTabBannerHost.kt similarity index 98% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTabBannerHost.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTabBannerHost.kt index 8af4fb5..0469ed7 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTabBannerHost.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTabBannerHost.kt @@ -1,12 +1,12 @@ -package ru.fromchat.ui.main +package ru.fromchat.ui.main.chats import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.clickable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close @@ -14,7 +14,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import androidx.compose.material3.Text +import ru.fromchat.ui.components.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.remember diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/AboutScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AboutScreen.kt similarity index 75% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/AboutScreen.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AboutScreen.kt index f143f86..e28a0c0 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/AboutScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AboutScreen.kt @@ -1,4 +1,4 @@ -package ru.fromchat.ui +package ru.fromchat.ui.main.settings import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -10,14 +10,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.outlined.BugReport import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text +import ru.fromchat.ui.components.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable @@ -34,7 +33,6 @@ import coil3.compose.AsyncImage import com.pr0gramm3r101.components.Category import com.pr0gramm3r101.components.ListItem import com.pr0gramm3r101.ui.Website -import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.vectorResource import ru.fromchat.Res @@ -44,30 +42,14 @@ import ru.fromchat.about_link_telegram import ru.fromchat.about_link_website import ru.fromchat.about_version import ru.fromchat.app_desc -import ru.fromchat.app_name -import ru.fromchat.debug_tools -import ru.fromchat.debug_tools_d import ru.fromchat.back -import ru.fromchat.ui.branding.FromChatBrandTitle +import ru.fromchat.ui.LocalNavController +import ru.fromchat.ui.components.BrandTitle -/** - * About is a root [androidx.navigation.NavHost] destination; system and predictive back (see - * manifest `enableOnBackInvokedCallback`) pop via the same stack as [LocalNavController.navigateUp]. - */ private const val URL_TELEGRAM = "https://t.me/fromchat_ch" private const val URL_MAX = "https://maxgate.io/fromchat_ch" private const val URL_WEBSITE = "https://fromchat.ru" -@Composable -private fun AboutBrandListIcon(drawable: DrawableResource) { - Icon( - imageVector = vectorResource(drawable), - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) -} - @OptIn(ExperimentalMaterial3Api::class) @Composable fun AboutScreen() { @@ -120,16 +102,16 @@ fun AboutScreen() { .clip(RoundedCornerShape(28.dp)) ) } - FromChatBrandTitle( - text = stringResource(Res.string.app_name), - modifier = Modifier.padding(bottom = 4.dp), - ) + + BrandTitle(Modifier.padding(bottom = 4.dp)) + Text( text = stringResource(Res.string.about_version), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 16.dp) ) + Text( text = stringResource(Res.string.app_desc), textAlign = TextAlign.Center, @@ -145,23 +127,21 @@ fun AboutScreen() { supportingText = URL_TELEGRAM, onClick = { uriHandler.openUri(URL_TELEGRAM) }, divider = true, - dividerColor = MaterialTheme.colorScheme.surface, - dividerThickness = 2.dp, leadingContent = { - AboutBrandListIcon(Res.drawable.about_link_telegram) + Icon(vectorResource(Res.drawable.about_link_telegram), null) } ) + ListItem( headline = stringResource(Res.string.about_link_max), supportingText = URL_MAX, onClick = { uriHandler.openUri(URL_MAX) }, divider = true, - dividerColor = MaterialTheme.colorScheme.surface, - dividerThickness = 2.dp, leadingContent = { - AboutBrandListIcon(Res.drawable.about_link_max) + Icon(vectorResource(Res.drawable.about_link_max), null) } ) + ListItem( headline = stringResource(Res.string.about_link_website), supportingText = URL_WEBSITE, @@ -175,19 +155,6 @@ fun AboutScreen() { ) } ) - ListItem( - headline = stringResource(Res.string.debug_tools), - supportingText = stringResource(Res.string.debug_tools_d), - onClick = { navController.navigate("debug") }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.BugReport, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - ) } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AppearanceScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AppearanceScreen.kt new file mode 100644 index 0000000..0dabee5 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AppearanceScreen.kt @@ -0,0 +1,148 @@ +package ru.fromchat.ui.main.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Brush +import androidx.compose.material.icons.filled.DarkMode +import androidx.compose.material.icons.filled.LightMode +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Wallpaper +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MediumTopAppBar +import androidx.compose.material3.Scaffold +import ru.fromchat.ui.components.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.pr0gramm3r101.components.Category +import com.pr0gramm3r101.components.ListItem +import com.pr0gramm3r101.components.SwitchListItem +import com.pr0gramm3r101.utils.materialYouAvailable +import org.jetbrains.compose.resources.stringResource +import ru.fromchat.Res +import ru.fromchat.as_system +import ru.fromchat.back +import ru.fromchat.config.Settings +import ru.fromchat.dark +import ru.fromchat.light +import ru.fromchat.materialYou +import ru.fromchat.materialYou_d +import ru.fromchat.settings_category_appearance +import ru.fromchat.theme +import ru.fromchat.ui.Theme +import ru.fromchat.ui.dynamicThemeEnabled +import ru.fromchat.ui.theme + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun AppearanceScreen(onBack: () -> Unit) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) + + var materialYouSwitch by remember { + mutableStateOf(Settings.materialYou && materialYouAvailable) + } + var themeChipIndex by remember { mutableIntStateOf(Settings.theme.ordinal) } + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + MediumTopAppBar( + title = { Text(stringResource(Res.string.settings_category_appearance)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back)) + } + }, + scrollBehavior = scrollBehavior + ) + } + ) { innerPadding -> + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(innerPadding) + ) { + Category( + Modifier.padding(top = 16.dp), + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ) { + SwitchListItem( + headline = stringResource(Res.string.materialYou), + supportingText = stringResource(Res.string.materialYou_d), + enabled = materialYouAvailable, + checked = materialYouSwitch, + onCheckedChange = { + materialYouSwitch = it + Settings.materialYou = it + dynamicThemeEnabled = it + }, + divider = true, + leadingContent = { + Icon(Icons.Filled.Wallpaper, null) + } + ) + ListItem( + headline = stringResource(Res.string.theme), + leadingContent = { Icon(Icons.Filled.Brush, null) }, + bottomContent = { + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + val options = listOf( + stringResource(Res.string.as_system), + stringResource(Res.string.light), + stringResource(Res.string.dark) + ) + options.forEachIndexed { index, label -> + FilterChip( + onClick = { + themeChipIndex = index + Settings.theme = Theme.entries[index] + theme = Theme.entries[index] + }, + selected = index == themeChipIndex, + leadingIcon = { + if (index == 0) { + Spacer(Modifier.width(16.dp)) + } + when (index) { + 0 -> Icon(Icons.Filled.Settings, null) + 1 -> Icon(Icons.Filled.LightMode, null) + 2 -> Icon(Icons.Filled.DarkMode, null) + else -> Unit + } + }, + label = { + Text(text = label, overflow = TextOverflow.Ellipsis) + } + ) + } + } + } + ) + } + } + } +} \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/DevicesScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/DevicesScreen.kt new file mode 100644 index 0000000..7ca9b1f --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/DevicesScreen.kt @@ -0,0 +1,605 @@ +@file:Suppress("AssignedValueIsNeverRead") + +package ru.fromchat.ui.main.settings + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Login +import androidx.compose.material.icons.filled.Devices +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.rounded.Android +import androidx.compose.material.icons.rounded.Language +import androidx.compose.material.icons.rounded.LaptopMac +import androidx.compose.material.icons.rounded.PhoneAndroid +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.pr0gramm3r101.components.Category +import com.pr0gramm3r101.components.ListItem +import com.pr0gramm3r101.utils.currentDeviceInfo +import dev.chrisbanes.haze.HazeProgressive +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import dev.chrisbanes.haze.materials.HazeMaterials +import dev.chrisbanes.haze.rememberHazeState +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource +import ru.fromchat.Res +import ru.fromchat.api.ApiClient +import ru.fromchat.api.schema.user.devices.DeviceSessionInfo +import ru.fromchat.back +import ru.fromchat.cancel +import ru.fromchat.config.Settings +import ru.fromchat.confirm +import ru.fromchat.error_unexpected +import ru.fromchat.settings_devices_active_sessions +import ru.fromchat.settings_devices_field_browser +import ru.fromchat.settings_devices_field_last_active +import ru.fromchat.settings_devices_field_os +import ru.fromchat.settings_devices_field_signed_in +import ru.fromchat.settings_devices_last_active +import ru.fromchat.settings_devices_logout_all +import ru.fromchat.settings_devices_logout_all_confirm_body +import ru.fromchat.settings_devices_logout_all_confirm_title +import ru.fromchat.settings_devices_sign_out_sheet +import ru.fromchat.settings_devices_this_device +import ru.fromchat.settings_devices_title +import ru.fromchat.settings_devices_unknown +import ru.fromchat.ui.components.ActionButton +import ru.fromchat.ui.components.ExpressiveIconFrame +import ru.fromchat.ui.components.FromChatSnackbarHost +import ru.fromchat.ui.components.HazeActionButton +import ru.fromchat.ui.components.Text +import ru.fromchat.unknown + +private fun formatDeviceLine(d: DeviceSessionInfo, fallbackLabel: String) = + listOfNotNull( + d.deviceName, + d.brand, + d.model, + deviceSessionOsLine(d), + d.browserName, + d.deviceType + ) + .mapNotNull { it.trim().takeIf { it.isNotBlank() } } + .distinct() + .let { + if (it.isNotEmpty()) { + it.joinToString(" • ") + } else { + d.deviceType?.takeIf { it.isNotBlank() } ?: fallbackLabel + } + } + +private fun deviceSessionForCurrentDevice(d: DeviceSessionInfo): DeviceSessionInfo { + if (d.current) { + val current = currentDeviceInfo() + + val localOsName = current.osName?.trim()?.takeIf { it.isNotBlank() } + val localOsVersion = current.osVersion?.trim()?.takeIf { it.isNotBlank() } + val localDeviceType = current.deviceType?.trim()?.takeIf { it.isNotBlank() } + val localDeviceName = current.deviceName?.trim()?.takeIf { it.isNotBlank() } + val localBrand = current.brand?.trim()?.takeIf { it.isNotBlank() } + val localModel = current.model?.trim()?.takeIf { it.isNotBlank() } + val remoteDeviceName = d.deviceName?.trim()?.takeIf { it.isNotBlank() } + val remoteBrand = d.brand?.trim()?.takeIf { it.isNotBlank() } + val remoteModel = d.model?.trim()?.takeIf { it.isNotBlank() } + + return d.copy( + osName = localOsName ?: d.osName, + osVersion = localOsVersion ?: d.osVersion, + deviceType = localDeviceType ?: d.deviceType, + deviceName = remoteDeviceName ?: localDeviceName ?: d.deviceName, + brand = remoteBrand ?: localBrand ?: d.brand, + model = remoteModel ?: localModel ?: d.model + ) + } else { + return d + } +} + +@Composable +private fun formatDeviceLastSeen(iso: String?): String { + if (iso.isNullOrBlank()) return stringResource(Res.string.unknown) + return iso.replace("T", " ").take(19) +} + +private fun resolveDeviceOsName(d: DeviceSessionInfo): String? { + val direct = normalizeDeviceOsName(d.osName) + if (direct != null) return direct + return normalizeDeviceOsName(inferDeviceOsFromSession(d)) +} + +private fun normalizeDeviceOsName(raw: String?): String? { + val trimmed = raw?.trim() ?: return null + if (trimmed.isBlank()) return null + val lower = trimmed.lowercase() + return when { + "windows" in lower || "win" in lower -> "Windows" + "mac" in lower || "os x" in lower || "darwin" in lower -> "macOS" + "linux" in lower -> "Linux" + "android" in lower -> "Android" + "ios" in lower || "iphone" in lower || "ipad" in lower -> "iOS" + else -> trimmed + } +} + +private fun inferDeviceOsFromSession(d: DeviceSessionInfo): String? { + val type = d.deviceType?.lowercase().orEmpty() + val name = d.deviceName?.lowercase().orEmpty() + val brand = d.brand?.lowercase().orEmpty() + val model = d.model?.lowercase().orEmpty() + val browser = d.browserName?.lowercase().orEmpty() + val isMobile = "mobile" in type || "tablet" in type || "phone" in type + return when { + isMobile && (brand.contains("apple") || name.contains("iphone") || name.contains("ipad") || model.contains("iphone") || model.contains("ipad") || browser.contains("ios")) -> "iOS" + isMobile && (brand.contains("android") || name.contains("android") || model.contains("android") || browser.contains("android")) -> "Android" + isMobile && ( + brand.contains("samsung") || + brand.contains("xiaomi") || + brand.contains("huawei") || + brand.contains("oppo") || + brand.contains("vivo") || + brand.contains("pixel") || + brand.contains("oneplus") || + brand.contains("google") || + brand.contains("lg") || + brand.contains("motorola") || + brand.contains("honor") || + brand.contains("realme") + ) -> "Android" + "android" in browser || "android" in brand || "android" in model -> "Android" + brand.contains("apple") || model.contains("iphone") || model.contains("ipad") || browser.contains("ios") || browser.contains("iphone") || browser.contains("ipad") -> "iOS" + else -> null + } +} + +private fun deviceSessionOsLine(d: DeviceSessionInfo): String? { + val os = resolveDeviceOsName(d) ?: return null + val version = d.osVersion?.trim().orEmpty() + return if (version.isBlank()) os else "$os $version" +} + +private fun deviceSessionLogoResource(d: DeviceSessionInfo): String? { + return when (resolveDeviceOsName(d)?.lowercase()) { + "windows", "windows nt" -> "drawable/os_windows.svg" + "mac", "mac os", "macos" -> "drawable/os_macos.svg" + "linux" -> "drawable/os_linux.svg" + else -> null + } +} + +private fun deviceHeadline(d: DeviceSessionInfo, fallback: String): String { + d.deviceName?.trim()?.takeIf { it.isNotEmpty() }?.let { return it } + val brandModel = listOfNotNull(d.brand?.trim(), d.model?.trim()) + .filter { it.isNotEmpty() } + .joinToString(" ") + if (brandModel.isNotEmpty()) return brandModel + return formatDeviceLine(d, fallback) +} + +private fun deviceSessionIcon(d: DeviceSessionInfo): ImageVector { + val type = d.deviceType?.lowercase().orEmpty() + val os = resolveDeviceOsName(d)?.lowercase().orEmpty() + val hasBrowser = d.browserName?.isNotBlank() == true + + return when { + "android" in os -> Icons.Rounded.Android + // "ios" in os || "iphone" in os -> TODO() + "mobile" in type || "phone" in type -> Icons.Rounded.PhoneAndroid + hasBrowser && "windows" !in os && "mac" !in os && "linux" !in os && + "android" !in os && "ios" !in os -> Icons.Rounded.Language + hasBrowser -> Icons.Rounded.Language + else -> Icons.Rounded.LaptopMac + } +} + +@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun DeviceSessionDetailBottomSheet( + d: DeviceSessionInfo, + unknownDeviceLabel: String, + signingOut: Boolean, + onDismiss: () -> Unit, + onConfirmSignOut: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .navigationBarsPadding() + .padding(horizontal = 16.dp) + .padding(bottom = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(Modifier.height(4.dp)) + + ExpressiveIconFrame( + icon = deviceSessionIcon(d), + materialPolygon = MaterialShapes.Cookie7Sided + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = deviceHeadline(d, unknownDeviceLabel), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(16.dp)) + + Category( + margin = PaddingValues(), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + ) { + ListItem( + headline = stringResource(Res.string.settings_devices_field_os), + supportingText = "${resolveDeviceOsName(d)} ${d.osVersion}", + leadingContent = { + Icon(deviceSessionIcon(d), null) + }, + divider = true + ) + + if (!d.browserName.isNullOrBlank()) { + ListItem( + headline = stringResource(Res.string.settings_devices_field_browser), + supportingText = "${d.browserName} ${d.browserVersion}", + leadingContent = { + Icon(Icons.Filled.Language, null) + }, + divider = true + ) + } + + ListItem( + headline = stringResource(Res.string.settings_devices_field_signed_in), + supportingText = formatDeviceLastSeen(d.createdAt), + leadingContent = { + Icon(Icons.AutoMirrored.Filled.Login, null) + }, + divider = true + ) + + ListItem( + headline = stringResource(Res.string.settings_devices_field_last_active), + supportingText = formatDeviceLastSeen(d.lastSeen), + leadingContent = { + Icon(Icons.Filled.History, null) + } + ) + } + + Spacer(Modifier.height(16.dp)) + + if (!d.current) { + ActionButton( + onClick = onConfirmSignOut, + loading = signingOut + ) { + Text(stringResource(Res.string.settings_devices_sign_out_sheet)) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class, + ExperimentalMaterial3ExpressiveApi::class +) +@Composable +fun DevicesScreen(onBack: () -> Unit) { + val scope = rememberCoroutineScope() + val hazeState = rememberHazeState(blurEnabled = true) + + val snackbarHostState = remember { SnackbarHostState() } + val initialCache = remember { Settings.readDeviceSessionsCache() } + var devices by remember { mutableStateOf(initialCache) } + var loading by remember { mutableStateOf(initialCache == null) } + var sheetDevice by remember { mutableStateOf(null) } + var sheetSigningOut by remember { mutableStateOf(false) } + var showLogoutAllConfirm by remember { mutableStateOf(false) } + val errUnexpected = stringResource(Res.string.error_unexpected) + val unknownDeviceLabel = stringResource(Res.string.settings_devices_unknown) + + LaunchedEffect(sheetDevice) { + sheetSigningOut = false + } + + fun reload() { + scope.launch { + if (devices == null) loading = true + + devices = runCatching { ApiClient.listDevices() } + .let { + if (it.isSuccess) { + Settings.writeDeviceSessionsCache(it.getOrNull()!!) + it.getOrNull()!! + } else { + snackbarHostState.showSnackbar(errUnexpected) + + if (devices == null) { + emptyList() + } else { + devices + } + } + }?.let { + it.indexOfFirst { it.current }.let { index -> + it + .toMutableList() + .also { + it[index] = deviceSessionForCurrentDevice(it[index]) + } + .toList() + } + } + + loading = false + } + } + + LaunchedEffect(Unit) { + reload() + } + + Scaffold( + snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) }, + topBar = { + TopAppBar( + title = {}, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back)) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = Color.Transparent + ), + modifier = Modifier.hazeEffect( + state = hazeState, + style = HazeMaterials.thin() + ) { + progressive = HazeProgressive.verticalGradient( + startIntensity = 1f, + endIntensity = 0f, + ) + } + ) + }, + bottomBar = { + if (devices?.let { it.size > 1 } == true) { + HazeActionButton( + hazeState = hazeState, + onClick = { showLogoutAllConfirm = true } + ) { + Text(stringResource(Res.string.settings_devices_logout_all)) + } + } + } + ) { innerPadding -> + if (loading) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .hazeSource(hazeState), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator() + } + } else { + val active_sessions = stringResource(Res.string.settings_devices_active_sessions) + + LazyColumn( + modifier = Modifier + .hazeSource(hazeState) + .padding() + .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp), + contentPadding = innerPadding + ) { + item { + Column(Modifier.fillMaxWidth()) { + ExpressiveIconFrame( + icon = Icons.Filled.Devices, + modifier = Modifier.align(Alignment.CenterHorizontally), + materialPolygon = MaterialShapes.VerySunny + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = stringResource(Res.string.settings_devices_title), + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.height(16.dp)) + } + } + + var currentDevice: DeviceSessionInfo? = null + val sessionList = devices!! + .toMutableList() + .also { mutable -> + currentDevice = mutable.first { it.current }.also { + mutable.remove(it) + } + } + .toList() + + @Composable + fun Device( + device: DeviceSessionInfo, + divider: Boolean + ) { + val osLogo = remember(device) { deviceSessionLogoResource(device) } + + ListItem( + headline = remember(device) { + deviceHeadline(device, unknownDeviceLabel) + }, + supportingText = stringResource( + Res.string.settings_devices_last_active, + formatDeviceLastSeen(device.lastSeen) + ), + leadingContent = { + if (osLogo != null) { + AsyncImage( + model = Res.getUri(osLogo), + contentDescription = null, + modifier = Modifier.size(24.dp), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface) + ) + } else { + Icon( + imageVector = remember(device) { + deviceSessionIcon( + device + ) + }, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + divider = divider, + onClick = { sheetDevice = device } + ) + } + + if (currentDevice != null) { + item { + Category( + title = stringResource(Res.string.settings_devices_this_device), + margin = PaddingValues(bottom = 20.dp) + ) { + Device(currentDevice, false) + } + } + } + + Category( + margin = PaddingValues(bottom = 20.dp), + title = active_sessions + ) { + sessionList.forEachIndexed { index, it -> + item { + Device(it, index < sessionList.lastIndex) + } + } + } + } + } + } + + sheetDevice?.let { d -> + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = { if (!sheetSigningOut) sheetDevice = null }, + sheetState = sheetState + ) { + DeviceSessionDetailBottomSheet( + d = d, + unknownDeviceLabel = unknownDeviceLabel, + signingOut = sheetSigningOut, + onDismiss = { if (!sheetSigningOut) sheetDevice = null }, + onConfirmSignOut = { + scope.launch { + sheetSigningOut = true + + runCatching { + ApiClient.revokeDeviceSession(d.sessionId) + }.onSuccess { + sheetState.hide() + sheetDevice = null + reload() + }.onFailure { + snackbarHostState.showSnackbar(errUnexpected) + } + + sheetSigningOut = false + } + } + ) + } + } + + if (showLogoutAllConfirm) { + AlertDialog( + onDismissRequest = { showLogoutAllConfirm = false }, + title = { Text(stringResource(Res.string.settings_devices_logout_all_confirm_title)) }, + text = { Text(stringResource(Res.string.settings_devices_logout_all_confirm_body)) }, + confirmButton = { + TextButton( + onClick = { + showLogoutAllConfirm = false + scope.launch { + runCatching { ApiClient.revokeAllOtherDeviceSessions() } + .onSuccess { reload() } + .onFailure { snackbarHostState.showSnackbar(errUnexpected) } + } + } + ) { + Text(stringResource(Res.string.confirm)) + } + }, + dismissButton = { + TextButton(onClick = { showLogoutAllConfirm = false }) { + Text(stringResource(Res.string.cancel)) + } + } + ) + } +} \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.kt new file mode 100644 index 0000000..ed1268f --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.kt @@ -0,0 +1,134 @@ +package ru.fromchat.ui.main.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MediumTopAppBar +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.unit.dp +import com.pr0gramm3r101.components.Category +import com.pr0gramm3r101.components.ListItem +import com.pr0gramm3r101.components.SwitchListItem +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource +import ru.fromchat.Res +import ru.fromchat.api.ensureFcmTokenRegistered +import ru.fromchat.api.unregisterFcmTokenFromServer +import ru.fromchat.back +import ru.fromchat.error_unexpected +import ru.fromchat.settings_notification_settings +import ru.fromchat.settings_notification_settings_d +import ru.fromchat.settings_notifications_permission_required +import ru.fromchat.settings_notifications_title +import ru.fromchat.settings_push_notifications +import ru.fromchat.settings_push_notifications_d +import ru.fromchat.ui.components.FromChatSnackbarHost +import ru.fromchat.ui.components.Text + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NotificationsScreen(onBack: () -> Unit) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) + val coroutineScope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + var notificationsEnabled by remember { mutableStateOf(areAppNotificationsEnabled()) } + val notificationsPermissionText = stringResource(Res.string.settings_notifications_permission_required) + val unexpectedErrorText = stringResource(Res.string.error_unexpected) + + Scaffold( + snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) }, + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + MediumTopAppBar( + title = { Text(stringResource(Res.string.settings_notifications_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back)) + } + }, + scrollBehavior = scrollBehavior + ) + } + ) { innerPadding -> + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(innerPadding) + ) { + Category( + modifier = Modifier.padding(top = 16.dp) + ) { + SwitchListItem( + headline = stringResource(Res.string.settings_push_notifications), + supportingText = stringResource(Res.string.settings_push_notifications_d), + leadingContent = { + Icon(Icons.Filled.Notifications, null) + }, + checked = notificationsEnabled, + onCheckedChange = { + coroutineScope.launch { + if (!areAppNotificationsEnabled()) { + if (!openAppNotificationSettings()) { + snackbarHostState.showSnackbar(message = unexpectedErrorText) + } else { + snackbarHostState.showSnackbar(message = notificationsPermissionText) + } + return@launch + } + + if (notificationsEnabled) { + unregisterFcmTokenFromServer() + notificationsEnabled = !notificationsEnabled + } else { + ensureFcmTokenRegistered() + snackbarHostState.showSnackbar(message = unexpectedErrorText) + } + } + }, + divider = true + ) + + ListItem( + headline = stringResource(Res.string.settings_notification_settings), + supportingText = stringResource(Res.string.settings_notification_settings_d), + leadingContent = { + Icon(Icons.Filled.Settings, null) + }, + onClick = { openAppNotificationSettings() } + ) + } + } + } +} + +/** + * Opens the system screen where the user can change notification permission and channels for this app. + * @return true if an intent/URL was fired (best effort). + */ +expect fun openAppNotificationSettings(): Boolean + +/** + * Returns true when notifications are currently enabled for this app, including runtime permission. + */ +expect fun areAppNotificationsEnabled(): Boolean \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsExpressive.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsExpressive.kt deleted file mode 100644 index 636fd61..0000000 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsExpressive.kt +++ /dev/null @@ -1,293 +0,0 @@ -package ru.fromchat.ui.main.settings - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Key -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.VerifiedUser -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialShapes -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.toPath -import androidx.compose.material3.toShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.drawscope.scale -import androidx.compose.ui.graphics.drawscope.translate -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.lerp -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.graphics.shapes.Morph -import androidx.graphics.shapes.RoundedPolygon - -/** Horizontal padding for stepped / full-bleed settings screens (matches key M3 spacing). */ -val SettingsStepHorizontalPadding = 24.dp - -/** Inset “cut” between rows on tinted cards: matches the window surface behind the card. */ -@Composable -fun settingsSurfaceCutDividerColor(): Color = MaterialTheme.colorScheme.surface - -val SettingsSurfaceCutDividerThickness = 2.dp - -@Composable -fun SettingsSurfaceCutDivider() { - HorizontalDivider( - color = settingsSurfaceCutDividerColor(), - thickness = SettingsSurfaceCutDividerThickness - ) -} - -/** Primary CTA shape for security flow (soft squircle). */ -val SettingsSecurityCtaShape = RoundedCornerShape(percent = 38) - -/** Outline shape for password step text fields. */ -val SettingsPasswordOutlineFieldShape = RoundedCornerShape(18.dp) - -/** Steps in the change-password flow (single navigation destination; drives hero morph + form slide). */ -enum class SecurityPasswordFlowStep { - Current, - New, - Confirm, - ; - - companion object { - fun fromOrdinal(ordinal: Int): SecurityPasswordFlowStep = - entries.getOrElse(ordinal.coerceIn(0, entries.lastIndex)) { Current } - } -} - -/** - * Predefined [RoundedPolygon]s from the Material 3 shape library ([MaterialShapes]), one per step. - * Uses the same “cookie” family so morphs stay subtle between steps. - */ -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -private fun securityHeroMaterialPolygon(step: SecurityPasswordFlowStep): RoundedPolygon = - when (step) { - SecurityPasswordFlowStep.Current -> MaterialShapes.Cookie4Sided - SecurityPasswordFlowStep.New -> MaterialShapes.Cookie6Sided - SecurityPasswordFlowStep.Confirm -> MaterialShapes.Cookie7Sided - }.normalized() - -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -private fun rememberSecurityPasswordHeroMorph(step: SecurityPasswordFlowStep): Pair> { - var fromStep by remember { mutableStateOf(step) } - var toStep by remember { mutableStateOf(step) } - val progress = remember { Animatable(1f) } - LaunchedEffect(step) { - if (step != toStep) { - fromStep = toStep - toStep = step - progress.snapTo(0f) - progress.animateTo( - targetValue = 1f, - animationSpec = spring(dampingRatio = 0.82f, stiffness = 380f), - ) - } - } - val morph = remember(fromStep, toStep) { - Morph(securityHeroMaterialPolygon(fromStep), securityHeroMaterialPolygon(toStep)) - } - return morph to progress -} - -/** - * Large step icon with gradient fill; **shape morphs** between library polygons ([MaterialShapes] via [Morph]), - * icon **crossfades**. - */ -@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalAnimationApi::class) -@Composable -fun SettingsSecurityMorphedPasswordHero( - step: SecurityPasswordFlowStep, - modifier: Modifier = Modifier, - containerSize: Dp = 132.dp, - iconSize: Dp = 48.dp, - predictiveProgress: Float? = null, - predictiveFromStep: SecurityPasswordFlowStep? = null, - predictiveToStep: SecurityPasswordFlowStep? = null, -) { - val usePredictive = predictiveProgress != null && predictiveFromStep != null && predictiveToStep != null - - val fromStep = when { - usePredictive -> predictiveFromStep - else -> step - } - val toStep = when { - usePredictive -> predictiveToStep - else -> step - } - - val morph = remember(fromStep, toStep) { - Morph( - securityHeroMaterialPolygon(fromStep), - securityHeroMaterialPolygon(toStep), - ) - } - - val scheme = MaterialTheme.colorScheme - val fromContainer = when (fromStep) { - SecurityPasswordFlowStep.Current -> scheme.secondaryContainer - SecurityPasswordFlowStep.New -> scheme.tertiaryContainer - SecurityPasswordFlowStep.Confirm -> scheme.primaryContainer - } - val toContainer = when (toStep) { - SecurityPasswordFlowStep.Current -> scheme.secondaryContainer - SecurityPasswordFlowStep.New -> scheme.tertiaryContainer - SecurityPasswordFlowStep.Confirm -> scheme.primaryContainer - } - val fromContent = when (fromStep) { - SecurityPasswordFlowStep.Current -> scheme.onSecondaryContainer - SecurityPasswordFlowStep.New -> scheme.onTertiaryContainer - SecurityPasswordFlowStep.Confirm -> scheme.onPrimaryContainer - } - val toContent = when (toStep) { - SecurityPasswordFlowStep.Current -> scheme.onSecondaryContainer - SecurityPasswordFlowStep.New -> scheme.onTertiaryContainer - SecurityPasswordFlowStep.Confirm -> scheme.onPrimaryContainer - } - - val p = (predictiveProgress ?: 0f).coerceIn(0f, 1f) - val deep = lerp(fromContainer, toContainer, p) - val contentColor = lerp(fromContent, toContent, p) - val light = deep.copy(alpha = 0.72f) - Box( - modifier = modifier.size(containerSize), - contentAlignment = Alignment.Center - ) { - Canvas(Modifier.fillMaxSize()) { - val path = morph.toPath(p, Path()) - // Shapes are normalized to a 0–1 box; keep a fixed outer size so the hero - // never appears to grow or shrink between steps, only morph its outline. - val unit = minOf(size.width, size.height) * 0.94f - val s = unit - // Center at canvas origin, then draw the normalized path around (-0.5, -0.5) .. (0.5, 0.5). - translate(left = size.width / 2f, top = size.height / 2f) { - scale(scaleX = s, scaleY = s, pivot = Offset.Zero) { - translate(left = -0.5f, top = -0.5f) { - drawPath( - path = path, - brush = Brush.linearGradient( - colors = listOf(light, deep), - start = Offset.Zero, - end = Offset(1f, 1f), - ), - ) - } - } - } - } - val fromIcon = when (fromStep) { - SecurityPasswordFlowStep.Current -> Icons.Filled.Key - SecurityPasswordFlowStep.New -> Icons.Filled.Lock - SecurityPasswordFlowStep.Confirm -> Icons.Filled.VerifiedUser - } - val toIcon = when (toStep) { - SecurityPasswordFlowStep.Current -> Icons.Filled.Key - SecurityPasswordFlowStep.New -> Icons.Filled.Lock - SecurityPasswordFlowStep.Confirm -> Icons.Filled.VerifiedUser - } - if (usePredictive && fromStep != toStep) { - Box( - modifier = Modifier.size(iconSize), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = fromIcon, - contentDescription = null, - modifier = Modifier - .matchParentSize() - .graphicsLayer { alpha = 1f - p }, - tint = contentColor, - ) - Icon( - imageVector = toIcon, - contentDescription = null, - modifier = Modifier - .matchParentSize() - .graphicsLayer { alpha = p }, - tint = contentColor, - ) - } - } else { - AnimatedContent( - targetState = step, - transitionSpec = { - fadeIn(tween(220)) togetherWith fadeOut(tween(180)) - }, - label = "security_hero_icon" - ) { s -> - val icon = when (s) { - SecurityPasswordFlowStep.Current -> Icons.Filled.Key - SecurityPasswordFlowStep.New -> Icons.Filled.Lock - SecurityPasswordFlowStep.Confirm -> Icons.Filled.VerifiedUser - } - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(iconSize), - tint = contentColor - ) - } - } - } -} - -/** - * Large icon clipped with a Material expressive polygon ([MaterialShapes] + [toShape]). - * Default is a circle; pass another library polygon (e.g. cookie-sided) to match adjacent flows. - */ -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -fun SettingsExpressiveIconFrame( - icon: ImageVector, - modifier: Modifier = Modifier, - containerSize: Dp = 112.dp, - iconSize: Dp = 52.dp, - containerColor: Color = MaterialTheme.colorScheme.primaryContainer, - contentColor: Color = MaterialTheme.colorScheme.onPrimaryContainer, - materialPolygon: RoundedPolygon = MaterialShapes.Circle, -) { - val frameShape = materialPolygon.normalized().toShape() - Box( - modifier = modifier - .size(containerSize) - .clip(frameShape) - .background(containerColor), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(iconSize), - tint = contentColor - ) - } -} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsNavHost.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsNavHost.kt deleted file mode 100644 index 1b79a79..0000000 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsNavHost.kt +++ /dev/null @@ -1,27 +0,0 @@ -package ru.fromchat.ui.main.settings - -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import org.jetbrains.compose.resources.stringResource -import ru.fromchat.Res -import ru.fromchat.settings -import ru.fromchat.ui.LocalNavController -import ru.fromchat.ui.main.TabBase - -@Composable -fun SettingsTab() { - TabBase { - val nav = LocalNavController.current - SettingsHubScreen( - onAppearance = { nav.navigate(SettingsRoutes.Appearance) }, - onNotifications = { nav.navigate(SettingsRoutes.Notifications) }, - onDevices = { nav.navigate(SettingsRoutes.Devices) }, - onSwitchServer = { nav.navigate("serverConfig") }, - onAccount = { nav.navigate(SettingsRoutes.Account) }, - onAbout = { nav.navigate("about") }, - title = stringResource(Res.string.settings), - modifier = Modifier.fillMaxSize() - ) - } -} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsPredictiveBack.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsPredictiveBack.kt deleted file mode 100644 index 1ef4c29..0000000 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsPredictiveBack.kt +++ /dev/null @@ -1,18 +0,0 @@ -package ru.fromchat.ui.main.settings - -import androidx.compose.runtime.Composable - -/** - * Cross-platform hook for Android predictive back progress on the security password flow screen. - * - * - On Android, this ties into androidx.activity.compose.PredictiveBackHandler. - * - On other platforms, it is a no-op. - */ -@Composable -expect fun SettingsSecurityPredictiveBackHandler( - enabled: Boolean, - onProgress: (Float) -> Unit, - onCommit: () -> Unit, - onCancel: () -> Unit, -) - diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsRoutes.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsRoutes.kt index 385f9b5..63a3c21 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsRoutes.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsRoutes.kt @@ -1,9 +1,9 @@ +@file:Suppress("ConstPropertyName") + package ru.fromchat.ui.main.settings -/** Root [androidx.navigation.NavController] routes for full-screen settings (slide transitions). */ object SettingsRoutes { const val Appearance = "settings/appearance" - const val ServerTools = "settings/server_tools" const val Notifications = "settings/notifications" const val Devices = "settings/devices" /** Hub with a single action to start the password flow. */ @@ -11,4 +11,6 @@ object SettingsRoutes { /** Single destination: in-screen steps + morphing hero (no nested nav routes per step). */ const val SecurityPasswordFlow = "settings/security/password" const val Account = "settings/account" + const val ServerConfig = "serverConfig" + const val About = "about" } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsScreens.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsScreens.kt deleted file mode 100644 index 973d21c..0000000 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsScreens.kt +++ /dev/null @@ -1,2059 +0,0 @@ -package ru.fromchat.ui.main.settings - -import androidx.compose.foundation.LocalIndication -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.ime -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.union -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.automirrored.filled.Logout -import androidx.compose.material.icons.filled.AccountCircle -import androidx.compose.material.icons.filled.Brush -import androidx.compose.material.icons.filled.BugReport -import androidx.compose.material.icons.filled.DarkMode -import androidx.compose.material.icons.filled.Devices -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.Key -import androidx.compose.material.icons.filled.Language -import androidx.compose.material.icons.filled.LaptopMac -import androidx.compose.material.icons.filled.LightMode -import androidx.compose.material.icons.filled.Notifications -import androidx.compose.material.icons.filled.PhoneAndroid -import androidx.compose.material.icons.filled.Palette -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.DeleteSweep -import androidx.compose.material.icons.filled.Storage -import androidx.compose.material.icons.filled.TabletAndroid -import androidx.compose.material.icons.filled.VerifiedUser -import androidx.compose.material.icons.filled.Wallpaper -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.FilterChip -import androidx.compose.material3.Icon -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MediumTopAppBar -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TextFieldColors -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.material3.rememberTopAppBarState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import ru.fromchat.ui.FromChatSnackbarHost -import com.pr0gramm3r101.components.Category -import com.pr0gramm3r101.components.ListItem -import com.pr0gramm3r101.components.SwitchListItem -import com.pr0gramm3r101.utils.crypto.deriveAuthSecret -import com.pr0gramm3r101.utils.materialYouAvailable -import io.ktor.client.plugins.ClientRequestException -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.stringResource -import ru.fromchat.Res -import ru.fromchat.about -import ru.fromchat.back -import ru.fromchat.action_wipe_local_cache_confirm_body -import ru.fromchat.action_wipe_local_cache_confirm_title -import ru.fromchat.action_wipe_local_cache_done -import ru.fromchat.action_wipe_local_cache_supporting -import ru.fromchat.action_wipe_local_cache_title -import ru.fromchat.api.ApiClient -import ru.fromchat.api.db.MessageRepository -import ru.fromchat.api.db.wipeLocalCacheOnDisk -import ru.fromchat.core.cache.writeFromChatCacheGeneration -import ru.fromchat.ui.imeScrollWithKeyboard -import ru.fromchat.api.DeviceSessionInfo -import ru.fromchat.ui.main.settings.SettingsSecurityPredictiveBackHandler -import ru.fromchat.api.WebSocketManager -import ru.fromchat.cancel -import ru.fromchat.change_server -import ru.fromchat.change_server_d -import ru.fromchat.confirm -import ru.fromchat.core.Settings -import ru.fromchat.dark -import ru.fromchat.debug_tools -import ru.fromchat.debug_tools_d -import ru.fromchat.error_unexpected -import ru.fromchat.fill_all_fields -import ru.fromchat.light -import ru.fromchat.logout -import ru.fromchat.materialYou -import ru.fromchat.materialYou_d -import ru.fromchat.password_length_error -import ru.fromchat.passwords_dont_match -import ru.fromchat.fcm.ensureFcmTokenRegistered -import ru.fromchat.fcm.unregisterFcmTokenFromServer -import ru.fromchat.platform.areAppNotificationsEnabled -import ru.fromchat.platform.openAppNotificationSettings -import ru.fromchat.platform.currentDeviceInfo -import ru.fromchat.settings_account_delete -import ru.fromchat.settings_account_delete_confirm_body -import ru.fromchat.settings_account_delete_confirm_title -import ru.fromchat.settings_account_delete_d -import ru.fromchat.settings_account_title -import ru.fromchat.settings_category_account -import ru.fromchat.settings_category_account_d -import ru.fromchat.settings_category_appearance -import ru.fromchat.settings_category_appearance_d -import ru.fromchat.settings_category_devices -import ru.fromchat.settings_category_devices_d -import ru.fromchat.settings_category_notifications -import ru.fromchat.settings_category_notifications_d -import ru.fromchat.settings_category_security -import ru.fromchat.settings_category_security_d -import ru.fromchat.settings_category_server_tools -import ru.fromchat.settings_category_server_tools_d -import ru.fromchat.settings_change_password -import ru.fromchat.settings_confirm_new_password -import ru.fromchat.settings_current_password -import ru.fromchat.settings_devices_current_hint -import ru.fromchat.settings_devices_empty -import ru.fromchat.settings_devices_empty_sub -import ru.fromchat.settings_devices_field_brand -import ru.fromchat.settings_devices_field_browser -import ru.fromchat.settings_devices_field_browser_version -import ru.fromchat.settings_devices_field_device_name -import ru.fromchat.settings_devices_field_device_type -import ru.fromchat.settings_devices_field_model -import ru.fromchat.settings_devices_field_os -import ru.fromchat.settings_devices_field_os_version -import ru.fromchat.settings_devices_field_last_active -import ru.fromchat.settings_devices_field_session_id -import ru.fromchat.settings_devices_field_signed_in -import ru.fromchat.settings_devices_last_active -import ru.fromchat.settings_devices_sheet_title -import ru.fromchat.settings_devices_sign_out_sheet -import ru.fromchat.settings_devices_signing_out -import ru.fromchat.settings_devices_unknown -import ru.fromchat.settings_devices_logout_all -import ru.fromchat.settings_devices_logout_all_confirm_body -import ru.fromchat.settings_devices_logout_all_confirm_title -import ru.fromchat.settings_devices_this_device -import ru.fromchat.settings_devices_title -import ru.fromchat.settings_new_password -import ru.fromchat.settings_notifications_body -import ru.fromchat.settings_notifications_disable -import ru.fromchat.settings_notifications_enable -import ru.fromchat.settings_notifications_title -import ru.fromchat.settings_notifications_permission_required -import ru.fromchat.settings_open_notification_settings -import ru.fromchat.settings_push_notifications_disabled -import ru.fromchat.settings_push_notifications_enabled -import ru.fromchat.settings_hub_about_sub -import ru.fromchat.settings_next -import ru.fromchat.settings_password_changed -import ru.fromchat.settings_security_step_confirm_body -import ru.fromchat.settings_security_step_confirm_title -import ru.fromchat.settings_security_step_current_body -import ru.fromchat.settings_security_step_current_title -import ru.fromchat.settings_security_step_new_body -import ru.fromchat.settings_security_change_password_sub -import ru.fromchat.settings_security_step_new_title -import ru.fromchat.settings_security_title -import ru.fromchat.as_system -import ru.fromchat.theme -import ru.fromchat.ui.Theme -import ru.fromchat.ui.dynamicThemeEnabled -import ru.fromchat.ui.theme -import coil3.compose.AsyncImage - -@Composable -private fun SettingsListLeadingIcon(imageVector: ImageVector) { - Icon( - imageVector = imageVector, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) -} - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) -@Composable -fun SettingsHubScreen( - onAppearance: () -> Unit, - onNotifications: () -> Unit, - onDevices: () -> Unit, - onSwitchServer: () -> Unit, - onAccount: () -> Unit, - onAbout: () -> Unit, - title: String, - modifier: Modifier = Modifier -) { - val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) - Scaffold( - modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - topBar = { - MediumTopAppBar( - title = { - Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) - }, - scrollBehavior = scrollBehavior - ) - } - ) { innerPadding -> - Column( - Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(innerPadding) - ) { - Category( - modifier = Modifier.padding(top = 16.dp), - containerColor = MaterialTheme.colorScheme.surfaceContainerLow - ) { - ListItem( - headline = stringResource(Res.string.settings_category_account), - supportingText = stringResource(Res.string.settings_category_account_d), - onClick = onAccount, - leadingContent = { SettingsListLeadingIcon(Icons.Filled.AccountCircle) }, - divider = true, - dividerColor = settingsSurfaceCutDividerColor(), - dividerThickness = SettingsSurfaceCutDividerThickness, - trailingContent = { - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - ) - ListItem( - headline = stringResource(Res.string.settings_category_devices), - supportingText = stringResource(Res.string.settings_category_devices_d), - onClick = onDevices, - leadingContent = { SettingsListLeadingIcon(Icons.Filled.Devices) }, - divider = true, - dividerColor = settingsSurfaceCutDividerColor(), - dividerThickness = SettingsSurfaceCutDividerThickness, - trailingContent = { - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - ) - ListItem( - headline = stringResource(Res.string.settings_category_appearance), - supportingText = stringResource(Res.string.settings_category_appearance_d), - onClick = onAppearance, - leadingContent = { SettingsListLeadingIcon(Icons.Filled.Palette) }, - divider = true, - dividerColor = settingsSurfaceCutDividerColor(), - dividerThickness = SettingsSurfaceCutDividerThickness, - trailingContent = { - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - ) - ListItem( - headline = stringResource(Res.string.settings_category_notifications), - supportingText = stringResource(Res.string.settings_category_notifications_d), - onClick = onNotifications, - leadingContent = { SettingsListLeadingIcon(Icons.Filled.Notifications) }, - divider = true, - dividerColor = settingsSurfaceCutDividerColor(), - dividerThickness = SettingsSurfaceCutDividerThickness, - trailingContent = { - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - ) - ListItem( - headline = stringResource(Res.string.change_server), - supportingText = stringResource(Res.string.change_server_d), - onClick = onSwitchServer, - leadingContent = { SettingsListLeadingIcon(Icons.Filled.Storage) }, - divider = true, - dividerColor = settingsSurfaceCutDividerColor(), - dividerThickness = SettingsSurfaceCutDividerThickness, - trailingContent = { - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - ) - } - Spacer(Modifier.height(20.dp)) - Category( - modifier = Modifier.padding(bottom = 24.dp), - containerColor = MaterialTheme.colorScheme.surfaceContainerLow - ) { - ListItem( - headline = stringResource(Res.string.about), - supportingText = stringResource(Res.string.settings_hub_about_sub), - onClick = onAbout, - leadingContent = { SettingsListLeadingIcon(Icons.Filled.Info) }, - trailingContent = { - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - ) - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) -@Composable -fun SettingsAppearanceScreen(onBack: () -> Unit) { - val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) - var materialYouSwitch by remember { - mutableStateOf(Settings.materialYou && materialYouAvailable) - } - var themeChipIndex by remember { mutableIntStateOf(Settings.theme.ordinal) } - - Scaffold( - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - topBar = { - MediumTopAppBar( - title = { Text(stringResource(Res.string.settings_category_appearance)) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back)) - } - }, - scrollBehavior = scrollBehavior - ) - } - ) { innerPadding -> - Column( - Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(innerPadding) - ) { - Category( - Modifier.padding(top = 16.dp), - containerColor = MaterialTheme.colorScheme.surfaceContainerLow - ) { - SwitchListItem( - headline = stringResource(Res.string.materialYou), - supportingText = stringResource(Res.string.materialYou_d), - enabled = materialYouAvailable, - checked = materialYouSwitch, - onCheckedChange = { - materialYouSwitch = it - Settings.materialYou = it - dynamicThemeEnabled = it - }, - divider = true, - dividerColor = settingsSurfaceCutDividerColor(), - dividerThickness = SettingsSurfaceCutDividerThickness, - leadingContent = { - SettingsListLeadingIcon(Icons.Filled.Wallpaper) - } - ) - ListItem( - headline = stringResource(Res.string.theme), - leadingContent = { SettingsListLeadingIcon(Icons.Filled.Brush) }, - bottomContent = { - FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - val options = listOf( - stringResource(Res.string.as_system), - stringResource(Res.string.light), - stringResource(Res.string.dark) - ) - options.forEachIndexed { index, label -> - FilterChip( - onClick = { - themeChipIndex = index - Settings.theme = Theme.entries[index] - theme = Theme.entries[index] - }, - selected = index == themeChipIndex, - leadingIcon = { - if (index == 0) { - Spacer(Modifier.width(16.dp)) - } - when (index) { - 0 -> Icon(Icons.Filled.Settings, null) - 1 -> Icon(Icons.Filled.LightMode, null) - 2 -> Icon(Icons.Filled.DarkMode, null) - else -> Unit - } - }, - label = { - Text(text = label, overflow = TextOverflow.Ellipsis) - } - ) - } - } - } - ) - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun SettingsServerToolsScreen(onBack: () -> Unit, outerNav: NavController) { - val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) - val snackbarHostState = remember { SnackbarHostState() } - val scope = rememberCoroutineScope() - var showWipeLocalCacheConfirm by remember { mutableStateOf(false) } - var wipingLocalCache by remember { mutableStateOf(false) } - val wipeDoneMessage = stringResource(Res.string.action_wipe_local_cache_done) - - Scaffold( - snackbarHost = { FromChatSnackbarHost(snackbarHostState) }, - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - topBar = { - MediumTopAppBar( - title = { Text(stringResource(Res.string.settings_category_server_tools)) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back)) - } - }, - scrollBehavior = scrollBehavior - ) - } - ) { innerPadding -> - Column( - Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(innerPadding) - ) { - Category( - Modifier.padding(top = 16.dp), - containerColor = MaterialTheme.colorScheme.surfaceContainerLow - ) { - ListItem( - headline = stringResource(Res.string.change_server), - supportingText = stringResource(Res.string.change_server_d), - onClick = { outerNav.navigate("serverConfig") }, - divider = true, - dividerColor = settingsSurfaceCutDividerColor(), - dividerThickness = SettingsSurfaceCutDividerThickness, - leadingContent = { SettingsListLeadingIcon(Icons.Filled.Storage) } - ) - ListItem( - headline = stringResource(Res.string.debug_tools), - supportingText = stringResource(Res.string.debug_tools_d), - onClick = { outerNav.navigate("debug") }, - divider = true, - dividerColor = settingsSurfaceCutDividerColor(), - dividerThickness = SettingsSurfaceCutDividerThickness, - leadingContent = { SettingsListLeadingIcon(Icons.Filled.BugReport) } - ) - ListItem( - headline = stringResource(Res.string.action_wipe_local_cache_title), - supportingText = stringResource(Res.string.action_wipe_local_cache_supporting), - onClick = { if (!wipingLocalCache) showWipeLocalCacheConfirm = true }, - enabled = !wipingLocalCache, - divider = false, - leadingContent = { SettingsListLeadingIcon(Icons.Filled.DeleteSweep) } - ) - } - } - } - - if (showWipeLocalCacheConfirm) { - AlertDialog( - onDismissRequest = { if (!wipingLocalCache) showWipeLocalCacheConfirm = false }, - title = { Text(stringResource(Res.string.action_wipe_local_cache_confirm_title)) }, - text = { Text(stringResource(Res.string.action_wipe_local_cache_confirm_body)) }, - confirmButton = { - TextButton( - enabled = !wipingLocalCache, - onClick = { - wipingLocalCache = true - scope.launch { - runCatching { - wipeLocalCacheOnDisk() - writeFromChatCacheGeneration() - } - wipingLocalCache = false - showWipeLocalCacheConfirm = false - snackbarHostState.showSnackbar(wipeDoneMessage) - } - }, - ) { - Text(stringResource(Res.string.confirm)) - } - }, - dismissButton = { - TextButton( - enabled = !wipingLocalCache, - onClick = { showWipeLocalCacheConfirm = false }, - ) { - Text(stringResource(Res.string.cancel)) - } - }, - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) -@Composable -fun SettingsSecurityHubScreen(onBack: () -> Unit, onChangePassword: () -> Unit) { - val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) - Scaffold( - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - topBar = { - MediumTopAppBar( - title = { Text(stringResource(Res.string.settings_security_title)) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(Res.string.back) - ) - } - }, - scrollBehavior = scrollBehavior - ) - } - ) { innerPadding -> - Column( - Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(innerPadding) - ) { - Category( - modifier = Modifier.padding(top = 16.dp), - containerColor = MaterialTheme.colorScheme.surfaceContainerLow - ) { - ListItem( - headline = stringResource(Res.string.settings_change_password), - supportingText = stringResource(Res.string.settings_security_change_password_sub), - onClick = onChangePassword, - leadingContent = { SettingsListLeadingIcon(Icons.Filled.Key) } - ) - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun SettingsNotificationsScreen(onBack: () -> Unit) { - val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) - val coroutineScope = rememberCoroutineScope() - val snackbarHostState = remember { SnackbarHostState() } - var isUpdating by remember { mutableStateOf(false) } - var notificationsEnabled by remember { mutableStateOf(areAppNotificationsEnabled()) } - val pushNotificationsEnabledText = stringResource(Res.string.settings_push_notifications_enabled) - val pushNotificationsDisabledText = stringResource(Res.string.settings_push_notifications_disabled) - val notificationsEnableText = stringResource(Res.string.settings_notifications_enable) - val notificationsDisableText = stringResource(Res.string.settings_notifications_disable) - val notificationsPermissionText = stringResource(Res.string.settings_notifications_permission_required) - val unexpectedErrorText = stringResource(Res.string.error_unexpected) - - Scaffold( - snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) }, - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - topBar = { - MediumTopAppBar( - title = { Text(stringResource(Res.string.settings_notifications_title)) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back)) - } - }, - scrollBehavior = scrollBehavior - ) - } - ) { innerPadding -> - Column( - Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(innerPadding) - ) { - Category( - modifier = Modifier.padding(top = 16.dp), - containerColor = MaterialTheme.colorScheme.surfaceContainerLow - ) { - Text( - text = stringResource(Res.string.settings_notifications_body), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 8.dp) - ) - Text( - text = stringResource( - if (notificationsEnabled) { - Res.string.settings_push_notifications_enabled - } else { - Res.string.settings_push_notifications_disabled - } - ), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, bottom = 12.dp) - ) - FilledTonalButton( - onClick = { - coroutineScope.launch { - isUpdating = true - if (!areAppNotificationsEnabled()) { - val opened = openAppNotificationSettings() - if (!opened) { - snackbarHostState.showSnackbar(message = unexpectedErrorText) - } else { - snackbarHostState.showSnackbar(message = notificationsPermissionText) - } - isUpdating = false - return@launch - } - - val success = if (notificationsEnabled) { - unregisterFcmTokenFromServer() - } else { - ensureFcmTokenRegistered() - } - - if (success) { - notificationsEnabled = !notificationsEnabled - } else { - snackbarHostState.showSnackbar(message = unexpectedErrorText) - } - isUpdating = false - } - }, - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, bottom = 12.dp), - enabled = !isUpdating - ) { - Text( - text = if (notificationsEnabled) { - notificationsDisableText - } else { - notificationsEnableText - } - ) - } - FilledTonalButton( - onClick = { openAppNotificationSettings() }, - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) - ) { - Text(stringResource(Res.string.settings_open_notification_settings)) - } - } - } - } -} - -private fun formatDeviceLine(d: DeviceSessionInfo, fallbackLabel: String): String { - val parts = listOfNotNull( - d.deviceName, - d.brand, - d.model, - deviceSessionOsLine(d), - d.browserName, - d.deviceType - ).mapNotNull { it.trim().takeIf { it.isNotBlank() } } - .distinct() - return if (parts.isNotEmpty()) parts.joinToString(" • ") else (d.deviceType?.takeIf { it.isNotBlank() } ?: fallbackLabel) -} - -private fun deviceSessionForCurrentDevice(d: DeviceSessionInfo): DeviceSessionInfo { - if (!d.current) return d - val current = currentDeviceInfo() - - val localOsName = current.osName?.trim()?.takeIf { it.isNotBlank() } - val localOsVersion = current.osVersion?.trim()?.takeIf { it.isNotBlank() } - val localDeviceType = current.deviceType?.trim()?.takeIf { it.isNotBlank() } - val localDeviceName = current.deviceName?.trim()?.takeIf { it.isNotBlank() } - val localBrand = current.brand?.trim()?.takeIf { it.isNotBlank() } - val localModel = current.model?.trim()?.takeIf { it.isNotBlank() } - val remoteDeviceName = d.deviceName?.trim()?.takeIf { it.isNotBlank() } - val remoteBrand = d.brand?.trim()?.takeIf { it.isNotBlank() } - val remoteModel = d.model?.trim()?.takeIf { it.isNotBlank() } - - return d.copy( - osName = localOsName ?: d.osName, - osVersion = localOsVersion ?: d.osVersion, - deviceType = localDeviceType ?: d.deviceType, - deviceName = remoteDeviceName ?: localDeviceName ?: d.deviceName, - brand = remoteBrand ?: localBrand ?: d.brand, - model = remoteModel ?: localModel ?: d.model - ) -} - -private fun formatDeviceLastSeen(iso: String?): String { - if (iso.isNullOrBlank()) return "—" - return iso.replace("T", " ").take(19) -} - -private fun resolveDeviceOsName(d: DeviceSessionInfo): String? { - val direct = normalizeDeviceOsName(d.osName) - if (direct != null) return direct - return normalizeDeviceOsName(inferDeviceOsFromSession(d)) -} - -private fun normalizeDeviceOsName(raw: String?): String? { - val trimmed = raw?.trim() ?: return null - if (trimmed.isBlank()) return null - val lower = trimmed.lowercase() - return when { - "windows" in lower || "win" in lower -> "Windows" - "mac" in lower || "os x" in lower || "darwin" in lower -> "macOS" - "linux" in lower -> "Linux" - "android" in lower -> "Android" - "ios" in lower || "iphone" in lower || "ipad" in lower -> "iOS" - else -> trimmed - } -} - -private fun inferDeviceOsFromSession(d: DeviceSessionInfo): String? { - val type = d.deviceType?.lowercase().orEmpty() - val name = d.deviceName?.lowercase().orEmpty() - val brand = d.brand?.lowercase().orEmpty() - val model = d.model?.lowercase().orEmpty() - val browser = d.browserName?.lowercase().orEmpty() - val isMobile = "mobile" in type || "tablet" in type || "phone" in type - return when { - isMobile && (brand.contains("apple") || name.contains("iphone") || name.contains("ipad") || model.contains("iphone") || model.contains("ipad") || browser.contains("ios")) -> "iOS" - isMobile && (brand.contains("android") || name.contains("android") || model.contains("android") || browser.contains("android")) -> "Android" - isMobile && (brand.contains("samsung") || brand.contains("xiaomi") || brand.contains("huawei") || brand.contains("oppo") || brand.contains("vivo") || brand.contains("pixel") || brand.contains("oneplus") || brand.contains("google") || brand.contains("lg") || brand.contains("motorola") || brand.contains("honor") || brand.contains("realme")) -> "Android" - "android" in browser || "android" in brand || "android" in model -> "Android" - brand.contains("apple") || model.contains("iphone") || model.contains("ipad") || browser.contains("ios") || browser.contains("iphone") || browser.contains("ipad") -> "iOS" - else -> null - } -} - -private fun deviceSessionOsLine(d: DeviceSessionInfo): String? { - val os = resolveDeviceOsName(d) ?: return null - val version = d.osVersion?.trim().orEmpty() - return if (version.isBlank()) os else "$os $version" -} - -private fun deviceSessionLogoResource(d: DeviceSessionInfo): String? { - return when (resolveDeviceOsName(d)?.lowercase()) { - "windows", "windows nt" -> "drawable/os_windows.svg" - "mac", "mac os", "macos" -> "drawable/os_macos.svg" - "linux" -> "drawable/os_linux.svg" - else -> null - } -} - -private fun deviceHeadline(d: DeviceSessionInfo, fallback: String): String { - d.deviceName?.trim()?.takeIf { it.isNotEmpty() }?.let { return it } - val brandModel = listOfNotNull(d.brand?.trim(), d.model?.trim()) - .filter { it.isNotEmpty() } - .joinToString(" ") - if (brandModel.isNotEmpty()) return brandModel - return formatDeviceLine(d, fallback) -} - -private fun deviceDetailLine(d: DeviceSessionInfo): String? { - val deviceType = d.deviceType?.trim()?.takeIf { it.isNotBlank() && !it.equals("other", ignoreCase = true) } - val brand = d.brand?.trim()?.takeIf { it.isNotBlank() } - val model = d.model?.trim()?.takeIf { it.isNotBlank() } - val browser = d.browserName?.trim() - ?.takeIf { it.isNotBlank() && !it.equals("other", ignoreCase = true) && !it.equals("desktop", ignoreCase = true) } - - val parts = buildList { - deviceSessionOsLine(d)?.let { add(it.trim()) } - brand?.let { add(it) } - model?.let { add(it) } - deviceType?.let { add(it) } - browser?.let { add(it) } - } - return if (parts.isNotEmpty()) parts.joinToString(" • ") else null -} - -private fun deviceSessionIcon(d: DeviceSessionInfo): ImageVector { - val type = d.deviceType?.lowercase().orEmpty() - val os = resolveDeviceOsName(d)?.lowercase().orEmpty() - val hasBrowser = d.browserName?.isNotBlank() == true - return when { - "tablet" in type -> Icons.Filled.TabletAndroid - "mobile" in type || "phone" in type -> Icons.Filled.PhoneAndroid - "android" in os || "ios" in os || "iphone" in os -> Icons.Filled.PhoneAndroid - hasBrowser && "windows" !in os && "mac" !in os && "linux" !in os && - "android" !in os && "ios" !in os -> Icons.Filled.Language - hasBrowser -> Icons.Filled.Language - else -> Icons.Filled.LaptopMac - } -} - -@Composable -private fun DeviceSessionDetailParam(label: String, value: String?) { - if (value.isNullOrBlank()) return - Column( - Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - ) { - Text( - text = label, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(Modifier.height(4.dp)) - Text( - text = value, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface - ) - } -} - -@OptIn(ExperimentalAnimationApi::class) -@Composable -private fun DeviceSessionDetailBottomSheet( - d: DeviceSessionInfo, - unknownDeviceLabel: String, - signingOut: Boolean, - onDismiss: () -> Unit, - onConfirmSignOut: () -> Unit -) { - Column( - Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .navigationBarsPadding() - .padding(horizontal = 24.dp) - .padding(bottom = 20.dp) - ) { - Spacer(Modifier.height(4.dp)) - Text( - text = stringResource(Res.string.settings_devices_sheet_title), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary - ) - Spacer(Modifier.height(8.dp)) - Text( - text = deviceHeadline(d, unknownDeviceLabel), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface - ) - Spacer(Modifier.height(16.dp)) - DeviceSessionDetailParam( - stringResource(Res.string.settings_devices_field_session_id), - d.sessionId - ) - DeviceSessionDetailParam( - stringResource(Res.string.settings_devices_field_device_name), - d.deviceName - ) - DeviceSessionDetailParam( - stringResource(Res.string.settings_devices_field_device_type), - d.deviceType - ) - DeviceSessionDetailParam( - stringResource(Res.string.settings_devices_field_os), - resolveDeviceOsName(d) - ) - DeviceSessionDetailParam( - stringResource(Res.string.settings_devices_field_os_version), - d.osVersion - ) - DeviceSessionDetailParam( - stringResource(Res.string.settings_devices_field_browser), - d.browserName - ) - DeviceSessionDetailParam( - stringResource(Res.string.settings_devices_field_browser_version), - d.browserVersion - ) - DeviceSessionDetailParam( - stringResource(Res.string.settings_devices_field_brand), - d.brand - ) - DeviceSessionDetailParam( - stringResource(Res.string.settings_devices_field_model), - d.model - ) - DeviceSessionDetailParam( - stringResource(Res.string.settings_devices_field_signed_in), - d.createdAt?.replace("T", " ")?.take(19) - ) - DeviceSessionDetailParam( - stringResource(Res.string.settings_devices_field_last_active), - formatDeviceLastSeen(d.lastSeen).takeIf { it != "—" } - ) - HorizontalDivider( - Modifier.padding(vertical = 16.dp), - color = MaterialTheme.colorScheme.outlineVariant - ) - if (d.current) { - FilledTonalButton( - onClick = { }, - enabled = false, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(Res.string.settings_devices_this_device)) - } - } else { - Button( - onClick = onConfirmSignOut, - enabled = !signingOut, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 52.dp), - shape = RoundedCornerShape(16.dp), - contentPadding = ButtonDefaults.ContentPadding - ) { - Box( - Modifier - .fillMaxWidth() - .defaultMinSize(minHeight = 24.dp), - contentAlignment = Alignment.Center - ) { - AnimatedContent( - targetState = signingOut, - transitionSpec = { - (fadeIn(tween(200)) + slideInVertically { it / 4 }) togetherWith - (fadeOut(tween(200)) + slideOutVertically { -it / 4 }) - }, - label = "device_sign_out" - ) { busy -> - if (busy) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary - ) - } else { - Text(stringResource(Res.string.settings_devices_sign_out_sheet)) - } - } - } - } - } - TextButton( - onClick = onDismiss, - enabled = !signingOut, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(Res.string.cancel)) - } - } -} - -@Composable -private fun DeviceSessionRow( - d: DeviceSessionInfo, - unknownDeviceLabel: String, - onOpen: () -> Unit -) { - val headline = remember(d) { deviceHeadline(d, unknownDeviceLabel) } - val detail = remember(d) { deviceDetailLine(d) } - val osLogo = remember(d) { deviceSessionLogoResource(d) } - val fallbackIcon = remember(d) { deviceSessionIcon(d) } - val interaction = remember(d.sessionId) { MutableInteractionSource() } - Row( - modifier = Modifier - .fillMaxWidth() - .clickable( - interactionSource = interaction, - indication = LocalIndication.current, - onClick = onOpen - ) - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .size(52.dp) - .clip(RoundedCornerShape(16.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHigh), - contentAlignment = Alignment.Center - ) { - if (osLogo != null) { - AsyncImage( - model = Res.getUri(osLogo), - contentDescription = null, - modifier = Modifier.size(28.dp), - contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface) - ) - } else { - Icon( - imageVector = fallbackIcon, - contentDescription = null, - modifier = Modifier.size(28.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - Column( - modifier = Modifier - .weight(1f) - .padding(start = 14.dp, end = 8.dp) - ) { - Text( - text = headline, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - if (d.current) { - Spacer(Modifier.height(8.dp)) - Surface( - shape = RoundedCornerShape(100), - color = MaterialTheme.colorScheme.primaryContainer - ) { - Text( - text = stringResource(Res.string.settings_devices_this_device), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp) - ) - } - } - detail?.let { line -> - Spacer(Modifier.height(6.dp)) - Text( - text = line, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - Spacer(Modifier.height(6.dp)) - Text( - text = if (d.current) { - stringResource(Res.string.settings_devices_current_hint) - } else { - stringResource( - Res.string.settings_devices_last_active, - formatDeviceLastSeen(d.lastSeen) - ) - }, - style = MaterialTheme.typography.bodySmall, - color = if (d.current) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } - ) - } - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - modifier = Modifier.size(22.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun SettingsDevicesScreen(onBack: () -> Unit) { - val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) - val scope = rememberCoroutineScope() - val snackbarHostState = remember { SnackbarHostState() } - val initialCache = remember { Settings.readDeviceSessionsCache() } - var devices by remember { mutableStateOf?>(initialCache) } - var loading by remember { mutableStateOf(initialCache == null) } - var sheetDevice by remember { mutableStateOf(null) } - var sheetSigningOut by remember { mutableStateOf(false) } - var showLogoutAllConfirm by remember { mutableStateOf(false) } - val errUnexpected = stringResource(Res.string.error_unexpected) - val unknownDeviceLabel = stringResource(Res.string.settings_devices_unknown) - - LaunchedEffect(sheetDevice) { - sheetSigningOut = false - } - - fun reload() { - scope.launch { - if (devices == null) loading = true - runCatching { ApiClient.listDevices() } - .onSuccess { - devices = it - Settings.writeDeviceSessionsCache(it) - } - .onFailure { - if (devices == null) { - devices = emptyList() - } - snackbarHostState.showSnackbar(errUnexpected) - } - loading = false - } - } - - LaunchedEffect(Unit) { - reload() - } - - Scaffold( - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) }, - topBar = { - MediumTopAppBar( - title = { Text(stringResource(Res.string.settings_devices_title)) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back)) - } - }, - scrollBehavior = scrollBehavior - ) - } - ) { innerPadding -> - when { - loading -> { - Column( - Modifier.fillMaxSize().padding(innerPadding), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - CircularProgressIndicator() - } - } - devices.isNullOrEmpty() -> { - Column( - Modifier - .fillMaxSize() - .padding(innerPadding) - .verticalScroll(rememberScrollState()) - .padding(horizontal = 24.dp, vertical = 24.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - SettingsExpressiveIconFrame( - icon = Icons.Filled.Devices, - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - contentColor = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(Modifier.height(24.dp)) - Text( - text = stringResource(Res.string.settings_devices_empty), - style = MaterialTheme.typography.titleLarge, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurface - ) - Spacer(Modifier.height(8.dp)) - Text( - text = stringResource(Res.string.settings_devices_empty_sub), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - else -> { - val sessionList = devices!! - LazyColumn( - modifier = Modifier.fillMaxSize().padding(innerPadding), - contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - item(key = "sign_out_others") { - FilledTonalButton( - onClick = { showLogoutAllConfirm = true }, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(Res.string.settings_devices_logout_all)) - } - } - item(key = "sessions_card") { - Column(Modifier.fillMaxWidth()) { - Category( - modifier = Modifier.fillMaxWidth(), - margin = PaddingValues(0.dp), - containerColor = MaterialTheme.colorScheme.surfaceContainerLow - ) { - sessionList.forEachIndexed { index, d -> - val displaySession = deviceSessionForCurrentDevice(d) - DeviceSessionRow( - d = displaySession, - unknownDeviceLabel = unknownDeviceLabel, - onOpen = { sheetDevice = displaySession } - ) - if (index < sessionList.lastIndex) { - SettingsSurfaceCutDivider() - } - } - } - } - } - } - } - } - } - - sheetDevice?.let { d -> - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - ModalBottomSheet( - onDismissRequest = { if (!sheetSigningOut) sheetDevice = null }, - sheetState = sheetState - ) { - DeviceSessionDetailBottomSheet( - d = d, - unknownDeviceLabel = unknownDeviceLabel, - signingOut = sheetSigningOut, - onDismiss = { if (!sheetSigningOut) sheetDevice = null }, - onConfirmSignOut = { - val id = d.sessionId - scope.launch { - sheetSigningOut = true - val result = runCatching { ApiClient.revokeDeviceSession(id) } - sheetSigningOut = false - result - .onSuccess { - sheetDevice = null - reload() - } - .onFailure { - snackbarHostState.showSnackbar(errUnexpected) - } - } - } - ) - } - } - - if (showLogoutAllConfirm) { - AlertDialog( - onDismissRequest = { showLogoutAllConfirm = false }, - title = { Text(stringResource(Res.string.settings_devices_logout_all_confirm_title)) }, - text = { Text(stringResource(Res.string.settings_devices_logout_all_confirm_body)) }, - confirmButton = { - TextButton( - onClick = { - showLogoutAllConfirm = false - scope.launch { - runCatching { ApiClient.revokeAllOtherDeviceSessions() } - .onSuccess { reload() } - .onFailure { snackbarHostState.showSnackbar(errUnexpected) } - } - } - ) { - Text(stringResource(Res.string.confirm)) - } - }, - dismissButton = { - TextButton(onClick = { showLogoutAllConfirm = false }) { - Text(stringResource(Res.string.cancel)) - } - } - ) - } -} - -private object SecurityPasswordDraft { - var current: String = "" - var newPassword: String = "" - var confirmPassword: String = "" - - fun clear() { - current = "" - newPassword = "" - confirmPassword = "" - } -} - -@Composable -private fun SecurityPasswordOutlinedField( - value: String, - onValueChange: (String) -> Unit, - label: @Composable () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - colors: TextFieldColors, -) { - OutlinedTextField( - value = value, - onValueChange = onValueChange, - label = label, - modifier = modifier, - enabled = enabled, - visualTransformation = PasswordVisualTransformation(), - singleLine = true, - colors = colors, - shape = SettingsPasswordOutlineFieldShape, - ) -} - -@OptIn(ExperimentalLayoutApi::class, ExperimentalAnimationApi::class) -@Composable -fun SettingsSecurityPasswordFlowScreen(onBack: () -> Unit, onDonePopToHub: () -> Unit) { - val scope = rememberCoroutineScope() - val snackbarHostState = remember { SnackbarHostState() } - var snackbarToken by remember { mutableIntStateOf(0) } - - var current by remember { mutableStateOf("") } - var newP by remember { mutableStateOf("") } - var confirmP by remember { mutableStateOf("") } - var busy by remember { mutableStateOf(false) } - - val showSnack = { text: String -> - snackbarToken++ - val currentToken = snackbarToken - scope.launch { - snackbarHostState.showSnackbar( - message = text, - withDismissAction = false, - duration = SnackbarDuration.Indefinite, - ) - } - scope.launch { - delay(1000L) - if (snackbarToken == currentToken) { - snackbarHostState.currentSnackbarData?.dismiss() - } - } - } - - val fillAll = stringResource(Res.string.fill_all_fields) - val pwdLen = stringResource(Res.string.password_length_error) - val pwdMatch = stringResource(Res.string.passwords_dont_match) - val okMsg = stringResource(Res.string.settings_password_changed) - val errUnexpected = stringResource(Res.string.error_unexpected) - val username = ApiClient.user?.username.orEmpty() - - LaunchedEffect(Unit) { - SecurityPasswordDraft.clear() - current = "" - newP = "" - confirmP = "" - busy = false - } - - val pagerState = rememberPagerState( - initialPage = 0, - pageCount = { SecurityPasswordFlowStep.entries.size }, - ) - - val step = SecurityPasswordFlowStep.fromOrdinal(pagerState.currentPage) - val pageOffset = pagerState.currentPageOffsetFraction - - var predictiveFromStep by remember { mutableStateOf(null) } - var predictiveToStep by remember { mutableStateOf(null) } - var predictiveProgress by remember { mutableFloatStateOf(0f) } - val predictiveThreshold = 0.15f - - SettingsSecurityPredictiveBackHandler( - enabled = pagerState.currentPage > 0, - onProgress = { p -> - val clamped = p.coerceIn(0f, 1f) - if (clamped <= 0f) { - predictiveProgress = 0f - predictiveFromStep = null - predictiveToStep = null - } else { - if (predictiveFromStep == null || predictiveToStep == null) { - val fromPage = pagerState.currentPage - val toPage = (fromPage - 1).coerceAtLeast(0) - predictiveFromStep = SecurityPasswordFlowStep.fromOrdinal(fromPage) - predictiveToStep = SecurityPasswordFlowStep.fromOrdinal(toPage) - } - predictiveProgress = clamped - } - }, - onCommit = { - val fromStepSnapshot = predictiveFromStep - val toStepSnapshot = predictiveToStep - val lastProgress = predictiveProgress.coerceIn(0f, 1f) - // If progress is below threshold, treat as cancel and animate back to 0. - if (lastProgress < predictiveThreshold || fromStepSnapshot == null || toStepSnapshot == null) { - scope.launch { - val start = lastProgress - val anim = Animatable(start) - anim.animateTo( - targetValue = 0f, - animationSpec = tween(durationMillis = 220), - ) { - predictiveProgress = value - } - val fromPage = fromStepSnapshot?.ordinal ?: pagerState.currentPage - pagerState.scrollToPage(fromPage) - predictiveFromStep = null - predictiveToStep = null - predictiveProgress = 0f - } - } else { - scope.launch { - val start = lastProgress - val anim = Animatable(start) - anim.animateTo( - targetValue = 1f, - animationSpec = tween(durationMillis = 220), - ) { - predictiveProgress = value - } - pagerState.scrollToPage(toStepSnapshot.ordinal) - predictiveFromStep = null - predictiveToStep = null - predictiveProgress = 0f - } - } - }, - onCancel = { - val fromStepSnapshot = predictiveFromStep - val lastProgress = predictiveProgress.coerceIn(0f, 1f) - if (fromStepSnapshot == null) { - predictiveProgress = 0f - predictiveFromStep = null - predictiveToStep = null - return@SettingsSecurityPredictiveBackHandler - } - scope.launch { - val start = lastProgress - val target = if (lastProgress >= predictiveThreshold) 1f else 0f - val anim = Animatable(start) - anim.animateTo( - targetValue = target, - animationSpec = tween(durationMillis = 220), - ) { - predictiveProgress = value - } - val commit = lastProgress >= predictiveThreshold - val targetPage = if (commit) { - (fromStepSnapshot.ordinal - 1).coerceAtLeast(0) - } else { - fromStepSnapshot.ordinal - } - pagerState.scrollToPage(targetPage) - predictiveFromStep = null - predictiveToStep = null - predictiveProgress = 0f - } - }, - ) - - LaunchedEffect(predictiveFromStep, predictiveProgress) { - val fromStepForPager = predictiveFromStep ?: return@LaunchedEffect - val toStepForPager = predictiveToStep ?: return@LaunchedEffect - val clamped = predictiveProgress.coerceIn(0f, 1f) - if (clamped <= 0f) return@LaunchedEffect - val page: Int - val offset: Float - if (clamped <= 0.5f) { - page = fromStepForPager.ordinal - offset = (-clamped).coerceIn(-0.5f, 0f) - } else { - page = toStepForPager.ordinal - offset = (1f - clamped).coerceIn(0f, 0.5f) - } - pagerState.scrollToPage( - page = page, - pageOffsetFraction = offset, - ) - } - - val lastIndex = SecurityPasswordFlowStep.entries.lastIndex - val fromIndex: Int - val toIndex: Int - val morphProgress: Float - if (predictiveFromStep != null && predictiveToStep != null && predictiveProgress > 0f) { - fromIndex = predictiveFromStep!!.ordinal - toIndex = predictiveToStep!!.ordinal - morphProgress = predictiveProgress - } else if (pageOffset < 0f) { - fromIndex = pagerState.currentPage - toIndex = (fromIndex - 1).coerceAtLeast(0) - morphProgress = -pageOffset - } else if (pageOffset > 0f) { - fromIndex = pagerState.currentPage - toIndex = (fromIndex + 1).coerceAtMost(lastIndex) - morphProgress = pageOffset - } else { - fromIndex = pagerState.currentPage - toIndex = fromIndex - morphProgress = 0f - } - val fromStep = SecurityPasswordFlowStep.fromOrdinal(fromIndex) - val toStep = SecurityPasswordFlowStep.fromOrdinal(toIndex) - val effectiveMorphProgress = morphProgress.coerceIn(0f, 1f) - - val scheme = MaterialTheme.colorScheme - val passwordFieldColors = OutlinedTextFieldDefaults.colors( - focusedTextColor = scheme.onSurface, - unfocusedTextColor = scheme.onSurface, - disabledTextColor = scheme.onSurface.copy(alpha = 0.38f), - focusedLabelColor = scheme.primary, - unfocusedLabelColor = scheme.onSurfaceVariant, - disabledLabelColor = scheme.onSurfaceVariant.copy(alpha = 0.38f), - cursorColor = scheme.primary, - focusedBorderColor = scheme.primary, - unfocusedBorderColor = scheme.outline, - disabledBorderColor = scheme.onSurface.copy(alpha = 0.12f), - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - disabledContainerColor = Color.Transparent, - ) - - Surface( - modifier = Modifier.fillMaxSize(), - color = scheme.surface, - contentColor = scheme.onSurface, - ) { - Box( - modifier = Modifier - .fillMaxSize() - .imePadding() - ) { - Column( - modifier = Modifier - .fillMaxSize() - ) { - Box( - modifier = Modifier - .weight(1f) - .fillMaxWidth() - ) { - Column( - modifier = Modifier - .align(Alignment.TopStart) - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .imeScrollWithKeyboard() - .statusBarsPadding(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Spacer(Modifier.height(40.dp)) - Spacer(Modifier.height(4.dp)) - - SettingsSecurityMorphedPasswordHero( - step = step, - predictiveProgress = if (fromStep != toStep) effectiveMorphProgress else null, - predictiveFromStep = if (fromStep != toStep) fromStep else null, - predictiveToStep = if (fromStep != toStep) toStep else null, - ) - - Spacer(Modifier.height(16.dp)) - - // Pager layout height is max(cross-axis size) of composed pages (visible + beyondViewport). - // Default beyondViewport is small, so after predictive back only page 0 may be measured and - // the slot height can shrink vs the two-page gesture — content jumps up. Composing neighbors - // on both sides keeps max height stable across steps without visiting the last page first. - HorizontalPager( - state = pagerState, - userScrollEnabled = false, - beyondViewportPageCount = SecurityPasswordFlowStep.entries.lastIndex, - modifier = Modifier.fillMaxWidth(), - ) { page -> - val pageStep = SecurityPasswordFlowStep.fromOrdinal(page) - Box(Modifier.padding(horizontal = SettingsStepHorizontalPadding)) { - SecurityPasswordStepPage( - step = pageStep, - scheme = scheme, - passwordFieldColors = passwordFieldColors, - current = current, - onCurrentChange = { current = it }, - newP = newP, - onNewPChange = { newP = it }, - confirmP = confirmP, - onConfirmPChange = { confirmP = it }, - busy = busy, - ) - } - } - - Spacer(Modifier.height(16.dp)) - } - - IconButton( - onClick = { - if (pagerState.currentPage > 0) { - scope.launch { pagerState.animateScrollToPage(pagerState.currentPage - 1) } - } else { - onBack() - } - }, - modifier = Modifier - .align(Alignment.TopStart) - .statusBarsPadding() - .padding(start = 4.dp, top = 4.dp), - ) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(Res.string.back), - ) - } - } - - Box( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - .padding(horizontal = SettingsStepHorizontalPadding) - .padding(top = 12.dp, bottom = 16.dp) - ) { - Column( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - ) { - when (step) { - SecurityPasswordFlowStep.Current -> { - Button( - onClick = { - if (current.isBlank()) { - showSnack(fillAll) - return@Button - } - SecurityPasswordDraft.current = current - scope.launch { pagerState.animateScrollToPage(1) } - }, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 52.dp), - shape = SettingsSecurityCtaShape, - elevation = ButtonDefaults.buttonElevation( - defaultElevation = 0.dp, - pressedElevation = 0.dp, - focusedElevation = 0.dp, - hoveredElevation = 0.dp, - disabledElevation = 0.dp, - ), - ) { - PasswordFlowBottomButtonText(stringResource(Res.string.settings_next)) - } - } - - SecurityPasswordFlowStep.New -> { - Button( - onClick = { - if (newP.length !in 5..50) { - showSnack(pwdLen) - return@Button - } - SecurityPasswordDraft.newPassword = newP - scope.launch { pagerState.animateScrollToPage(2) } - }, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 52.dp), - shape = SettingsSecurityCtaShape, - elevation = ButtonDefaults.buttonElevation( - defaultElevation = 0.dp, - pressedElevation = 0.dp, - focusedElevation = 0.dp, - hoveredElevation = 0.dp, - disabledElevation = 0.dp, - ), - ) { - PasswordFlowBottomButtonText(stringResource(Res.string.settings_next)) - } - } - - SecurityPasswordFlowStep.Confirm -> { - Button( - onClick = { - if (confirmP.isBlank()) { - showSnack(fillAll) - return@Button - } - if (SecurityPasswordDraft.newPassword != confirmP) { - showSnack(pwdMatch) - return@Button - } - if (SecurityPasswordDraft.newPassword.length !in 5..50) { - showSnack(pwdLen) - return@Button - } - if (username.isBlank()) { - showSnack(errUnexpected) - return@Button - } - scope.launch { - busy = true - runCatching { - val curD = deriveAuthSecret(username, SecurityPasswordDraft.current) - val newD = deriveAuthSecret(username, SecurityPasswordDraft.newPassword) - ApiClient.changePassword(curD, newD, true) - }.onSuccess { - SecurityPasswordDraft.clear() - showSnack(okMsg) - onDonePopToHub() - }.onFailure { e -> - val msg = (e as? ClientRequestException)?.response?.let { "Error ${it.status.value}" } - ?: e.message - ?: errUnexpected - showSnack(msg) - } - busy = false - } - }, - enabled = !busy, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 52.dp), - shape = SettingsSecurityCtaShape, - elevation = ButtonDefaults.buttonElevation( - defaultElevation = 0.dp, - pressedElevation = 0.dp, - focusedElevation = 0.dp, - hoveredElevation = 0.dp, - disabledElevation = 0.dp, - ), - ) { - Box( - Modifier - .fillMaxWidth() - .defaultMinSize(minHeight = 24.dp), - contentAlignment = Alignment.Center - ) { - AnimatedContent( - targetState = busy, - transitionSpec = { - (fadeIn(tween(200)) + slideInVertically { it / 4 }) togetherWith - (fadeOut(tween(200)) + slideOutVertically { -it / 4 }) - }, - label = "change_password_cta" - ) { loading -> - if (loading) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary - ) - } else { - PasswordFlowBottomButtonText(stringResource(Res.string.settings_change_password)) - } - } - } - } - } - } - } - } - } - - FromChatSnackbarHost( - hostState = snackbarHostState, - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(horizontal = SettingsStepHorizontalPadding) - .padding(bottom = 76.dp) - .fillMaxWidth(), - snackbarModifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - ) - } - } -} - -@Composable -private fun PasswordFlowBottomButtonText(text: String) { - AnimatedContent( - targetState = text, - transitionSpec = { - (fadeIn(tween(220)) + slideInVertically { it / 3 }) togetherWith - (fadeOut(tween(220)) + slideOutVertically { -it / 3 }) - }, - label = "settings_password_button_text" - ) { label -> - Text(label) - } -} - -@Composable -private fun SecurityPasswordStepPage( - step: SecurityPasswordFlowStep, - scheme: androidx.compose.material3.ColorScheme, - passwordFieldColors: TextFieldColors, - current: String, - onCurrentChange: (String) -> Unit, - newP: String, - onNewPChange: (String) -> Unit, - confirmP: String, - onConfirmPChange: (String) -> Unit, - busy: Boolean, -) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - when (step) { - SecurityPasswordFlowStep.Current -> { - Text( - text = stringResource(Res.string.settings_security_step_current_title), - style = MaterialTheme.typography.titleLarge, - color = scheme.onSurface, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center - ) - Spacer(Modifier.height(6.dp)) - Text( - text = stringResource(Res.string.settings_security_step_current_body), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center - ) - Spacer(Modifier.height(16.dp)) - SecurityPasswordOutlinedField( - value = current, - onValueChange = onCurrentChange, - label = { Text(stringResource(Res.string.settings_current_password)) }, - modifier = Modifier.fillMaxWidth(), - colors = passwordFieldColors, - ) - } - - SecurityPasswordFlowStep.New -> { - Text( - text = stringResource(Res.string.settings_security_step_new_title), - style = MaterialTheme.typography.titleLarge, - color = scheme.onSurface, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center - ) - Spacer(Modifier.height(6.dp)) - Text( - text = stringResource(Res.string.settings_security_step_new_body), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center - ) - Spacer(Modifier.height(16.dp)) - SecurityPasswordOutlinedField( - value = newP, - onValueChange = onNewPChange, - label = { Text(stringResource(Res.string.settings_new_password)) }, - modifier = Modifier.fillMaxWidth(), - colors = passwordFieldColors, - ) - } - - SecurityPasswordFlowStep.Confirm -> { - Text( - text = stringResource(Res.string.settings_security_step_confirm_title), - style = MaterialTheme.typography.titleLarge, - color = scheme.onSurface, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center - ) - Spacer(Modifier.height(6.dp)) - Text( - text = stringResource(Res.string.settings_security_step_confirm_body), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center - ) - Spacer(Modifier.height(16.dp)) - SecurityPasswordOutlinedField( - value = confirmP, - onValueChange = onConfirmPChange, - label = { Text(stringResource(Res.string.settings_confirm_new_password)) }, - modifier = Modifier.fillMaxWidth(), - enabled = !busy, - colors = passwordFieldColors, - ) - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun SettingsAccountScreen(onBack: () -> Unit, onLogout: () -> Unit, onChangePassword: () -> Unit) { - val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) - val scope = rememberCoroutineScope() - val snackbarHostState = remember { SnackbarHostState() } - var showDeleteConfirm by remember { mutableStateOf(false) } - val errUnexpected = stringResource(Res.string.error_unexpected) - - Scaffold( - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) }, - topBar = { - MediumTopAppBar( - title = { Text(stringResource(Res.string.settings_account_title)) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back)) - } - }, - scrollBehavior = scrollBehavior - ) - } - ) { innerPadding -> - Column( - Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(innerPadding) - ) { - Category( - Modifier.padding(top = 16.dp), - containerColor = MaterialTheme.colorScheme.surfaceContainerLow - ) { - ListItem( - headline = stringResource(Res.string.settings_change_password), - supportingText = stringResource(Res.string.settings_security_change_password_sub), - onClick = onChangePassword, - leadingContent = { SettingsListLeadingIcon(Icons.Filled.Key) }, - divider = true, - dividerColor = settingsSurfaceCutDividerColor(), - dividerThickness = SettingsSurfaceCutDividerThickness, - trailingContent = { - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - ) - ListItem( - headline = stringResource(Res.string.logout), - onClick = { - scope.launch { - runCatching { ApiClient.logout() } - WebSocketManager.disconnect() - onLogout() - } - }, - leadingContent = { - Icon( - Icons.AutoMirrored.Filled.Logout, - null, - Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - }, - divider = true, - dividerColor = settingsSurfaceCutDividerColor(), - dividerThickness = SettingsSurfaceCutDividerThickness - ) - ListItem( - headline = stringResource(Res.string.settings_account_delete), - supportingText = stringResource(Res.string.settings_account_delete_d), - onClick = { showDeleteConfirm = true }, - leadingContent = { SettingsListLeadingIcon(Icons.Filled.AccountCircle) } - ) - } - } - } - - if (showDeleteConfirm) { - AlertDialog( - onDismissRequest = { showDeleteConfirm = false }, - title = { Text(stringResource(Res.string.settings_account_delete_confirm_title)) }, - text = { Text(stringResource(Res.string.settings_account_delete_confirm_body)) }, - confirmButton = { - TextButton( - onClick = { - showDeleteConfirm = false - scope.launch { - runCatching { - ApiClient.deleteAccount() - WebSocketManager.disconnect() - ApiClient.clearLocalSession() - onLogout() - }.onFailure { - snackbarHostState.showSnackbar(it.message ?: errUnexpected) - } - } - } - ) { - Text(stringResource(Res.string.confirm)) - } - }, - dismissButton = { - TextButton(onClick = { showDeleteConfirm = false }) { - Text(stringResource(Res.string.cancel)) - } - } - ) - } -} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt new file mode 100644 index 0000000..1459f1a --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt @@ -0,0 +1,121 @@ +package ru.fromchat.ui.main.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Devices +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MediumTopAppBar +import androidx.compose.material3.Scaffold +import ru.fromchat.ui.components.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.pr0gramm3r101.components.Category +import com.pr0gramm3r101.components.ListItem +import com.pr0gramm3r101.utils.verticalScroll +import org.jetbrains.compose.resources.stringResource +import ru.fromchat.Res +import ru.fromchat.about +import ru.fromchat.change_server +import ru.fromchat.change_server_d +import ru.fromchat.settings +import ru.fromchat.settings_category_account +import ru.fromchat.settings_category_account_d +import ru.fromchat.settings_category_appearance +import ru.fromchat.settings_category_appearance_d +import ru.fromchat.settings_category_devices +import ru.fromchat.settings_category_devices_d +import ru.fromchat.settings_category_notifications +import ru.fromchat.settings_category_notifications_d +import ru.fromchat.settings_hub_about_sub +import ru.fromchat.ui.LocalNavController + +val SettingsStepHorizontalPadding = 24.dp + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun SettingsTab() { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) + val navController = LocalNavController.current + + Scaffold( + modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + MediumTopAppBar( + title = { + Text(stringResource(Res.string.settings), maxLines = 1, overflow = TextOverflow.Ellipsis) + }, + scrollBehavior = scrollBehavior + ) + } + ) { innerPadding -> + Column( + Modifier + .fillMaxSize() + .verticalScroll() + .padding(innerPadding) + ) { + Category(Modifier.padding(top = 16.dp)) { + ListItem( + headline = stringResource(Res.string.settings_category_account), + supportingText = stringResource(Res.string.settings_category_account_d), + onClick = { navController.navigate(SettingsRoutes.Account) }, + leadingContent = { Icon(Icons.Filled.AccountCircle, null) }, + divider = true + ) + + ListItem( + headline = stringResource(Res.string.settings_category_devices), + supportingText = stringResource(Res.string.settings_category_devices_d), + onClick = { navController.navigate(SettingsRoutes.Devices) }, + leadingContent = { Icon(Icons.Filled.Devices, null) }, + divider = true + ) + + ListItem( + headline = stringResource(Res.string.settings_category_appearance), + supportingText = stringResource(Res.string.settings_category_appearance_d), + onClick = { navController.navigate(SettingsRoutes.Appearance) }, + leadingContent = { Icon(Icons.Filled.Palette, null) }, + divider = true + ) + + ListItem( + headline = stringResource(Res.string.settings_category_notifications), + supportingText = stringResource(Res.string.settings_category_notifications_d), + onClick = { navController.navigate(SettingsRoutes.Notifications) }, + leadingContent = { Icon(Icons.Filled.Notifications, null) }, + divider = true + ) + + ListItem( + headline = stringResource(Res.string.change_server), + supportingText = stringResource(Res.string.change_server_d), + onClick = { navController.navigate(SettingsRoutes.ServerConfig) }, + leadingContent = { Icon(Icons.Filled.Storage, null) }, + divider = true + ) + + ListItem( + headline = stringResource(Res.string.about), + supportingText = stringResource(Res.string.settings_hub_about_sub), + onClick = { navController.navigate(SettingsRoutes.About) }, + leadingContent = { Icon(Icons.Filled.Info, null) } + ) + } + } + } +} \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/AccountScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/AccountScreen.kt new file mode 100644 index 0000000..39a9fda --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/AccountScreen.kt @@ -0,0 +1,168 @@ +@file:Suppress("AssignedValueIsNeverRead") + +package ru.fromchat.ui.main.settings.account + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.automirrored.filled.Logout +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Key +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MediumTopAppBar +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHostState +import ru.fromchat.ui.components.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.unit.dp +import com.pr0gramm3r101.components.Category +import com.pr0gramm3r101.components.ListItem +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource +import ru.fromchat.Res +import ru.fromchat.api.ApiClient +import ru.fromchat.api.local.WebSocketManager +import ru.fromchat.back +import ru.fromchat.cancel +import ru.fromchat.confirm +import ru.fromchat.error_unexpected +import ru.fromchat.logout +import ru.fromchat.settings_account_delete +import ru.fromchat.settings_account_delete_confirm_body +import ru.fromchat.settings_account_delete_confirm_title +import ru.fromchat.settings_account_delete_d +import ru.fromchat.settings_account_title +import ru.fromchat.settings_change_password +import ru.fromchat.settings_security_change_password_sub +import ru.fromchat.ui.components.FromChatSnackbarHost + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AccountScreen(onBack: () -> Unit, onLogout: () -> Unit, onChangePassword: () -> Unit) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + var showDeleteConfirm by remember { mutableStateOf(false) } + val errUnexpected = stringResource(Res.string.error_unexpected) + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) }, + topBar = { + MediumTopAppBar( + title = { Text(stringResource(Res.string.settings_account_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back)) + } + }, + scrollBehavior = scrollBehavior + ) + } + ) { innerPadding -> + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(innerPadding) + ) { + Category( + Modifier.padding(top = 16.dp), + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ) { + ListItem( + headline = stringResource(Res.string.settings_change_password), + supportingText = stringResource(Res.string.settings_security_change_password_sub), + onClick = onChangePassword, + leadingContent = { Icon(Icons.Filled.Key, null) }, + divider = true, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + ) + ListItem( + headline = stringResource(Res.string.logout), + onClick = { + scope.launch { + runCatching { ApiClient.logout() } + WebSocketManager.disconnect() + onLogout() + } + }, + leadingContent = { + Icon( + Icons.AutoMirrored.Filled.Logout, + null, + Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + divider = true + ) + ListItem( + headline = stringResource(Res.string.settings_account_delete), + supportingText = stringResource(Res.string.settings_account_delete_d), + onClick = { showDeleteConfirm = true }, + leadingContent = { Icon(Icons.Filled.AccountCircle, null) } + ) + } + } + } + + if (showDeleteConfirm) { + AlertDialog( + onDismissRequest = { showDeleteConfirm = false }, + title = { Text(stringResource(Res.string.settings_account_delete_confirm_title)) }, + text = { Text(stringResource(Res.string.settings_account_delete_confirm_body)) }, + confirmButton = { + TextButton( + onClick = { + showDeleteConfirm = false + scope.launch { + runCatching { + ApiClient.deleteAccount() + WebSocketManager.disconnect() + ApiClient.clearLocalSession() + onLogout() + }.onFailure { + snackbarHostState.showSnackbar(it.message ?: errUnexpected) + } + } + } + ) { + Text(stringResource(Res.string.confirm)) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirm = false }) { + Text(stringResource(Res.string.cancel)) + } + } + ) + } +} \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/ChangePasswordScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/ChangePasswordScreen.kt new file mode 100644 index 0000000..24eb789 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/ChangePasswordScreen.kt @@ -0,0 +1,892 @@ +package ru.fromchat.ui.main.settings.account + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.VerifiedUser +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.TextFieldColors +import androidx.compose.material3.toPath +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.graphics.shapes.Morph +import androidx.graphics.shapes.RoundedPolygon +import com.pr0gramm3r101.utils.crypto.deriveAuthSecret +import com.pr0gramm3r101.utils.imeScrollWithKeyboard +import io.ktor.client.plugins.ClientRequestException +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource +import ru.fromchat.Res +import ru.fromchat.api.ApiClient +import ru.fromchat.back +import ru.fromchat.error_unexpected +import ru.fromchat.fill_all_fields +import ru.fromchat.password_length_error +import ru.fromchat.passwords_dont_match +import ru.fromchat.settings_change_password +import ru.fromchat.settings_confirm_new_password +import ru.fromchat.settings_current_password +import ru.fromchat.settings_new_password +import ru.fromchat.settings_next +import ru.fromchat.settings_password_changed +import ru.fromchat.settings_security_step_confirm_body +import ru.fromchat.settings_security_step_confirm_title +import ru.fromchat.settings_security_step_current_body +import ru.fromchat.settings_security_step_current_title +import ru.fromchat.settings_security_step_new_body +import ru.fromchat.settings_security_step_new_title +import ru.fromchat.ui.components.CtaShape +import ru.fromchat.ui.components.FromChatSnackbarHost +import ru.fromchat.ui.components.PredictiveBackHandler +import ru.fromchat.ui.components.Text +import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding + +/** Outline shape for password step text fields. */ +val SettingsPasswordOutlineFieldShape = RoundedCornerShape(18.dp) + +/** Steps in the change-password flow (single navigation destination; drives hero morph + form slide). */ +enum class SecurityPasswordFlowStep { + Current, + New, + Confirm, + ; + + companion object { + fun fromOrdinal(ordinal: Int): SecurityPasswordFlowStep = + entries.getOrElse(ordinal.coerceIn(0, entries.lastIndex)) { Current } + } +} + +/** + * Predefined [RoundedPolygon]s from the Material 3 shape library ([MaterialShapes]), one per step. + * Uses the same “cookie” family so morphs stay subtle between steps. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +fun securityHeroMaterialPolygon(step: SecurityPasswordFlowStep): RoundedPolygon = + when (step) { + SecurityPasswordFlowStep.Current -> MaterialShapes.Cookie4Sided + SecurityPasswordFlowStep.New -> MaterialShapes.Cookie6Sided + SecurityPasswordFlowStep.Confirm -> MaterialShapes.Cookie7Sided + }.normalized() + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun rememberSecurityPasswordHeroMorph(step: SecurityPasswordFlowStep): Pair> { + var fromStep by remember { mutableStateOf(step) } + var toStep by remember { mutableStateOf(step) } + val progress = remember { Animatable(1f) } + LaunchedEffect(step) { + if (step != toStep) { + fromStep = toStep + toStep = step + progress.snapTo(0f) + progress.animateTo( + targetValue = 1f, + animationSpec = spring(dampingRatio = 0.82f, stiffness = 380f), + ) + } + } + val morph = remember(fromStep, toStep) { + Morph(securityHeroMaterialPolygon(fromStep), securityHeroMaterialPolygon(toStep)) + } + return morph to progress +} + +private object SecurityPasswordDraft { + var current: String = "" + var newPassword: String = "" + var confirmPassword: String = "" + + fun clear() { + current = "" + newPassword = "" + confirmPassword = "" + } +} + +@Composable +private fun SecurityPasswordOutlinedField( + value: String, + onValueChange: (String) -> Unit, + label: @Composable () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + colors: TextFieldColors, +) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = label, + modifier = modifier, + enabled = enabled, + visualTransformation = PasswordVisualTransformation(), + singleLine = true, + colors = colors, + shape = SettingsPasswordOutlineFieldShape, + ) +} + +@OptIn(ExperimentalLayoutApi::class, ExperimentalAnimationApi::class) +@Composable +fun SettingsSecurityPasswordFlowScreen(onBack: () -> Unit, onDonePopToHub: () -> Unit) { + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + + var current by remember { mutableStateOf("") } + var newP by remember { mutableStateOf("") } + var confirmP by remember { mutableStateOf("") } + var busy by remember { mutableStateOf(false) } + + val showSnack = { text: String -> + scope.launch { + snackbarHostState.showSnackbar( + message = text, + withDismissAction = false, + duration = SnackbarDuration.Short, + ) + } + } + + val fillAll = stringResource(Res.string.fill_all_fields) + val pwdLen = stringResource(Res.string.password_length_error) + val pwdMatch = stringResource(Res.string.passwords_dont_match) + val okMsg = stringResource(Res.string.settings_password_changed) + val errUnexpected = stringResource(Res.string.error_unexpected) + val username = ApiClient.user?.username.orEmpty() + + LaunchedEffect(Unit) { + SecurityPasswordDraft.clear() + current = "" + newP = "" + confirmP = "" + busy = false + } + + val pagerState = rememberPagerState( + initialPage = 0, + pageCount = { SecurityPasswordFlowStep.entries.size }, + ) + + val step = SecurityPasswordFlowStep.fromOrdinal(pagerState.currentPage) + val pageOffset by derivedStateOf { pagerState.currentPageOffsetFraction } + + var predictiveFromStep by remember { mutableStateOf(null) } + var predictiveToStep by remember { mutableStateOf(null) } + var predictiveProgress by remember { mutableFloatStateOf(0f) } + val predictiveThreshold = 0.15f + + PredictiveBackHandler( + enabled = pagerState.currentPage > 0, + onProgress = { p -> + val clamped = p.coerceIn(0f, 1f) + if (clamped <= 0f) { + predictiveProgress = 0f + predictiveFromStep = null + predictiveToStep = null + } else { + if (predictiveFromStep == null || predictiveToStep == null) { + val fromPage = pagerState.currentPage + val toPage = (fromPage - 1).coerceAtLeast(0) + predictiveFromStep = SecurityPasswordFlowStep.fromOrdinal(fromPage) + predictiveToStep = SecurityPasswordFlowStep.fromOrdinal(toPage) + } + predictiveProgress = clamped + } + }, + onCommit = { + val fromStepSnapshot = predictiveFromStep + val toStepSnapshot = predictiveToStep + val lastProgress = predictiveProgress.coerceIn(0f, 1f) + // If progress is below threshold, treat as cancel and animate back to 0. + if (lastProgress < predictiveThreshold || fromStepSnapshot == null || toStepSnapshot == null) { + scope.launch { + Animatable(lastProgress).animateTo( + targetValue = 0f, + animationSpec = tween(durationMillis = 220), + ) { + predictiveProgress = value + } + val fromPage = fromStepSnapshot?.ordinal ?: pagerState.currentPage + pagerState.scrollToPage(fromPage) + predictiveFromStep = null + predictiveToStep = null + predictiveProgress = 0f + } + } else { + scope.launch { + Animatable(lastProgress).animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = 220), + ) { + predictiveProgress = value + } + pagerState.scrollToPage(toStepSnapshot.ordinal) + predictiveFromStep = null + predictiveToStep = null + predictiveProgress = 0f + } + } + }, + onCancel = { + val fromStepSnapshot = predictiveFromStep + val lastProgress = predictiveProgress.coerceIn(0f, 1f) + if (fromStepSnapshot == null) { + predictiveProgress = 0f + predictiveFromStep = null + predictiveToStep = null + return@PredictiveBackHandler + } + scope.launch { + val target = if (lastProgress >= predictiveThreshold) 1f else 0f + Animatable(lastProgress).animateTo( + targetValue = target, + animationSpec = tween(durationMillis = 220), + ) { + predictiveProgress = value + } + val commit = lastProgress >= predictiveThreshold + val targetPage = if (commit) { + (fromStepSnapshot.ordinal - 1).coerceAtLeast(0) + } else { + fromStepSnapshot.ordinal + } + pagerState.scrollToPage(targetPage) + predictiveFromStep = null + predictiveToStep = null + predictiveProgress = 0f + } + }, + ) + + LaunchedEffect(predictiveFromStep, predictiveProgress) { + val fromStepForPager = predictiveFromStep ?: return@LaunchedEffect + val toStepForPager = predictiveToStep ?: return@LaunchedEffect + val clamped = predictiveProgress.coerceIn(0f, 1f) + if (clamped <= 0f) return@LaunchedEffect + val page: Int + val offset: Float + if (clamped <= 0.5f) { + page = fromStepForPager.ordinal + offset = (-clamped).coerceIn(-0.5f, 0f) + } else { + page = toStepForPager.ordinal + offset = (1f - clamped).coerceIn(0f, 0.5f) + } + pagerState.scrollToPage( + page = page, + pageOffsetFraction = offset, + ) + } + + val lastIndex = SecurityPasswordFlowStep.entries.lastIndex + val fromIndex: Int + val toIndex: Int + val morphProgress: Float + if (predictiveFromStep != null && predictiveToStep != null && predictiveProgress > 0f) { + fromIndex = predictiveFromStep!!.ordinal + toIndex = predictiveToStep!!.ordinal + morphProgress = predictiveProgress + } else if (pageOffset < 0f) { + fromIndex = pagerState.currentPage + toIndex = (fromIndex - 1).coerceAtLeast(0) + morphProgress = -pageOffset + } else if (pageOffset > 0f) { + fromIndex = pagerState.currentPage + toIndex = (fromIndex + 1).coerceAtMost(lastIndex) + morphProgress = pageOffset + } else { + fromIndex = pagerState.currentPage + toIndex = fromIndex + morphProgress = 0f + } + val fromStep = SecurityPasswordFlowStep.fromOrdinal(fromIndex) + val toStep = SecurityPasswordFlowStep.fromOrdinal(toIndex) + val effectiveMorphProgress = morphProgress.coerceIn(0f, 1f) + + val scheme = MaterialTheme.colorScheme + val passwordFieldColors = OutlinedTextFieldDefaults.colors( + focusedTextColor = scheme.onSurface, + unfocusedTextColor = scheme.onSurface, + disabledTextColor = scheme.onSurface.copy(alpha = 0.38f), + focusedLabelColor = scheme.primary, + unfocusedLabelColor = scheme.onSurfaceVariant, + disabledLabelColor = scheme.onSurfaceVariant.copy(alpha = 0.38f), + cursorColor = scheme.primary, + focusedBorderColor = scheme.primary, + unfocusedBorderColor = scheme.outline, + disabledBorderColor = scheme.onSurface.copy(alpha = 0.12f), + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + ) + + Surface( + modifier = Modifier.fillMaxSize(), + color = scheme.surface, + contentColor = scheme.onSurface, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .imePadding() + ) { + Column( + modifier = Modifier + .fillMaxSize() + ) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + Column( + modifier = Modifier + .align(Alignment.TopStart) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .imeScrollWithKeyboard() + .statusBarsPadding(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(40.dp)) + Spacer(Modifier.height(4.dp)) + + SettingsSecurityMorphedPasswordHero( + step = step, + predictiveProgress = if (fromStep != toStep) effectiveMorphProgress else null, + predictiveFromStep = if (fromStep != toStep) fromStep else null, + predictiveToStep = if (fromStep != toStep) toStep else null, + ) + + Spacer(Modifier.height(16.dp)) + + // Pager layout height is max(cross-axis size) of composed pages (visible + beyondViewport). + // Default beyondViewport is small, so after predictive back only page 0 may be measured and + // the slot height can shrink vs the two-page gesture — content jumps up. Composing neighbors + // on both sides keeps max height stable across steps without visiting the last page first. + HorizontalPager( + state = pagerState, + userScrollEnabled = false, + beyondViewportPageCount = SecurityPasswordFlowStep.entries.lastIndex, + modifier = Modifier.fillMaxWidth(), + ) { page -> + val pageStep = SecurityPasswordFlowStep.fromOrdinal(page) + Box(Modifier.padding(horizontal = SettingsStepHorizontalPadding)) { + SecurityPasswordStepPage( + step = pageStep, + scheme = scheme, + passwordFieldColors = passwordFieldColors, + current = current, + onCurrentChange = { current = it }, + newP = newP, + onNewPChange = { newP = it }, + confirmP = confirmP, + onConfirmPChange = { confirmP = it }, + busy = busy, + ) + } + } + + Spacer(Modifier.height(16.dp)) + } + + IconButton( + onClick = { + if (pagerState.currentPage > 0) { + scope.launch { pagerState.animateScrollToPage(pagerState.currentPage - 1) } + } else { + onBack() + } + }, + modifier = Modifier + .align(Alignment.TopStart) + .statusBarsPadding() + .padding(start = 4.dp, top = 4.dp), + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.back), + ) + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = SettingsStepHorizontalPadding) + .padding(top = 12.dp, bottom = 16.dp) + ) { + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + ) { + when (step) { + SecurityPasswordFlowStep.Current -> { + Button( + onClick = { + if (current.isBlank()) { + showSnack(fillAll) + return@Button + } + SecurityPasswordDraft.current = current + scope.launch { pagerState.animateScrollToPage(1) } + }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 52.dp), + shape = CtaShape, + elevation = ButtonDefaults.buttonElevation( + defaultElevation = 0.dp, + pressedElevation = 0.dp, + focusedElevation = 0.dp, + hoveredElevation = 0.dp, + disabledElevation = 0.dp, + ), + ) { + PasswordFlowBottomButtonText(stringResource(Res.string.settings_next)) + } + } + + SecurityPasswordFlowStep.New -> { + Button( + onClick = { + if (newP.length !in 5..50) { + showSnack(pwdLen) + return@Button + } + SecurityPasswordDraft.newPassword = newP + scope.launch { pagerState.animateScrollToPage(2) } + }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 52.dp), + shape = CtaShape, + elevation = ButtonDefaults.buttonElevation( + defaultElevation = 0.dp, + pressedElevation = 0.dp, + focusedElevation = 0.dp, + hoveredElevation = 0.dp, + disabledElevation = 0.dp, + ), + ) { + PasswordFlowBottomButtonText(stringResource(Res.string.settings_next)) + } + } + + SecurityPasswordFlowStep.Confirm -> { + Button( + onClick = { + if (confirmP.isBlank()) { + showSnack(fillAll) + return@Button + } + if (SecurityPasswordDraft.newPassword != confirmP) { + showSnack(pwdMatch) + return@Button + } + if (SecurityPasswordDraft.newPassword.length !in 5..50) { + showSnack(pwdLen) + return@Button + } + if (username.isBlank()) { + showSnack(errUnexpected) + return@Button + } + scope.launch { + busy = true + runCatching { + val curD = deriveAuthSecret(username, SecurityPasswordDraft.current) + val newD = deriveAuthSecret(username, SecurityPasswordDraft.newPassword) + ApiClient.changePassword(curD, newD, true) + }.onSuccess { + SecurityPasswordDraft.clear() + showSnack(okMsg) + onDonePopToHub() + }.onFailure { e -> + val msg = (e as? ClientRequestException)?.response?.let { "Error ${it.status.value}" } + ?: e.message + ?: errUnexpected + showSnack(msg) + } + busy = false + } + }, + enabled = !busy, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 52.dp), + shape = CtaShape, + elevation = ButtonDefaults.buttonElevation( + defaultElevation = 0.dp, + pressedElevation = 0.dp, + focusedElevation = 0.dp, + hoveredElevation = 0.dp, + disabledElevation = 0.dp, + ), + ) { + Box( + Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 24.dp), + contentAlignment = Alignment.Center + ) { + AnimatedContent( + targetState = busy, + transitionSpec = { + (fadeIn(tween(200)) + slideInVertically { it / 4 }) togetherWith + (fadeOut(tween(200)) + slideOutVertically { -it / 4 }) + }, + label = "change_password_cta" + ) { loading -> + if (loading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + PasswordFlowBottomButtonText(stringResource(Res.string.settings_change_password)) + } + } + } + } + } + } + } + } + } + + FromChatSnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = SettingsStepHorizontalPadding) + .padding(bottom = 76.dp) + .fillMaxWidth(), + snackbarModifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + ) + } + } +} + +@Composable +private fun PasswordFlowBottomButtonText(text: String) { + AnimatedContent( + targetState = text, + transitionSpec = { + (fadeIn(tween(220)) + slideInVertically { it / 3 }) togetherWith + (fadeOut(tween(220)) + slideOutVertically { -it / 3 }) + }, + label = "settings_password_button_text" + ) { label -> + Text(label) + } +} + +@Composable +private fun SecurityPasswordStepPage( + step: SecurityPasswordFlowStep, + scheme: androidx.compose.material3.ColorScheme, + passwordFieldColors: TextFieldColors, + current: String, + onCurrentChange: (String) -> Unit, + newP: String, + onNewPChange: (String) -> Unit, + confirmP: String, + onConfirmPChange: (String) -> Unit, + busy: Boolean, +) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + when (step) { + SecurityPasswordFlowStep.Current -> { + Text( + text = stringResource(Res.string.settings_security_step_current_title), + style = MaterialTheme.typography.titleLarge, + color = scheme.onSurface, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(6.dp)) + Text( + text = stringResource(Res.string.settings_security_step_current_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(16.dp)) + SecurityPasswordOutlinedField( + value = current, + onValueChange = onCurrentChange, + label = { Text(stringResource(Res.string.settings_current_password)) }, + modifier = Modifier.fillMaxWidth(), + colors = passwordFieldColors, + ) + } + + SecurityPasswordFlowStep.New -> { + Text( + text = stringResource(Res.string.settings_security_step_new_title), + style = MaterialTheme.typography.titleLarge, + color = scheme.onSurface, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(6.dp)) + Text( + text = stringResource(Res.string.settings_security_step_new_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(16.dp)) + SecurityPasswordOutlinedField( + value = newP, + onValueChange = onNewPChange, + label = { Text(stringResource(Res.string.settings_new_password)) }, + modifier = Modifier.fillMaxWidth(), + colors = passwordFieldColors, + ) + } + + SecurityPasswordFlowStep.Confirm -> { + Text( + text = stringResource(Res.string.settings_security_step_confirm_title), + style = MaterialTheme.typography.titleLarge, + color = scheme.onSurface, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(6.dp)) + Text( + text = stringResource(Res.string.settings_security_step_confirm_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(16.dp)) + SecurityPasswordOutlinedField( + value = confirmP, + onValueChange = onConfirmPChange, + label = { Text(stringResource(Res.string.settings_confirm_new_password)) }, + modifier = Modifier.fillMaxWidth(), + enabled = !busy, + colors = passwordFieldColors, + ) + } + } + } +} + +/** + * Large step icon with gradient fill; **shape morphs** between library polygons ([MaterialShapes] via [Morph]), + * icon **crossfades**. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalAnimationApi::class) +@Composable +fun SettingsSecurityMorphedPasswordHero( + step: SecurityPasswordFlowStep, + modifier: Modifier = Modifier, + containerSize: Dp = 132.dp, + iconSize: Dp = 48.dp, + predictiveProgress: Float? = null, + predictiveFromStep: SecurityPasswordFlowStep? = null, + predictiveToStep: SecurityPasswordFlowStep? = null, +) { + val usePredictive = predictiveProgress != null && predictiveFromStep != null && predictiveToStep != null + + val fromStep = when { + usePredictive -> predictiveFromStep + else -> step + } + val toStep = when { + usePredictive -> predictiveToStep + else -> step + } + + val morph = remember(fromStep, toStep) { + Morph( + securityHeroMaterialPolygon(fromStep), + securityHeroMaterialPolygon(toStep), + ) + } + + val scheme = MaterialTheme.colorScheme + val fromContainer = when (fromStep) { + SecurityPasswordFlowStep.Current -> scheme.secondaryContainer + SecurityPasswordFlowStep.New -> scheme.tertiaryContainer + SecurityPasswordFlowStep.Confirm -> scheme.primaryContainer + } + val toContainer = when (toStep) { + SecurityPasswordFlowStep.Current -> scheme.secondaryContainer + SecurityPasswordFlowStep.New -> scheme.tertiaryContainer + SecurityPasswordFlowStep.Confirm -> scheme.primaryContainer + } + val fromContent = when (fromStep) { + SecurityPasswordFlowStep.Current -> scheme.onSecondaryContainer + SecurityPasswordFlowStep.New -> scheme.onTertiaryContainer + SecurityPasswordFlowStep.Confirm -> scheme.onPrimaryContainer + } + val toContent = when (toStep) { + SecurityPasswordFlowStep.Current -> scheme.onSecondaryContainer + SecurityPasswordFlowStep.New -> scheme.onTertiaryContainer + SecurityPasswordFlowStep.Confirm -> scheme.onPrimaryContainer + } + + val p = (predictiveProgress ?: 0f).coerceIn(0f, 1f) + val deep = lerp(fromContainer, toContainer, p) + val contentColor = lerp(fromContent, toContent, p) + val light = deep.copy(alpha = 0.72f) + + Box( + modifier = modifier.size(containerSize), + contentAlignment = Alignment.Center + ) { + Canvas(Modifier.fillMaxSize()) { + val unit = minOf(size.width, size.height) * 0.94f + + translate(left = size.width / 2f, top = size.height / 2f) { + scale(scaleX = unit, scaleY = unit, pivot = Offset.Zero) { + translate(left = -0.5f, top = -0.5f) { + drawPath( + path = morph.toPath(p, Path()), + brush = Brush.linearGradient( + colors = listOf(light, deep), + start = Offset.Zero, + end = Offset(1f, 1f), + ), + ) + } + } + } + } + + val fromIcon = when (fromStep) { + SecurityPasswordFlowStep.Current -> Icons.Filled.Key + SecurityPasswordFlowStep.New -> Icons.Filled.Lock + SecurityPasswordFlowStep.Confirm -> Icons.Filled.VerifiedUser + } + + val toIcon = when (toStep) { + SecurityPasswordFlowStep.Current -> Icons.Filled.Key + SecurityPasswordFlowStep.New -> Icons.Filled.Lock + SecurityPasswordFlowStep.Confirm -> Icons.Filled.VerifiedUser + } + + if (usePredictive && fromStep != toStep) { + Box( + modifier = Modifier.size(iconSize), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = fromIcon, + contentDescription = null, + modifier = Modifier + .matchParentSize() + .graphicsLayer { alpha = 1f - p }, + tint = contentColor, + ) + Icon( + imageVector = toIcon, + contentDescription = null, + modifier = Modifier + .matchParentSize() + .graphicsLayer { alpha = p }, + tint = contentColor, + ) + } + } else { + AnimatedContent( + targetState = step, + transitionSpec = { + fadeIn(tween(220)) togetherWith fadeOut(tween(180)) + }, + label = "security_hero_icon" + ) { s -> + val icon = when (s) { + SecurityPasswordFlowStep.Current -> Icons.Filled.Key + SecurityPasswordFlowStep.New -> Icons.Filled.Lock + SecurityPasswordFlowStep.Confirm -> Icons.Filled.VerifiedUser + } + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(iconSize), + tint = contentColor + ) + } + } + } +} \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/HostValidation.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/HostValidation.kt new file mode 100644 index 0000000..2d54f4a --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/HostValidation.kt @@ -0,0 +1,81 @@ +package ru.fromchat.ui.main.settings.server + +private fun isAllowedHostChar(ch: Char) = ch.isLetterOrDigit() || ch in ".:-[]_" + +internal fun filterHostInput(raw: String) = raw.filter(::isAllowedHostChar).take(253) + +internal fun isValidPortNumber(text: String): Boolean = + (text.ifBlank { return true }.toIntOrNull() ?: return false) in 1..65535 + +internal fun isValidIpOrHostname(host: String) = host.trim().let { h -> + !( + h.isEmpty() || + h.length > 253 || + h.any { !isAllowedHostChar(it) } + ) && ( + isValidIpv4(h) || + isValidIpv6Bracketed(h) || + isValidHostname(h) + ) +} + +private fun isValidIpv4(s: String): Boolean { + for (x in s.split('.').also { if (it.size != 4) return false }) { + val n = x.toIntOrNull() ?: return false + if (n !in 0..255) return false + } + + return true +} + +private fun isValidIpv6Bracketed(s: String): Boolean { + if (!s.startsWith('[') || !s.contains(']')) return false + + val end = s.indexOf(']') + if (end <= 1) return false + + val inner = s.substring(1, end) + if (inner.isBlank() || !inner.all { it.isDigit() || it in "abcdefABCDEF:" }) return false + + return inner.count { it == ':' } >= 2 +} + +private fun isValidHostname(host: String): Boolean { + if ( + host.contains(':') || + host.startsWith('[') || + host.endsWith('.') + ) return false + + for ( + label in host + .split('.') + .also { if (it.isEmpty() || it.size > 127) return false } + ) { + if ( + label.isEmpty() || + label.length > 63 || + label.startsWith('-') || + label.endsWith('-') || + !label.all { + it.isLetterOrDigit() || + it == '-' || + it == '_' + } + ) return false + } + + return true +} + +/** Host part for URL authority (brackets IPv6). */ +internal fun hostForAuthority(host: String) = + host + .trim() + .let { + if ( + it.contains(':') && + !it.startsWith("[") + ) "[$it]" + else it + } \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/ServerConfigScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/ServerConfigScreen.kt new file mode 100644 index 0000000..663da01 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/ServerConfigScreen.kt @@ -0,0 +1,812 @@ +@file:Suppress("NOTHING_TO_INLINE") + +package ru.fromchat.ui.main.settings.server + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.BringIntoViewSpec +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Call +import androidx.compose.material.icons.filled.Dns +import androidx.compose.material.icons.filled.FlashOn +import androidx.compose.material.icons.filled.Http +import androidx.compose.material.icons.filled.RestartAlt +import androidx.compose.material.icons.filled.Shield +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHostState +import ru.fromchat.ui.components.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.pr0gramm3r101.components.Category +import com.pr0gramm3r101.components.SwitchListItem +import com.pr0gramm3r101.utils.navigateAndWipeBackStack +import com.pr0gramm3r101.utils.toDp +import com.pr0gramm3r101.utils.toPx +import dev.chrisbanes.haze.HazeProgressive +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import dev.chrisbanes.haze.materials.HazeMaterials +import dev.chrisbanes.haze.rememberHazeState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource +import ru.fromchat.Res +import ru.fromchat.api.ApiClient +import ru.fromchat.api.local.WebSocketManager +import ru.fromchat.api_port_label +import ru.fromchat.back +import ru.fromchat.calls_port_label +import ru.fromchat.cancel +import ru.fromchat.confirm +import ru.fromchat.config.DEFAULT_CALLS_PORT +import ru.fromchat.config.ServerConfigData +import ru.fromchat.config.Settings +import ru.fromchat.api.instance.ServerProbeResult +import ru.fromchat.api.instance.applyServerAndNavigate +import ru.fromchat.api.instance.probeServer +import ru.fromchat.save_continue +import ru.fromchat.server_config_action_check +import ru.fromchat.server_config_action_reset +import ru.fromchat.server_config_action_reset_confirm_body +import ru.fromchat.server_config_action_reset_confirm_title +import ru.fromchat.server_config_checking +import ru.fromchat.server_config_host_error +import ru.fromchat.server_config_https_headline +import ru.fromchat.server_config_https_local_hint +import ru.fromchat.server_config_port_error +import ru.fromchat.server_config_snackbar_api_fail +import ru.fromchat.server_config_snackbar_defaults +import ru.fromchat.server_config_snackbar_ok_calls +import ru.fromchat.server_config_snackbar_ok_calls_skip +import ru.fromchat.server_config_snackbar_timeout +import ru.fromchat.server_config_subtitle +import ru.fromchat.server_config_title +import ru.fromchat.server_config_unsupported_no_instance_id +import ru.fromchat.server_ip_hint +import ru.fromchat.server_ip_label +import ru.fromchat.ui.LocalNavController +import ru.fromchat.ui.components.CtaShape +import ru.fromchat.ui.components.ExpressiveIconFrame +import ru.fromchat.ui.components.FromChatSnackbarHost +import ru.fromchat.ui.components.HazeActionButton +import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding +import ru.fromchat.ui.main.settings.account.SettingsPasswordOutlineFieldShape +import kotlin.math.roundToInt + + +/** + * Fixed [Dp] gaps between all children (resolved in [Density.arrange] like [Arrangement.spacedBy]), + * plus extra height inserted **only** before the last child so it sits at the bottom when content + * is shorter than the viewport. Uses a stable [remember] so [4.dp.roundToPx] changes from + * recomposition do not swap the arrangement instance and nudge spacing by a pixel. + */ +@Stable +private class ServerConfigSpacedByLastAnchoredBottom( + private val space: Dp, +) : Arrangement.Vertical { + override val spacing get() = space + + override fun Density.arrange( + totalSize: Int, + sizes: IntArray, + outPositions: IntArray, + ) { + val spacePx = space.roundToPx() + when (sizes.size) { + 0 -> return + 1 -> { + outPositions[0] = (totalSize - sizes[0]).coerceAtLeast(0) + return + } + } + + var y = 0 + for (i in 0 until sizes.size - 1) { + outPositions[i] = y + y += sizes[i] + spacePx + } + + outPositions[sizes.size - 1] = y + (totalSize - sizes.sum() - (spacePx * (sizes.size - 1))).coerceAtLeast(0) + } +} + +private inline fun port(text: String, default: Int) = text + .trim() + .ifEmpty { default } + .let { (it as String).toIntOrNull() } + ?.takeIf { it in 1..65535 } ?: default + +private fun resolvedApiPort(apiPortText: String) = port(apiPortText, 443) +private fun resolvedCallsPort(callsPortText: String) = port(callsPortText, DEFAULT_CALLS_PORT) + +private suspend fun LazyListState.scrollFocusedItemIntoView( + itemIndex: Int, + viewportMarginPx: Float, +) { + if (layoutInfo.visibleItemsInfo.none { it.index == itemIndex }) { + animateScrollToItem(itemIndex) + } + + val viewportStart = layoutInfo.viewportStartOffset + viewportMarginPx + val viewportEnd = (layoutInfo.viewportEndOffset - layoutInfo.afterContentPadding - viewportMarginPx) + .coerceAtLeast(viewportStart) + + val item = layoutInfo.visibleItemsInfo.firstOrNull { it.index == itemIndex } ?: return + + val itemStart = item.offset.toFloat() + val itemEnd = itemStart + item.size + + when { + itemStart < viewportStart -> itemStart - viewportStart + itemEnd > viewportEnd -> itemEnd - viewportEnd + else -> 0f + }.also { if (it != 0f) animateScrollBy(it) } +} + + + +@OptIn( + ExperimentalFoundationApi::class, + ExperimentalMaterial3Api::class, + ExperimentalMaterial3ExpressiveApi::class, + ExperimentalAnimationApi::class, + ExperimentalHazeMaterialsApi::class, +) +@Composable +fun ServerConfigScreen() { + val navController = LocalNavController.current + val scope = rememberCoroutineScope() + val scheme = MaterialTheme.colorScheme + val snackbarHostState = remember { SnackbarHostState() } + val density = LocalDensity.current + + var serverIp by remember { mutableStateOf("") } + var apiPortText by remember { mutableStateOf("") } + var callsPortText by remember { mutableStateOf("") } + var httpsEnabled by remember { mutableStateOf(true) } + + LaunchedEffect(Unit) { + Settings.serverConfig.apply { + serverIp = this.serverIp + apiPortText = this.apiPort.toString() + callsPortText = this.callsPort.toString() + httpsEnabled = this.httpsEnabled + } + } + + var busy by remember { mutableStateOf(false) } + var lastProbe by remember { mutableStateOf(null) } + var lastProbedConfig by remember { mutableStateOf(null) } + var showResetDialog by remember { mutableStateOf(false) } + val actionHazeState = rememberHazeState() + var focusedItemIndex by remember { mutableStateOf(null) } + + + val strSnackbarDefaults = stringResource(Res.string.server_config_snackbar_defaults) + val strHostError = stringResource(Res.string.server_config_host_error) + val strPortError = stringResource(Res.string.server_config_port_error) + val strSnackbarApiFail = stringResource(Res.string.server_config_snackbar_api_fail) + val strSnackbarTimeout = stringResource(Res.string.server_config_snackbar_timeout) + val strUnsupportedInstance = stringResource(Res.string.server_config_unsupported_no_instance_id) + val strChecking = stringResource(Res.string.server_config_checking) + val strResetConfirmTitle = stringResource(Res.string.server_config_action_reset_confirm_title) + val strResetConfirmBody = stringResource(Res.string.server_config_action_reset_confirm_body) + + val hostOk = serverIp.isNotBlank() && isValidIpOrHostname(serverIp) + val apiPortError = apiPortText.isNotEmpty() && !isValidPortNumber(apiPortText) + val callsPortError = callsPortText.isNotEmpty() && !isValidPortNumber(callsPortText) + + val canApply = + hostOk && + !apiPortError && + !callsPortError && + !busy + + fun resetToDefaults() { + serverIp = "fromchat.ru" + apiPortText = "443" + callsPortText = DEFAULT_CALLS_PORT.toString() + httpsEnabled = true + + scope.launch { + snackbarHostState.showSnackbar(strSnackbarDefaults) + } + } + + fun buildTentativeConfig(): ServerConfigData? { + val host = serverIp.trim() + + if ( + host.isEmpty() || + !isValidIpOrHostname(host) || + (apiPortText.isNotEmpty() && !isValidPortNumber(apiPortText)) || + (callsPortText.isNotEmpty() && !isValidPortNumber(callsPortText)) + ) return null + + return ServerConfigData( + serverIp = host, + apiPort = resolvedApiPort(apiPortText), + callsPort = resolvedCallsPort(callsPortText), + httpsEnabled = httpsEnabled, + ) + } + + fun verifyServer() { + scope.launch { + launch { snackbarHostState.showSnackbar(strChecking) } + val tentative = buildTentativeConfig() + if (tentative == null) { + snackbarHostState.showSnackbar( + if (serverIp.isNotEmpty() && !isValidIpOrHostname(serverIp.trim())) { + strHostError + } else { + strPortError + }, + ) + return@launch + } + val probe = probeServer(tentative) + lastProbe = probe + lastProbedConfig = tentative + val msg = when (probe) { + is ServerProbeResult.Supported -> { + if (probe.callsOk) { + getString(Res.string.server_config_snackbar_ok_calls, probe.pingMs) + } else { + getString(Res.string.server_config_snackbar_ok_calls_skip, probe.pingMs) + } + } + ServerProbeResult.Unsupported -> strUnsupportedInstance + ServerProbeResult.Timeout -> strSnackbarTimeout + ServerProbeResult.Unreachable -> strSnackbarApiFail + } + snackbarHostState.showSnackbar(msg) + } + } + + val fieldColors = OutlinedTextFieldDefaults.colors( + focusedTextColor = scheme.onSurface, + unfocusedTextColor = scheme.onSurface, + disabledTextColor = scheme.onSurface.copy(alpha = 0.38f), + focusedLabelColor = scheme.primary, + unfocusedLabelColor = scheme.onSurfaceVariant, + disabledLabelColor = scheme.onSurfaceVariant.copy(alpha = 0.38f), + cursorColor = scheme.primary, + focusedBorderColor = scheme.primary, + unfocusedBorderColor = scheme.outline, + disabledBorderColor = scheme.onSurface.copy(alpha = 0.12f), + errorBorderColor = scheme.error, + errorLabelColor = scheme.error, + errorCursorColor = scheme.error, + errorSupportingTextColor = scheme.error, + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + ) + + Box(Modifier.fillMaxSize()) { + Scaffold( + modifier = Modifier.fillMaxSize(), + contentWindowInsets = WindowInsets.navigationBars, + containerColor = Color.Transparent, + contentColor = scheme.onSurface, + snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) }, + bottomBar = { + HazeActionButton( + hazeState = actionHazeState, + onClick = { + if (!canApply) return@HazeActionButton + + scope.launch { + try { + busy = true + + val tentative = buildTentativeConfig() ?: return@launch + val probe = if ( + lastProbedConfig == tentative && lastProbe != null + ) { + lastProbe!! + } else { + probeServer(tentative).also { + lastProbe = it + lastProbedConfig = tentative + } + } + + when (probe) { + ServerProbeResult.Unsupported -> { + snackbarHostState.showSnackbar( + strUnsupportedInstance + ) + } + + ServerProbeResult.Timeout -> { + snackbarHostState.showSnackbar(strSnackbarTimeout) + } + + ServerProbeResult.Unreachable -> { + snackbarHostState.showSnackbar(strSnackbarApiFail) + } + + is ServerProbeResult.Supported -> { + Settings.lastKnownServerInstanceId = + probe.instanceId + + applyServerAndNavigate( + probe = probe, + config = tentative, + bearer = ApiClient.token?.trim().orEmpty(), + onNavigateLogin = { + withContext(Dispatchers.Main) { + navController.navigateAndWipeBackStack("login") + } + }, + onNavigateChat = { + withContext(Dispatchers.Main) { + if (!navController.popBackStack()) { + navController.navigate("chat") { + popUpTo("login") { + inclusive = true + } + } + } + } + }, + onLogoutOldHost = { + withContext(Dispatchers.Main) { + WebSocketManager.disconnect() + runCatching { ApiClient.logout() } + ApiClient.clearMemorySession() + navController.navigateAndWipeBackStack("login") + } + } + ) + } + } + } finally { + busy = false + } + } + }, + enabled = canApply, + loading = busy + ) { + Text(stringResource(Res.string.save_continue)) + } + }, + topBar = { + TopAppBar( + title = {}, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.back) + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = Color.Transparent + ), + modifier = Modifier.hazeEffect(state = actionHazeState, style = HazeMaterials.thin()) { + progressive = HazeProgressive.verticalGradient( + startIntensity = 1f, + endIntensity = 0f, + ) + } + ) + } + ) { innerPadding -> + val floatingHeaderClearance = WindowInsets.statusBars.getTop(density).toDp(density) + 68.dp + val bottomInsetPadding = innerPadding.calculateBottomPadding() + val serverConfigListState = rememberLazyListState() + + LaunchedEffect(focusedItemIndex, bottomInsetPadding) { + delay(40L) + + serverConfigListState.scrollFocusedItemIntoView( + itemIndex = focusedItemIndex ?: return@LaunchedEffect, + viewportMarginPx = 12.dp.toPx(density), + ) + } + + Box(Modifier.fillMaxSize()) { + CompositionLocalProvider(LocalBringIntoViewSpec provides remember { + object : BringIntoViewSpec { + override fun calculateScrollDistance( + offset: Float, + size: Float, + containerSize: Float, + ): Float = 0f + } + }) { + LazyColumn( + state = serverConfigListState, + modifier = Modifier + .fillMaxSize() + .consumeWindowInsets(innerPadding) + .background(MaterialTheme.colorScheme.background) + .hazeSource(actionHazeState), + contentPadding = PaddingValues(bottom = bottomInsetPadding), + verticalArrangement = remember { + ServerConfigSpacedByLastAnchoredBottom(space = 4.dp) + }, + ) { + item { Spacer(Modifier.height(floatingHeaderClearance)) } + + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 4.dp), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = SettingsStepHorizontalPadding) + .clip(SettingsPasswordOutlineFieldShape) + .background(Color.Transparent), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ExpressiveIconFrame( + icon = Icons.Filled.Storage, + containerSize = 96.dp, + iconSize = 34.dp, + containerColor = scheme.primaryContainer, + contentColor = scheme.onPrimaryContainer, + materialPolygon = MaterialShapes.Cookie6Sided, + ) + + Spacer(Modifier.height(16.dp)) + + Text( + text = stringResource(Res.string.server_config_title), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = stringResource(Res.string.server_config_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = scheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(12.dp)) + } + } + } + } + + item { + OutlinedTextField( + value = serverIp, + onValueChange = { serverIp = filterHostInput(it) }, + label = { Text(stringResource(Res.string.server_ip_label)) }, + placeholder = { Text(stringResource(Res.string.server_ip_hint)) }, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { + if (it.isFocused) { + focusedItemIndex = 2 + } + } + .padding(horizontal = SettingsStepHorizontalPadding), + singleLine = true, + isError = !hostOk, + supportingText = if (!hostOk) {{ + Text(stringResource(Res.string.server_config_host_error)) + }} else null, + colors = fieldColors, + shape = SettingsPasswordOutlineFieldShape, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Next, + ), + leadingIcon = { + Icon(Icons.Filled.Dns, null) + } + ) + } + + item { Spacer(Modifier.height(8.dp)) } + + item { + Layout( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = SettingsStepHorizontalPadding), + content = { + OutlinedTextField( + value = apiPortText, + onValueChange = { apiPortText = it.filter { it.isDigit() }.take(6) }, + label = { + Text( + text = stringResource(Res.string.api_port_label), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + placeholder = { Text("8301") }, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { + if (it.isFocused) { + focusedItemIndex = 4 + } + }, + singleLine = true, + isError = apiPortError, + supportingText = if (apiPortError) { + { Text(stringResource(Res.string.server_config_port_error)) } + } else null, + colors = fieldColors, + shape = SettingsPasswordOutlineFieldShape, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.NumberPassword, + imeAction = ImeAction.Next, + ), + leadingIcon = { + Icon(Icons.Filled.Http, null) + }, + ) + + OutlinedTextField( + value = callsPortText, + onValueChange = { callsPortText = it.filter { it.isDigit() }.take(6) }, + label = { + Text( + text = stringResource(Res.string.calls_port_label), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + placeholder = { Text(DEFAULT_CALLS_PORT.toString()) }, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { + if (it.isFocused) { + focusedItemIndex = 4 + } + }, + singleLine = true, + isError = callsPortError, + supportingText = if (callsPortError) {{ + Text(stringResource(Res.string.server_config_port_error)) + }} else null, + colors = fieldColors, + shape = SettingsPasswordOutlineFieldShape, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + ), + leadingIcon = { + Icon(Icons.Filled.Call, null) + } + ) + } + ) { measurables, constraints -> + val maxWidth = constraints.maxWidth + val gapPx = 12.dp.roundToPx() + val available = (maxWidth - gapPx).coerceAtLeast(0) + + val callsPx = (available * 0.60f) + .roundToInt() + .coerceAtLeast(168.dp.roundToPx()) + .coerceAtMost(available) + val apiPx = (available - callsPx).coerceAtLeast(0) + + if (measurables.size != 2) { + return@Layout layout(0, 0) {} + } + + val apiWidth = measurables[0].measure(Constraints.fixedWidth(apiPx)) + val callsWidth = measurables[1].measure(Constraints.fixedWidth(callsPx)) + + layout( + maxWidth, + maxOf(apiWidth.height, callsWidth.height) + .coerceIn(constraints.minHeight, constraints.maxHeight) + ) { + apiWidth.place(0, 0) + callsWidth.place(apiPx + gapPx, 0) + } + } + } + + item { Spacer(Modifier.height(16.dp)) } + + item { + Column { + Category( + modifier = Modifier.fillMaxWidth(), + margin = PaddingValues( + start = SettingsStepHorizontalPadding, + end = SettingsStepHorizontalPadding, + ), + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + SwitchListItem( + modifier = Modifier.fillMaxWidth(), + headline = stringResource(Res.string.server_config_https_headline), + supportingText = stringResource(Res.string.server_config_https_local_hint), + leadingContent = { + Icon(Icons.Filled.Shield, null) + }, + checked = httpsEnabled, + onCheckedChange = { httpsEnabled = it }, + divider = false, + ) + } + } + } + + item { + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color.Transparent) + .padding(horizontal = SettingsStepHorizontalPadding) + .padding(top = 8.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + val contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp) + + @Composable + fun ActionButtonContent( + imageVector: ImageVector, + text: String, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = imageVector, + contentDescription = null, + modifier = Modifier + .size(16.dp) + .clip(CtaShape), + tint = MaterialTheme.colorScheme.primary + ) + + Text(text) + } + } + + TextButton( + onClick = ::verifyServer, + modifier = Modifier.weight(1f), + shape = CtaShape, + colors = ButtonDefaults.textButtonColors( + containerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.primary + ), + contentPadding = contentPadding + ) { + ActionButtonContent( + imageVector = Icons.Filled.FlashOn, + text = stringResource(Res.string.server_config_action_check), + ) + } + + TextButton( + onClick = { showResetDialog = true }, + modifier = Modifier.weight(1f), + shape = CtaShape, + colors = ButtonDefaults.textButtonColors( + containerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.primary + ), + contentPadding = contentPadding + ) { + ActionButtonContent( + imageVector = Icons.Filled.RestartAlt, + text = stringResource(Res.string.server_config_action_reset) + ) + } + } + } + } + } + + if (showResetDialog) { + AlertDialog( + onDismissRequest = { showResetDialog = false }, + title = { Text(strResetConfirmTitle) }, + text = { Text(strResetConfirmBody) }, + confirmButton = { + TextButton( + onClick = { + showResetDialog = false + resetToDefaults() + } + ) { + Text(stringResource(Res.string.confirm)) + } + }, + dismissButton = { + TextButton(onClick = { showResetDialog = false }) { + Text(stringResource(Res.string.cancel)) + } + } + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt index b3b9b4b..5c572a6 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt @@ -1,9 +1,15 @@ package ru.fromchat.ui.profile -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -33,29 +39,25 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.lifecycle.repeatOnLifecycle import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot @@ -63,37 +65,48 @@ import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle import com.pr0gramm3r101.components.Category -import com.pr0gramm3r101.components.CategoryDefaults import com.pr0gramm3r101.components.ListItem +import io.ktor.client.plugins.ClientRequestException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import io.ktor.client.plugins.ClientRequestException import org.jetbrains.compose.resources.stringResource +import ru.fromchat.Logger import ru.fromchat.Res -import ru.fromchat.* -import ru.fromchat.core.Logger +import ru.fromchat.action_chat +import ru.fromchat.action_copy_link +import ru.fromchat.action_open_settings import ru.fromchat.api.ApiClient -import ru.fromchat.api.ProfileCache -import ru.fromchat.api.UserProfile -import ru.fromchat.api.visibleDisplayName -import ru.fromchat.api.visibleUsername -import ru.fromchat.api.UserStatus -import ru.fromchat.api.UserStatusStore +import ru.fromchat.api.local.db.store.ProfileCache +import ru.fromchat.api.local.db.store.UserStatus +import ru.fromchat.api.local.db.store.UserStatusStore +import ru.fromchat.api.local.db.store.visibleDisplayName +import ru.fromchat.api.local.db.store.visibleUsername +import ru.fromchat.api.schema.user.profile.UserProfile +import ru.fromchat.back +import ru.fromchat.presence_online +import ru.fromchat.presence_recently +import ru.fromchat.profile_headline_bio +import ru.fromchat.profile_headline_member_since +import ru.fromchat.profile_headline_username +import ru.fromchat.profile_headline_verification +import ru.fromchat.profile_load_failed +import ru.fromchat.profile_not_found +import ru.fromchat.profile_title +import ru.fromchat.profile_verified_support +import ru.fromchat.profile_verify_prompt_support import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.chat.Avatar import ru.fromchat.ui.chat.TypingIndicator -import ru.fromchat.ui.chat.publicChatProfileSharedAvatarKey -import ru.fromchat.ui.scaleOnPress +import ru.fromchat.ui.chat.panels.publicchat.publicChatProfileSharedAvatarKey +import ru.fromchat.ui.components.Text import ru.fromchat.utils.formatLastSeen import ru.fromchat.utils.rememberLastSeenFormatStrings -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.togetherWith +import com.pr0gramm3r101.utils.scaleOnPress private sealed interface ProfileLoadError { data object Generic : ProfileLoadError @@ -106,7 +119,7 @@ private data class ProfileUiState( val error: ProfileLoadError? = null ) -private val profileActionCardPressSpring = spring( +private val profileActionCardPressSpring = spring( dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessLow, visibilityThreshold = 0.001f @@ -125,7 +138,6 @@ fun ProfileScreen( sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, sharedAvatarKey: Any? = null, - /** When true (Nav from public chat), [sharedSourceMessageId] pairs with [targetUserId] for the avatar key. */ useSharedElementFromNavigation: Boolean = false, sharedSourceMessageId: Int = -1, initialDisplayName: String? = null, @@ -134,33 +146,28 @@ fun ProfileScreen( ) { val clipboardManager: ClipboardManager = LocalClipboardManager.current val navController = LocalNavController.current - var linkCopied by remember { mutableStateOf(false) } - val profileTitle = stringResource(Res.string.profile_title) - val cdBack = stringResource(Res.string.back) + val lifecycleOwner = LocalLifecycleOwner.current + val scope = rememberCoroutineScope() + val profileLoadFailed = stringResource(Res.string.profile_load_failed) val profileNotFound = stringResource(Res.string.profile_not_found) - val labelSettings = stringResource(Res.string.action_open_settings) - val labelChat = stringResource(Res.string.action_chat) - val labelLink = stringResource(Res.string.action_copy_link) - val linkCopiedText = stringResource(Res.string.link_copied) - val detailsTitle = stringResource(Res.string.profile_details_category) - val headlineUsername = stringResource(Res.string.profile_headline_username) - val headlineMemberSince = stringResource(Res.string.profile_headline_member_since) - val headlineBio = stringResource(Res.string.profile_headline_bio) - val headlineVerification = stringResource(Res.string.profile_headline_verification) - val verifiedSupport = stringResource(Res.string.profile_verified_support) - val verifyPromptSupport = stringResource(Res.string.profile_verify_prompt_support) - val hideBackButton = navController.currentDestination?.route == "chat" + val targetUserId = userId.takeIf { it != null && it > 0 } val targetUsername = username?.trim()?.takeIf { it.isNotBlank() } val ownUserId = ApiClient.user?.id?.takeIf { it > 0 } + val cacheLookupId = when { targetUserId != null -> targetUserId targetUsername != null -> null else -> ownUserId } - val isTargetProfileLookup = targetUserId != null || targetUsername != null - val lookupMode = if (targetUserId != null) "id" else if (targetUsername != null) "username" else "own" + + val lookupMode = when { + targetUserId != null -> "id" + targetUsername != null -> "username" + else -> "own" + } + val lookupIdentifier = when { targetUserId != null -> targetUserId.toString() !targetUsername.isNullOrBlank() -> targetUsername @@ -168,22 +175,18 @@ fun ProfileScreen( } var state by remember(targetUserId, targetUsername, ownUserId) { - val cached = cacheLookupId?.let { ProfileCache.get(it) } - Logger.d( - "ProfileScreen", - "state init: cacheLookupId=$cacheLookupId cachedId=${cached?.id} cachedUsername=${cached?.username} cachedDisplay=${cached?.displayName}" - ) mutableStateOf( - ProfileUiState( - profile = cached, - isLoading = cached == null, - error = null - ) + cacheLookupId?.let { ProfileCache.get(it) }.let { + ProfileUiState( + profile = it, + isLoading = it == null, + error = null + ) + } ) } val latestUi by rememberUpdatedState(state) - val lifecycleOwner = LocalLifecycleOwner.current LaunchedEffect(cacheLookupId, targetUserId, targetUsername, lifecycleOwner) { Logger.d( @@ -191,44 +194,63 @@ fun ProfileScreen( "load start: mode=$lookupMode identifier=$lookupIdentifier cacheLookupId=$cacheLookupId ownUserId=$ownUserId " + "lifecycleStarted=${lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)}" ) + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - runCatching { - if (targetUserId == null && targetUsername == null) { - ApiClient.getOwnProfile() - } else if (targetUsername != null) { - ApiClient.getProfileByUsername(targetUsername) - } else { - ApiClient.getProfileById(targetUserId!!) + try { + val profile = when { + targetUserId == null && targetUsername == null -> ApiClient.getOwnProfile() + targetUsername != null -> ApiClient.getProfileByUsername(targetUsername) + else -> ApiClient.getProfileById(targetUserId!!) } - }.onSuccess { profile -> + if (profile.username.isBlank() && profile.displayName.isNullOrBlank()) { cacheLookupId?.let { ProfileCache.evictUnusableClientPreview(it) } - Logger.d("ProfileScreen", "load success but blank identity for id=${profile.id}, dropping as unusable preview") + + Logger.d( + "ProfileScreen", + "load success but blank identity for id=${profile.id}, dropping " + + "as unusable preview" + ) + state = latestUi.copy( profile = null, isLoading = false, error = ProfileLoadError.Generic ) - return@onSuccess + + return@repeatOnLifecycle } + Logger.d( "ProfileScreen", - "load success: mode=$lookupMode identifier=$lookupIdentifier -> id=${profile.id}, username='${profile.username}', display='${profile.displayName}', deleted=${profile.deleted}, suspended=${profile.suspended}" + "load success: mode=$lookupMode identifier=$lookupIdentifier -> " + + "id=${profile.id}, username='${profile.username}', " + + "display='${profile.displayName}', deleted=${profile.deleted}, " + + "suspended=${profile.suspended}" ) + ProfileCache.put(profile) state = latestUi.copy(profile = profile, isLoading = false, error = null) - }.onFailure { err -> - val fallbackId = if (targetUsername != null) null else targetUserId ?: ownUserId - fallbackId?.let { ProfileCache.evictUnusableClientPreview(it) } + } catch (err: Exception) { + val fallbackId = ( + if (targetUsername != null) null else targetUserId ?: ownUserId + )?.also { + ProfileCache.evictUnusableClientPreview(it) + } + val fallback = fallbackId?.let { ProfileCache.get(it) } + Logger.d( "ProfileScreen", - "load failure fallback lookup: fallbackId=$fallbackId fallbackFound=${fallback != null}" + "load failure fallback lookup: fallbackId=$fallbackId " + + "fallbackFound=${fallback != null}" ) + val resolvedErrorMessage = when { err is ClientRequestException && err.response.status.value == 404 -> profileNotFound else -> err.message?.takeIf { it.isNotBlank() } ?: profileLoadFailed } + if (err is ClientRequestException) { Logger.d( "ProfileScreen", @@ -242,14 +264,16 @@ fun ProfileScreen( "errorType=${err::class.simpleName} message=${err.message}" ) } + if ( showErrorAsToast && - isTargetProfileLookup && + (targetUserId != null || targetUsername != null) && latestUi.profile == null && fallback == null ) { showProfileLoadErrorMessage(resolvedErrorMessage) } + state = latestUi.copy( error = if (latestUi.profile == null && fallback == null) { if (resolvedErrorMessage == profileLoadFailed) { @@ -273,18 +297,13 @@ fun ProfileScreen( modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { MediumTopAppBar( - title = { Text(profileTitle) }, + title = { Text(stringResource(Res.string.profile_title)) }, navigationIcon = { - if (!hideBackButton) { - Box( - modifier = Modifier - .scaleOnPress(0.96f, onClick = onBack) - .padding(12.dp), - contentAlignment = Alignment.Center - ) { + if (navController.currentDestination?.route != "chat") { + IconButton(onClick = onBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = cdBack, + contentDescription = stringResource(Res.string.back), modifier = Modifier.size(24.dp) ) } @@ -298,6 +317,7 @@ fun ProfileScreen( modifier = Modifier.fillMaxSize() ) { val loadError = state.error + val profile = state.profile val currentProfileUserId = targetUserId ?: ownUserId ?: profile?.id val displayName = @@ -312,8 +332,8 @@ fun ProfileScreen( } else { null } - val effectiveSharedAvatarKey: Any? = sharedAvatarKey ?: navSharedAvatarKey + val effectiveSharedAvatarKey: Any? = sharedAvatarKey ?: navSharedAvatarKey val useSharedAvatar = sharedTransitionScope != null && animatedVisibilityScope != null && effectiveSharedAvatarKey != null @@ -327,24 +347,25 @@ fun ProfileScreen( ) { when { useSharedAvatar -> { - val sharedKey = checkNotNull(effectiveSharedAvatarKey) - val stScope = checkNotNull(sharedTransitionScope) - val visScope = checkNotNull(animatedVisibilityScope) - with(stScope) { + with(sharedTransitionScope) { Avatar( profilePictureUrl = profile?.profilePicture, displayName = displayName, modifier = Modifier .padding(top = 16.dp) .sharedElement( - rememberSharedContentState(key = sharedKey), - animatedVisibilityScope = visScope + rememberSharedContentState( + key = effectiveSharedAvatarKey + ), + animatedVisibilityScope = animatedVisibilityScope ) .size(128.dp) ) } + Spacer(modifier = Modifier.height(12.dp)) } + !hideAvatar -> { Avatar( profilePictureUrl = profile?.profilePicture, @@ -353,28 +374,30 @@ fun ProfileScreen( .padding(top = 16.dp) .size(128.dp) ) + Spacer(modifier = Modifier.height(12.dp)) } + onAvatarSlotBounds != null -> { Box( modifier = Modifier .padding(top = 16.dp) .size(128.dp) .onGloballyPositioned { coords -> - val pos = coords.positionInRoot() - val sz = coords.size onAvatarSlotBounds( Rect( - pos.x, - pos.y, - pos.x + sz.width.toFloat(), - pos.y + sz.height.toFloat() + coords.positionInRoot().x, + coords.positionInRoot().y, + coords.positionInRoot().x + coords.size.width.toFloat(), + coords.positionInRoot().y + coords.size.height.toFloat() ) ) } ) + Spacer(modifier = Modifier.height(12.dp)) } + else -> { Spacer(modifier = Modifier.height(16.dp + 128.dp + 12.dp)) } @@ -384,19 +407,19 @@ fun ProfileScreen( state.isLoading -> { CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp)) } + loadError != null -> { - val errText = when (loadError) { - ProfileLoadError.Generic -> profileLoadFailed - is ProfileLoadError.Message -> loadError.text - } Text( - text = errText, + text = when (loadError) { + ProfileLoadError.Generic -> profileLoadFailed + is ProfileLoadError.Message -> loadError.text + }, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(top = 24.dp) ) } + profile != null -> { - /** Public chat message → profile: minimal identity (no @ handle, username row, member since). */ val compactIdentityForPublicChat = useSharedElementFromNavigation && sharedSourceMessageId > 0 val profileLink = @@ -407,30 +430,25 @@ fun ProfileScreen( ?.let { "https://fromchat.ru/@$it" } ?: "https://fromchat.ru/?u=${profile.id}" } - val scope = rememberCoroutineScope() - val verificationLabel = - if (profile.verified == true) verifiedSupport else verifyPromptSupport val isOwnProfile = ApiClient.user?.id == profile.id - val statusMap by UserStatusStore.status.collectAsState() - val lastSeenFormat = rememberLastSeenFormatStrings() - val statusState = statusMap[profile.id] ?: UserStatus( - online = profile.online, - lastSeen = profile.lastSeen - ) + val statusState = UserStatusStore + .status + .collectAsState() + .value[profile.id] ?: UserStatus( + online = profile.online, + lastSeen = profile.lastSeen + ) + val typingUsers = statusState.typingUsernames val statusText = if (statusState.online) { stringResource(Res.string.presence_online) } else { - formatLastSeen(false, statusState.lastSeen, lastSeenFormat) - .ifEmpty { stringResource(Res.string.presence_recently) } - } - val statusStateKey = if (typingUsers.isNotEmpty()) { - "typing:${typingUsers.joinToString("|")}" - } else if (statusState.online) { - "online" - } else { - "offline" + formatLastSeen( + false, + statusState.lastSeen, + rememberLastSeenFormatStrings() + ).ifEmpty { stringResource(Res.string.presence_recently) } } Row( @@ -441,36 +459,42 @@ fun ProfileScreen( text = displayName, style = MaterialTheme.typography.titleLarge ) + StatusBadge( verified = profile.verified, userId = profile.id ) } + Spacer(modifier = Modifier.height(4.dp)) + AnimatedContent( - targetState = statusStateKey, + targetState = when { + typingUsers.isNotEmpty() -> "typing:${typingUsers.joinToString("|")}" + statusState.online ->"online" + else -> "offline" + }, transitionSpec = { (slideInVertically { it / 2 } + fadeIn()) togetherWith (slideOutVertically { -it / 2 } + fadeOut()) }, label = "profile_status_${profile.id}" ) { state -> - when { - state.startsWith("typing:") -> TypingIndicator( + if (state.startsWith("typing:")) { + TypingIndicator( typingUsers = typingUsers ) - state == "online" -> Text( + } else { + Text( text = statusText, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary - ) - else -> Text( - text = statusText, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = if (state == "online") + MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant ) } } + Spacer(modifier = Modifier.height(36.dp)) Row( @@ -479,108 +503,74 @@ fun ProfileScreen( .padding(start = 16.dp, end = 16.dp, bottom = 20.dp), horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - val primarySource = remember { MutableInteractionSource() } - val primaryClick: () -> Unit - val primaryIcon = if (isOwnProfile) Icons.Filled.Settings else Icons.AutoMirrored.Filled.Chat - val primaryLabel = if (isOwnProfile) labelSettings else labelChat + @Composable + fun Item(label: String, icon: ImageVector, onClick: () -> Unit) { + val interactionSource = remember { MutableInteractionSource() } - primaryClick = if (isOwnProfile) { - onOpenSettings + Card( + modifier = Modifier + .weight(1f) + .scaleOnPress( + scale = 0.90f, + interactionSource = interactionSource, + clipShape = MaterialTheme.shapes.extraLarge, + animationSpec = profileActionCardPressSpring + ), + shape = MaterialTheme.shapes.extraLarge, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .clickable( + interactionSource = interactionSource, + indication = LocalIndication.current, + onClick = onClick + ), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(vertical = 16.dp) + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(28.dp) + ) + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + text = label, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } + + if (isOwnProfile) { + Item( + stringResource(Res.string.action_open_settings), + Icons.Filled.Settings, + onOpenSettings + ) } else { - { onChat(profile.id) } - } - - Card( - modifier = Modifier - .weight(1f) - .scaleOnPress( - scale = 0.90f, - interactionSource = primarySource, - clipShape = MaterialTheme.shapes.extraLarge, - animationSpec = profileActionCardPressSpring - ), - shape = MaterialTheme.shapes.extraLarge, - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer) - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .clickable( - interactionSource = primarySource, - indication = LocalIndication.current, - onClick = primaryClick - ), - contentAlignment = Alignment.Center + Item( + stringResource(Res.string.action_chat), + Icons.AutoMirrored.Filled.Chat ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(vertical = 16.dp) - ) { - Icon( - imageVector = primaryIcon, - contentDescription = null, - modifier = Modifier.size(28.dp) - ) - Spacer(modifier = Modifier.height(6.dp)) - Text( - text = primaryLabel, - style = MaterialTheme.typography.bodyMedium - ) - } + onChat(profile.id) } } - val linkSource = remember { MutableInteractionSource() } - Card( - modifier = Modifier - .weight(1f) - .scaleOnPress( - scale = 0.90f, - interactionSource = linkSource, - clipShape = MaterialTheme.shapes.extraLarge, - animationSpec = profileActionCardPressSpring - ), - shape = MaterialTheme.shapes.extraLarge, - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer) - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .clickable( - interactionSource = linkSource, - indication = LocalIndication.current, - onClick = { - clipboardManager.setText(AnnotatedString(profileLink)) - linkCopied = true - } - ), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(vertical = 16.dp) - ) { - Icon( - imageVector = Icons.Filled.Link, - contentDescription = null, - modifier = Modifier.size(28.dp) - ) - Spacer(modifier = Modifier.height(6.dp)) - Text( - text = labelLink, - style = MaterialTheme.typography.bodyMedium - ) - } - } - } - } - if (linkCopied) { - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = linkCopiedText, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary - ) + Item( + stringResource(Res.string.action_copy_link), + Icons.Filled.Link + ) { + clipboardManager.setText(AnnotatedString(profileLink)) + } } val showDetailsUsername = @@ -590,86 +580,63 @@ fun ProfileScreen( !profile.createdAt.isNullOrBlank() val showDetailsBio = !profile.bio.isNullOrBlank() val showDetailsVerify = profile.verified == true || ApiClient.user?.id == 1 + if ( showDetailsUsername || - showDetailsMemberSince || - showDetailsBio || - showDetailsVerify + showDetailsMemberSince || + showDetailsBio || + showDetailsVerify ) { - Category(Modifier.padding(top = 16.dp), title = detailsTitle) { + Category(Modifier.padding(top = 16.dp)) { if (showDetailsUsername) { ListItem( - headline = headlineUsername, - supportingText = usernameForLinks.orEmpty(), + headline = stringResource(Res.string.profile_headline_username), + supportingText = usernameForLinks, divider = true, - dividerColor = CategoryDefaults.dividerColor, - dividerThickness = CategoryDefaults.dividerThickness, leadingContent = { - CompositionLocalProvider( - LocalContentColor provides MaterialTheme.colorScheme.onSurface - ) { - Icon( - imageVector = Icons.Filled.AlternateEmail, - contentDescription = null - ) - } + Icon( + imageVector = Icons.Filled.AlternateEmail, + contentDescription = null + ) } ) } + if (showDetailsMemberSince) { ListItem( - headline = headlineMemberSince, + headline = stringResource(Res.string.profile_headline_member_since), supportingText = profile.createdAt, divider = true, - dividerColor = CategoryDefaults.dividerColor, - dividerThickness = CategoryDefaults.dividerThickness, leadingContent = { - CompositionLocalProvider( - LocalContentColor provides MaterialTheme.colorScheme.onSurface - ) { - Icon( - imageVector = Icons.Filled.CalendarMonth, - contentDescription = null - ) - } + Icon( + imageVector = Icons.Filled.CalendarMonth, + contentDescription = null + ) } ) } + if (showDetailsBio) { ListItem( - headline = headlineBio, + headline = stringResource(Res.string.profile_headline_bio), supportingText = profile.bio, divider = true, - dividerColor = CategoryDefaults.dividerColor, - dividerThickness = CategoryDefaults.dividerThickness, leadingContent = { - CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { - Icon( - imageVector = Icons.Filled.Info, - contentDescription = null - ) - } + Icon( + imageVector = Icons.Filled.Info, + contentDescription = null + ) } ) } if (showDetailsVerify) { - val onVerifyToggle: () -> Unit = { - scope.launch { - val result = withContext(Dispatchers.Default) { - runCatching { ApiClient.verifyUser(profile.id) }.getOrNull() - } - result?.verified?.let { newVerified -> - val updated = state.profile?.copy(verified = newVerified) - state = state.copy(profile = updated) - updated?.let { ProfileCache.put(it) } - } - } - } - ListItem( - headline = headlineVerification, - supportingText = verificationLabel, + headline = stringResource(Res.string.profile_headline_verification), + supportingText = + if (profile.verified == true) + stringResource(Res.string.profile_verified_support) + else stringResource(Res.string.profile_verify_prompt_support), leadingContent = { Icon( imageVector = Icons.Filled.Verified, @@ -677,9 +644,21 @@ fun ProfileScreen( ) }, divider = true, - dividerColor = CategoryDefaults.dividerColor, - dividerThickness = CategoryDefaults.dividerThickness, - onClick = if (ApiClient.user?.id == 1) onVerifyToggle else null + onClick = if (ApiClient.user?.id == 1) { + { + scope.launch { + val result = withContext(Dispatchers.Default) { + runCatching { ApiClient.verifyUser(profile.id) }.getOrNull() + } + result?.verified?.let { newVerified -> + val updated = + state.profile?.copy(verified = newVerified) + state = state.copy(profile = updated) + updated?.let { ProfileCache.put(it) } + } + } + } + } else null ) } } @@ -690,3 +669,5 @@ fun ProfileScreen( } } } + +expect fun showProfileLoadErrorMessage(message: String) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/StatusBadge.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/StatusBadge.kt index 0edc928..aaf25b9 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/StatusBadge.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/StatusBadge.kt @@ -14,16 +14,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res -import ru.fromchat.* import ru.fromchat.api.ApiClient +import ru.fromchat.cd_similar_verified +import ru.fromchat.cd_verified_account @Composable fun StatusBadge( @@ -32,42 +31,35 @@ fun StatusBadge( modifier: Modifier = Modifier, size: Dp = 20.dp ) { - var isSimilarToVerified by remember(userId, verified) { mutableStateOf(false) } - val cdVerified = stringResource(Res.string.cd_verified_account) - val cdSimilar = stringResource(Res.string.cd_similar_verified) + var isSimilarToVerified by remember(userId, verified) { + mutableStateOf(false) + } LaunchedEffect(verified, userId, ApiClient.token) { - if (verified == true) { - isSimilarToVerified = false - return@LaunchedEffect - } - if (userId == null || ApiClient.token == null) { - isSimilarToVerified = false - return@LaunchedEffect - } - val result = withContext(Dispatchers.Default) { - runCatching { ApiClient.checkSimilarity(userId) }.getOrNull() - } - isSimilarToVerified = result?.isSimilar == true + isSimilarToVerified = !( + verified == true || + userId == null || + ApiClient.token == null + ) && withContext(Dispatchers.Default) { + runCatching { + ApiClient.checkSimilarity(userId) + }.getOrNull() + }?.isSimilar == true } when { verified == true -> Icon( imageVector = Icons.Filled.Verified, - contentDescription = cdVerified, - modifier = modifier - .size(size) - .semantics { contentDescription = cdVerified }, + contentDescription = stringResource(Res.string.cd_verified_account), + modifier = modifier.size(size), tint = MaterialTheme.colorScheme.primary ) + isSimilarToVerified -> Icon( imageVector = Icons.Filled.Warning, - contentDescription = cdSimilar, - modifier = modifier - .size(size) - .semantics { contentDescription = cdSimilar }, + contentDescription = stringResource(Res.string.cd_similar_verified), + modifier = modifier.size(size), tint = Color(0xFFFFA000) ) - else -> {} } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/VerifyButton.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/VerifyButton.kt index 683e3cd..178701c 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/VerifyButton.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/VerifyButton.kt @@ -3,7 +3,7 @@ package ru.fromchat.ui.profile import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import ru.fromchat.ui.components.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/setup/ServerConfigHostValidation.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/setup/ServerConfigHostValidation.kt deleted file mode 100644 index 66efd10..0000000 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/setup/ServerConfigHostValidation.kt +++ /dev/null @@ -1,62 +0,0 @@ -package ru.fromchat.ui.setup - -private fun isAllowedHostChar(ch: Char): Boolean = - ch.isLetterOrDigit() || ch in ".:-[]_" - -internal fun filterHostInput(raw: String): String = - raw.filter(::isAllowedHostChar).take(253) - -internal fun isValidPortNumber(text: String): Boolean { - if (text.isBlank()) return true - val n = text.toIntOrNull() ?: return false - return n in 1..65535 -} - -internal fun isValidIpOrHostname(host: String): Boolean { - val h = host.trim() - if (h.isEmpty() || h.length > 253) return false - if (h.any { !isAllowedHostChar(it) }) return false - return isValidIpv4(h) || isValidIpv6Bracketed(h) || isValidHostname(h) -} - -private fun isValidIpv4(s: String): Boolean { - val p = s.split('.') - if (p.size != 4) return false - for (x in p) { - val n = x.toIntOrNull() ?: return false - if (n !in 0..255) return false - } - return true -} - -private fun isValidIpv6Bracketed(s: String): Boolean { - if (!s.startsWith('[') || !s.contains(']')) return false - val end = s.indexOf(']') - if (end <= 1) return false - val inner = s.substring(1, end) - if (inner.isBlank()) return false - if (!inner.all { it.isDigit() || it in "abcdefABCDEF:" }) return false - val colons = inner.count { it == ':' } - return colons >= 2 -} - -private fun isValidHostname(host: String): Boolean { - if (host.contains(':')) return false - if (host.startsWith('[')) return false - if (host.endsWith('.')) return false - val labels = host.split('.') - if (labels.isEmpty() || labels.size > 127) return false - for (label in labels) { - if (label.isEmpty() || label.length > 63) return false - if (label.startsWith('-') || label.endsWith('-')) return false - if (!label.all { it.isLetterOrDigit() || it == '-' || it == '_' }) return false - } - return true -} - -/** Host part for URL authority (brackets IPv6). */ -internal fun hostForAuthority(host: String): String { - val t = host.trim() - if (t.contains(':') && !t.startsWith("[")) return "[$t]" - return t -} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/setup/ServerConfigScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/setup/ServerConfigScreen.kt deleted file mode 100644 index be13434..0000000 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/setup/ServerConfigScreen.kt +++ /dev/null @@ -1,1016 +0,0 @@ -package ru.fromchat.ui.setup - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.animateScrollBy -import androidx.compose.foundation.gestures.BringIntoViewSpec -import androidx.compose.foundation.gestures.LocalBringIntoViewSpec -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.ime -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Call -import androidx.compose.material.icons.filled.Dns -import androidx.compose.material.icons.filled.Http -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.FlashOn -import androidx.compose.material.icons.filled.RestartAlt -import androidx.compose.material.icons.filled.Shield -import androidx.compose.material.icons.filled.Storage -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialShapes -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.layout.Layout -import androidx.compose.ui.zIndex -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Constraints -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import com.pr0gramm3r101.components.Category -import com.pr0gramm3r101.components.SwitchListItem -import com.pr0gramm3r101.utils.navigateAndWipeBackStack -import dev.chrisbanes.haze.HazeProgressive -import dev.chrisbanes.haze.HazeState -import dev.chrisbanes.haze.hazeEffect -import dev.chrisbanes.haze.hazeSource -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import dev.chrisbanes.haze.materials.HazeMaterials -import dev.chrisbanes.haze.rememberHazeState -import kotlin.time.TimeSource -import kotlinx.coroutines.delay -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withTimeout -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.jetbrains.compose.resources.getString -import org.jetbrains.compose.resources.stringResource -import ru.fromchat.Res -import ru.fromchat.api.ApiClient -import ru.fromchat.api.WebSocketManager -import ru.fromchat.api_port_label -import ru.fromchat.back -import ru.fromchat.calls_port_label -import ru.fromchat.cancel -import ru.fromchat.confirm -import ru.fromchat.core.DEFAULT_CALLS_PORT -import ru.fromchat.core.ServerConfigData -import ru.fromchat.core.Settings -import ru.fromchat.core.config.Config -import ru.fromchat.save_continue -import ru.fromchat.server_config_action_check -import ru.fromchat.server_config_action_reset -import ru.fromchat.server_config_action_reset_confirm_body -import ru.fromchat.server_config_action_reset_confirm_title -import ru.fromchat.server_config_host_error -import ru.fromchat.server_config_https_headline -import ru.fromchat.server_config_https_local_hint -import ru.fromchat.server_config_port_error -import ru.fromchat.server_config_snackbar_api_fail -import ru.fromchat.server_config_snackbar_defaults -import ru.fromchat.server_config_snackbar_ok_api_calls_bad -import ru.fromchat.server_config_snackbar_ok_calls -import ru.fromchat.server_config_snackbar_ok_calls_skip -import ru.fromchat.server_config_snackbar_timeout -import ru.fromchat.server_config_unsupported_no_instance_id -import ru.fromchat.server_config_checking -import ru.fromchat.server_config_subtitle -import ru.fromchat.core.instance.ServerProbeResult -import ru.fromchat.core.instance.applyServerAndNavigate -import ru.fromchat.core.instance.probeServer -import ru.fromchat.server_config_title -import ru.fromchat.server_ip_hint -import ru.fromchat.server_ip_label -import ru.fromchat.ui.FromChatSnackbarHost -import ru.fromchat.ui.LocalNavController -import ru.fromchat.ui.main.settings.SettingsExpressiveIconFrame -import ru.fromchat.ui.main.settings.SettingsPasswordOutlineFieldShape -import ru.fromchat.ui.main.settings.SettingsSecurityCtaShape -import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding -import kotlin.math.roundToInt - -/** Tighter than chat’s +12.dp tail under the 64.dp toolbar row inside the frosted top bar. */ -private val ServerConfigTopBarBlurBottomInset = 4.dp -private val ServerConfigFocusedFieldViewportMargin = 12.dp - -private const val ServerConfigHostItemIndex = 2 -private const val ServerConfigPortsItemIndex = 4 - -/** Short wait for IME + Scaffold bottom inset to apply before measuring clip vs CTA. */ -private const val ServerConfigKeyboardScrollSettleMs = 40L - -private val ServerConfigLazyListItemSpacing = 4.dp - -/** - * Fixed [Dp] gaps between all children (resolved in [Density.arrange] like [Arrangement.spacedBy]), - * plus extra height inserted **only** before the last child so it sits at the bottom when content - * is shorter than the viewport. Uses a stable [remember] so [4.dp.roundToPx] changes from - * recomposition do not swap the arrangement instance and nudge spacing by a pixel. - */ -@Stable -private class ServerConfigSpacedByLastAnchoredBottom( - private val space: Dp, -) : Arrangement.Vertical { - override val spacing: Dp get() = space - - override fun Density.arrange( - totalSize: Int, - sizes: IntArray, - outPositions: IntArray, - ) { - val spacePx = space.roundToPx() - val n = sizes.size - if (n == 0) return - if (n == 1) { - outPositions[0] = (totalSize - sizes[0]).coerceAtLeast(0) - return - } - var sumHeights = 0 - for (i in 0 until n) { - sumHeights += sizes[i] - } - val gaps = spacePx * (n - 1) - val slack = (totalSize - sumHeights - gaps).coerceAtLeast(0) - var y = 0 - for (i in 0 until n - 1) { - outPositions[i] = y - y += sizes[i] + spacePx - } - outPositions[n - 1] = y + slack - } -} - -private fun apiBaseUrlFor(config: ServerConfigData): String { - val scheme = if (config.httpsEnabled) "https" else "http" - return "$scheme://${config.serverIp}:${config.apiPort}/api" -} - -private suspend fun probeCallsReachable(config: ServerConfigData): Boolean { - val urlScheme = if (config.httpsEnabled) "https" else "http" - val host = config.serverIp.trim() - val root = "$urlScheme://${hostForAuthority(host)}:${config.callsPort}/" - return ApiClient.probeHttpGet(root) -} - -private fun resolvedApiPort(apiPortText: String): Int { - val t = apiPortText.trim() - if (t.isEmpty()) return 443 - val n = t.toIntOrNull() ?: return 443 - return n.takeIf { it in 1..65535 } ?: 443 -} - -/** Blank calls field uses [DEFAULT_CALLS_PORT] instead of disabling calls. */ -private fun effectiveCallsPort(callsPortText: String): Int { - val t = callsPortText.trim() - if (t.isBlank()) return DEFAULT_CALLS_PORT - return t.toIntOrNull()?.takeIf { it in 1..65535 } ?: DEFAULT_CALLS_PORT -} - -private suspend fun LazyListState.scrollFocusedItemIntoView( - itemIndex: Int, - viewportMarginPx: Float, -) { - if (layoutInfo.visibleItemsInfo.none { it.index == itemIndex }) { - animateScrollToItem(itemIndex) - } - - val item = layoutInfo.visibleItemsInfo.firstOrNull { it.index == itemIndex } ?: return - val viewportStart = layoutInfo.viewportStartOffset + viewportMarginPx - // viewportEndOffset includes the afterContentPadding area. In this screen that padding comes - // from Scaffold's bottom bar/IME inset, so it is scrollable space, not unobscured viewport. - val viewportEnd = (layoutInfo.viewportEndOffset - layoutInfo.afterContentPadding - viewportMarginPx) - .coerceAtLeast(viewportStart) - val itemStart = item.offset.toFloat() - val itemEnd = itemStart + item.size - val scrollDelta = when { - itemStart < viewportStart -> itemStart - viewportStart - itemEnd > viewportEnd -> itemEnd - viewportEnd - else -> 0f - } - - if (scrollDelta != 0f) { - animateScrollBy(scrollDelta) - } -} - -@Composable -private fun ServerConfigActionButtons( - onReset: () -> Unit, - onVerify: () -> Unit, - modifier: Modifier = Modifier, -) { - Row( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 4.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - TextButton( - onClick = onVerify, - modifier = Modifier.weight(1f), - shape = SettingsSecurityCtaShape, - colors = ButtonDefaults.textButtonColors( - containerColor = Color.Transparent, - contentColor = MaterialTheme.colorScheme.primary, - disabledContainerColor = Color.Transparent, - disabledContentColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.38f), - ), - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp), - ) { - ServerConfigActionButtonContent( - imageVector = Icons.Filled.FlashOn, - text = stringResource(Res.string.server_config_action_check), - ) - } - TextButton( - onClick = onReset, - modifier = Modifier.weight(1f), - shape = SettingsSecurityCtaShape, - colors = ButtonDefaults.textButtonColors( - containerColor = Color.Transparent, - contentColor = MaterialTheme.colorScheme.primary, - disabledContainerColor = Color.Transparent, - disabledContentColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.38f), - ), - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp), - ) { - ServerConfigActionButtonContent( - imageVector = Icons.Filled.RestartAlt, - text = stringResource(Res.string.server_config_action_reset), - ) - } - } -} - -@Composable -private fun ServerConfigActionButtonContent( - imageVector: ImageVector, - text: String, -) { - Row( - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = imageVector, - contentDescription = null, - modifier = Modifier - .size(16.dp) - .clip(SettingsSecurityCtaShape), - tint = MaterialTheme.colorScheme.primary, - ) - Text(text) - } -} - -@Composable -private fun ServerConfigLeadingIcon( - imageVector: ImageVector, - tint: Color, -) { - Icon( - imageVector = imageVector, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = tint, - ) -} - -@Composable -private fun ServerConfigHttpsLeadingIcon(tint: Color) { - Box( - Modifier.size(40.dp), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Filled.Shield, - contentDescription = null, - modifier = Modifier.size(26.dp), - tint = tint, - ) - Icon( - imageVector = Icons.Filled.Lock, - contentDescription = null, - modifier = Modifier - .size(11.dp) - .offset(y = 4.dp), - tint = tint, - ) - } -} - -@OptIn(ExperimentalHazeMaterialsApi::class) -@Composable -private fun ServerConfigStickyTopBar( - hazeState: HazeState, - topBarBlurHeight: Dp, - onNavigateUp: () -> Unit, -) { - val scheme = MaterialTheme.colorScheme - val iconTint = scheme.onSurfaceVariant - Box( - modifier = Modifier - .fillMaxWidth() - .wrapContentHeight(), - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(topBarBlurHeight) - .align(Alignment.TopCenter) - .hazeEffect(state = hazeState, style = HazeMaterials.thin()) { - progressive = HazeProgressive.verticalGradient( - startIntensity = 1f, - endIntensity = 0f, - ) - }, - ) - Row( - modifier = Modifier - .fillMaxWidth() - .wrapContentHeight() - .height(IntrinsicSize.Min) - .zIndex(1f) - .align(Alignment.TopCenter) - .windowInsetsPadding(WindowInsets.statusBars) - .padding(start = 8.dp, end = 8.dp, top = 6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton(onClick = onNavigateUp) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(Res.string.back), - tint = iconTint, - ) - } - } - } -} - -@OptIn( - ExperimentalFoundationApi::class, - ExperimentalMaterial3Api::class, - ExperimentalMaterial3ExpressiveApi::class, - ExperimentalAnimationApi::class, - ExperimentalHazeMaterialsApi::class, -) -@Composable -fun ServerConfigScreen() { - val navController = LocalNavController.current - val scope = rememberCoroutineScope() - val scheme = MaterialTheme.colorScheme - val snackbarHostState = remember { SnackbarHostState() } - - var serverIp by remember { mutableStateOf("") } - var apiPortText by remember { mutableStateOf("") } - var callsPortText by remember { mutableStateOf("") } - var httpsEnabled by remember { mutableStateOf(true) } - - LaunchedEffect(Unit) { - val c = Settings.serverConfig - serverIp = c.serverIp - apiPortText = c.apiPort.toString() - callsPortText = c.callsPort.toString() - httpsEnabled = c.httpsEnabled - } - - var busy by remember { mutableStateOf(false) } - var lastProbe by remember { mutableStateOf(null) } - var lastProbedConfig by remember { mutableStateOf(null) } - var showResetDialog by remember { mutableStateOf(false) } - val actionHazeState = rememberHazeState() - val serverConfigListState = rememberLazyListState() - var focusedItemIndex by remember { mutableStateOf(null) } - - val strSnackbarDefaults = stringResource(Res.string.server_config_snackbar_defaults) - val strHostError = stringResource(Res.string.server_config_host_error) - val strPortError = stringResource(Res.string.server_config_port_error) - val strSnackbarApiFail = stringResource(Res.string.server_config_snackbar_api_fail) - val strSnackbarOkApiCallsBad = stringResource(Res.string.server_config_snackbar_ok_api_calls_bad) - val strSnackbarTimeout = stringResource(Res.string.server_config_snackbar_timeout) - val strUnsupportedInstance = stringResource(Res.string.server_config_unsupported_no_instance_id) - val strChecking = stringResource(Res.string.server_config_checking) - val strResetConfirmTitle = stringResource(Res.string.server_config_action_reset_confirm_title) - val strResetConfirmBody = stringResource(Res.string.server_config_action_reset_confirm_body) - - val portGap = 12.dp - - val hostOk = serverIp.isNotBlank() && isValidIpOrHostname(serverIp) - val hostError = serverIp.isNotEmpty() && !isValidIpOrHostname(serverIp) - val apiPortError = apiPortText.isNotEmpty() && !isValidPortNumber(apiPortText) - val callsPortError = callsPortText.isNotEmpty() && !isValidPortNumber(callsPortText) - - val apiPortEffective = resolvedApiPort(apiPortText) - val callsPortParsed = effectiveCallsPort(callsPortText) - val canApply = - hostOk && - !apiPortError && - !callsPortError && - !busy - - val resetToDefaults = { - serverIp = "fromchat.ru" - apiPortText = "443" - callsPortText = DEFAULT_CALLS_PORT.toString() - httpsEnabled = true - scope.launch { - snackbarHostState.showSnackbar(strSnackbarDefaults) - } - } - - fun buildTentativeConfig(): ServerConfigData? { - val host = serverIp.trim() - if (host.isEmpty() || !isValidIpOrHostname(host)) return null - if (apiPortText.isNotEmpty() && !isValidPortNumber(apiPortText)) return null - if (callsPortText.isNotEmpty() && !isValidPortNumber(callsPortText)) return null - return ServerConfigData( - serverIp = host, - apiPort = resolvedApiPort(apiPortText), - callsPort = effectiveCallsPort(callsPortText), - httpsEnabled = httpsEnabled, - ) - } - - val verifyServer: () -> Unit = { - scope.launch { - launch { snackbarHostState.showSnackbar(strChecking) } - val tentative = buildTentativeConfig() - if (tentative == null) { - snackbarHostState.showSnackbar( - if (serverIp.isNotEmpty() && !isValidIpOrHostname(serverIp.trim())) { - strHostError - } else { - strPortError - }, - ) - return@launch - } - val probe = probeServer(tentative) - lastProbe = probe - lastProbedConfig = tentative - val msg = when (probe) { - is ServerProbeResult.Supported -> { - if (probe.callsOk) { - getString(Res.string.server_config_snackbar_ok_calls, probe.pingMs) - } else { - getString(Res.string.server_config_snackbar_ok_calls_skip, probe.pingMs) - } - } - ServerProbeResult.Unsupported -> strUnsupportedInstance - ServerProbeResult.Timeout -> strSnackbarTimeout - ServerProbeResult.Unreachable -> strSnackbarApiFail - } - snackbarHostState.showSnackbar(msg) - } - } - - val fieldColors = OutlinedTextFieldDefaults.colors( - focusedTextColor = scheme.onSurface, - unfocusedTextColor = scheme.onSurface, - disabledTextColor = scheme.onSurface.copy(alpha = 0.38f), - focusedLabelColor = scheme.primary, - unfocusedLabelColor = scheme.onSurfaceVariant, - disabledLabelColor = scheme.onSurfaceVariant.copy(alpha = 0.38f), - cursorColor = scheme.primary, - focusedBorderColor = scheme.primary, - unfocusedBorderColor = scheme.outline, - disabledBorderColor = scheme.onSurface.copy(alpha = 0.12f), - errorBorderColor = scheme.error, - errorLabelColor = scheme.error, - errorCursorColor = scheme.error, - errorSupportingTextColor = scheme.error, - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - disabledContainerColor = Color.Transparent, - ) - - val iconTint = scheme.onSurfaceVariant - val density = LocalDensity.current - val focusedFieldViewportMarginPx = with(density) { ServerConfigFocusedFieldViewportMargin.toPx() } - val listVerticalArrangement = remember { - ServerConfigSpacedByLastAnchoredBottom(space = ServerConfigLazyListItemSpacing) - } - val disabledBringIntoViewSpec = remember { - object : BringIntoViewSpec { - override fun calculateScrollDistance( - offset: Float, - size: Float, - containerSize: Float, - ): Float = 0f - } - } - - Box(Modifier.fillMaxSize()) { - Scaffold( - modifier = Modifier.fillMaxSize(), - // Match ChatScreen: do not apply safeDrawing top to content — status bar handled on the floating top bar only. - contentWindowInsets = WindowInsets.navigationBars, - containerColor = Color.Transparent, - contentColor = scheme.onSurface, - snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) }, - bottomBar = { - Column( - modifier = Modifier - .windowInsetsPadding(WindowInsets.ime) - .fillMaxWidth() - .hazeEffect(state = actionHazeState, style = HazeMaterials.thin()) { - progressive = HazeProgressive.verticalGradient( - startIntensity = 0f, - endIntensity = 1f, - ) - }, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - .padding(horizontal = SettingsStepHorizontalPadding) - .padding(top = 0.dp, bottom = 16.dp), - ) { - val showCtaAsPrimary = canApply || busy - val ctaTargetContainer = - if (showCtaAsPrimary) scheme.primary else scheme.surfaceContainerHigh - val ctaTargetContent = - if (showCtaAsPrimary) scheme.onPrimary else scheme.onSurface.copy(alpha = 0.38f) - val ctaContainer by animateColorAsState( - ctaTargetContainer, - animationSpec = tween(durationMillis = 220), - label = "serverConfigCtaContainer", - ) - val ctaContent by animateColorAsState( - ctaTargetContent, - animationSpec = tween(durationMillis = 220), - label = "serverConfigCtaContent", - ) - Button( - onClick = { - if (!canApply) return@Button - scope.launch { - busy = true - try { - val tentative = buildTentativeConfig() ?: return@launch - val probe = if ( - lastProbedConfig == tentative && lastProbe != null - ) { - lastProbe!! - } else { - probeServer(tentative).also { - lastProbe = it - lastProbedConfig = tentative - } - } - when (probe) { - ServerProbeResult.Unsupported -> { - snackbarHostState.showSnackbar(strUnsupportedInstance) - } - ServerProbeResult.Timeout -> { - snackbarHostState.showSnackbar(strSnackbarTimeout) - } - ServerProbeResult.Unreachable -> { - snackbarHostState.showSnackbar(strSnackbarApiFail) - } - is ServerProbeResult.Supported -> { - Settings.lastKnownServerInstanceId = probe.instanceId - val bearer = ApiClient.token?.trim().orEmpty() - applyServerAndNavigate( - probe = probe, - config = tentative, - bearer = bearer, - onNavigateLogin = { - withContext(Dispatchers.Main) { - navController.navigateAndWipeBackStack("login") - } - }, - onNavigateChat = { - withContext(Dispatchers.Main) { - if (!navController.popBackStack()) { - navController.navigate("chat") { - popUpTo("login") { inclusive = true } - } - } - } - }, - onLogoutOldHost = { - withContext(Dispatchers.Main) { - reloginClearingSession(navController) - } - }, - ) - } - } - } finally { - busy = false - } - } - }, - enabled = canApply, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 52.dp), - shape = SettingsSecurityCtaShape, - colors = ButtonDefaults.buttonColors( - containerColor = ctaContainer, - contentColor = ctaContent, - disabledContainerColor = ctaContainer, - disabledContentColor = ctaContent, - ), - elevation = ButtonDefaults.buttonElevation( - defaultElevation = 0.dp, - pressedElevation = 0.dp, - focusedElevation = 0.dp, - hoveredElevation = 0.dp, - disabledElevation = 0.dp, - ), - ) { - Box( - Modifier - .fillMaxWidth() - .defaultMinSize(minHeight = 24.dp), - contentAlignment = Alignment.Center, - ) { - AnimatedContent( - targetState = busy, - transitionSpec = { - (fadeIn(tween(200)) + slideInVertically { it / 4 }) togetherWith - (fadeOut(tween(200)) + slideOutVertically { -it / 4 }) - }, - label = "server_config_apply_cta", - ) { loading -> - if (loading) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp, - color = ctaContent, - ) - } else { - Text(stringResource(Res.string.save_continue)) - } - } - } - } - } - } - }, - ) { innerPadding -> - val statusBarTopDp = with(density) { WindowInsets.statusBars.getTop(this).toDp() } - val floatingHeaderClearance = statusBarTopDp + 64.dp + ServerConfigTopBarBlurBottomInset - val bottomInsetPadding = innerPadding.calculateBottomPadding() - - LaunchedEffect(focusedItemIndex, bottomInsetPadding) { - val itemIndex = focusedItemIndex ?: return@LaunchedEffect - // Let the IME-adjusted Scaffold padding and LazyColumn layout settle, then do our - // own minimal scroll. This replaces Compose's generic focus relocation behavior. - delay(ServerConfigKeyboardScrollSettleMs) - serverConfigListState.scrollFocusedItemIntoView( - itemIndex = itemIndex, - viewportMarginPx = focusedFieldViewportMarginPx, - ) - } - - Box(Modifier.fillMaxSize()) { - Box(Modifier.fillMaxSize()) { - CompositionLocalProvider(LocalBringIntoViewSpec provides disabledBringIntoViewSpec) { - LazyColumn( - state = serverConfigListState, - modifier = Modifier - .fillMaxSize() - .consumeWindowInsets(innerPadding) - .background(MaterialTheme.colorScheme.background) - .hazeSource(actionHazeState), - contentPadding = PaddingValues(bottom = bottomInsetPadding), - verticalArrangement = listVerticalArrangement, - ) { - item { Spacer(Modifier.height(floatingHeaderClearance)) } - item { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 4.dp), - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = SettingsStepHorizontalPadding) - .clip(SettingsPasswordOutlineFieldShape) - .background(Color.Transparent), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 4.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - SettingsExpressiveIconFrame( - icon = Icons.Filled.Storage, - containerSize = 96.dp, - iconSize = 34.dp, - containerColor = scheme.primaryContainer, - contentColor = scheme.onPrimaryContainer, - materialPolygon = MaterialShapes.Cookie6Sided, - ) - - Spacer(Modifier.height(16.dp)) - - Text( - text = stringResource(Res.string.server_config_title), - style = MaterialTheme.typography.headlineMedium, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - ) - - Spacer(Modifier.height(8.dp)) - - Text( - text = stringResource(Res.string.server_config_subtitle), - style = MaterialTheme.typography.bodyMedium, - color = scheme.onSurfaceVariant, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - ) - - Spacer(Modifier.height(12.dp)) - } - } - } - } - item { - OutlinedTextField( - value = serverIp, - onValueChange = { serverIp = filterHostInput(it) }, - label = { Text(stringResource(Res.string.server_ip_label)) }, - placeholder = { Text(stringResource(Res.string.server_ip_hint)) }, - modifier = Modifier - .fillMaxWidth() - .onFocusChanged { - if (it.isFocused) { - focusedItemIndex = ServerConfigHostItemIndex - } - } - .padding(horizontal = SettingsStepHorizontalPadding), - singleLine = true, - isError = hostError, - supportingText = if (hostError) { - { Text(stringResource(Res.string.server_config_host_error)) } - } else null, - colors = fieldColors, - shape = SettingsPasswordOutlineFieldShape, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Uri, - imeAction = ImeAction.Next, - ), - leadingIcon = { - ServerConfigLeadingIcon(Icons.Filled.Dns, iconTint) - }, - ) - } - item { Spacer(Modifier.height(8.dp)) } - item { - Layout( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = SettingsStepHorizontalPadding), - content = { - OutlinedTextField( - value = apiPortText, - onValueChange = { v -> apiPortText = v.filter { it.isDigit() }.take(6) }, - label = { - Text( - text = stringResource(Res.string.api_port_label), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - placeholder = { Text("8301") }, - modifier = Modifier - .fillMaxWidth() - .onFocusChanged { - if (it.isFocused) { - focusedItemIndex = ServerConfigPortsItemIndex - } - }, - singleLine = true, - isError = apiPortError, - supportingText = if (apiPortError) { - { Text(stringResource(Res.string.server_config_port_error)) } - } else null, - colors = fieldColors, - shape = SettingsPasswordOutlineFieldShape, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.NumberPassword, - imeAction = ImeAction.Next, - ), - leadingIcon = { - ServerConfigLeadingIcon(Icons.Filled.Http, iconTint) - }, - ) - OutlinedTextField( - value = callsPortText, - onValueChange = { v -> callsPortText = v.filter { it.isDigit() }.take(6) }, - label = { - Text( - text = stringResource(Res.string.calls_port_label), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - placeholder = { Text(DEFAULT_CALLS_PORT.toString()) }, - modifier = Modifier - .fillMaxWidth() - .onFocusChanged { - if (it.isFocused) { - focusedItemIndex = ServerConfigPortsItemIndex - } - }, - singleLine = true, - isError = callsPortError, - supportingText = if (callsPortError) { - { Text(stringResource(Res.string.server_config_port_error)) } - } else null, - colors = fieldColors, - shape = SettingsPasswordOutlineFieldShape, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.NumberPassword, - imeAction = ImeAction.Done, - ), - leadingIcon = { - ServerConfigLeadingIcon(Icons.Filled.Call, iconTint) - }, - ) - }, - ) { measurables, constraints -> - val maxW = constraints.maxWidth - val gapPx = portGap.roundToPx() - val avail = (maxW - gapPx).coerceAtLeast(0) - val minCallsPx = 168.dp.roundToPx() - val callsPx = - (avail * 0.60f) - .roundToInt() - .coerceAtLeast(minCallsPx) - .coerceAtMost(avail) - val apiPx = (avail - callsPx).coerceAtLeast(0) - if (measurables.size != 2) { - return@Layout layout(0, 0) {} - } - val w0 = measurables[0].measure(Constraints.fixedWidth(apiPx)) - val w1 = measurables[1].measure(Constraints.fixedWidth(callsPx)) - val h = maxOf(w0.height, w1.height) - .coerceIn(constraints.minHeight, constraints.maxHeight) - layout(maxW, h) { - w0.place(0, 0) - w1.place(apiPx + gapPx, 0) - } - } - } - item { Spacer(Modifier.height(16.dp)) } - item { - Column { - Category( - modifier = Modifier.fillMaxWidth(), - margin = PaddingValues( - start = SettingsStepHorizontalPadding, - end = SettingsStepHorizontalPadding, - ), - containerColor = MaterialTheme.colorScheme.surfaceContainerLow, - ) { - SwitchListItem( - modifier = Modifier.fillMaxWidth(), - headline = stringResource(Res.string.server_config_https_headline), - supportingText = stringResource(Res.string.server_config_https_local_hint), - leadingContent = { - ServerConfigHttpsLeadingIcon(iconTint) - }, - checked = httpsEnabled, - onCheckedChange = { httpsEnabled = it }, - divider = false, - ) - } - } - } - item { - ServerConfigActionButtons( - onReset = { showResetDialog = true }, - onVerify = verifyServer, - modifier = Modifier - .fillMaxWidth() - .background(Color.Transparent) - .padding(horizontal = SettingsStepHorizontalPadding) - .padding(top = 8.dp, bottom = 6.dp), - ) - } - } - } - - Box(Modifier.align(Alignment.TopCenter)) { - ServerConfigStickyTopBar( - hazeState = actionHazeState, - topBarBlurHeight = floatingHeaderClearance, - onNavigateUp = { navController.navigateUp() }, - ) - } - } - if (showResetDialog) { - AlertDialog( - onDismissRequest = { - showResetDialog = false - }, - title = { - Text(strResetConfirmTitle) - }, - text = { - Text(strResetConfirmBody) - }, - confirmButton = { - TextButton( - onClick = { - showResetDialog = false - resetToDefaults() - }, - ) { - Text(stringResource(Res.string.confirm)) - } - }, - dismissButton = { - TextButton(onClick = { showResetDialog = false }) { - Text(stringResource(Res.string.cancel)) - } - }, - ) - } - } - } - } -} - -private suspend fun reloginClearingSession(navController: NavController) { - WebSocketManager.disconnect() - runCatching { ApiClient.logout() } - ApiClient.clearMemorySession() - navController.navigateAndWipeBackStack("login") -} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/utils/Generic.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/utils/Generic.kt deleted file mode 100644 index c2d0481..0000000 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/utils/Generic.kt +++ /dev/null @@ -1,9 +0,0 @@ -package ru.fromchat.utils - -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.exclude -import androidx.compose.foundation.layout.only - -@Suppress("NOTHING_TO_INLINE") -inline fun WindowInsets.exclude(sides: WindowInsetsSides) = exclude(this.only(sides)) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/net/NetworkConnectivity.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/utils/NetworkConnectivity.kt similarity index 55% rename from app/shared/src/commonMain/kotlin/ru/fromchat/net/NetworkConnectivity.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/utils/NetworkConnectivity.kt index e78ab02..507a810 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/net/NetworkConnectivity.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/utils/NetworkConnectivity.kt @@ -1,10 +1,9 @@ -package ru.fromchat.net +package ru.fromchat.utils import kotlinx.coroutines.flow.StateFlow expect object NetworkConnectivity { val isOnline: StateFlow - /** Register OS callbacks once (Android: ConnectivityManager; iOS: best-effort). */ fun ensureStarted() -} +} \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/HapticFeedback.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/utils/haptic/HapticFeedback.kt similarity index 65% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/HapticFeedback.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/utils/haptic/HapticFeedback.kt index 7031c6f..dd68024 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/HapticFeedback.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/utils/haptic/HapticFeedback.kt @@ -1,7 +1,9 @@ -package ru.fromchat.ui +package ru.fromchat.utils.haptic import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import ru.fromchat.utils.haptic.HapticFeedbackEvent +import ru.fromchat.ui.components.rememberHapticFeedbackInternal @Composable fun rememberHapticFeedback(): (HapticFeedbackEvent) -> Unit { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/HapticFeedbackEvent.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/utils/haptic/HapticFeedbackEvent.kt similarity index 76% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/HapticFeedbackEvent.kt rename to app/shared/src/commonMain/kotlin/ru/fromchat/utils/haptic/HapticFeedbackEvent.kt index 9343aa8..9108cae 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/HapticFeedbackEvent.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/utils/haptic/HapticFeedbackEvent.kt @@ -1,8 +1,8 @@ -package ru.fromchat.ui +package ru.fromchat.utils.haptic enum class HapticFeedbackEvent { ProfileOpened, ProfileClosed, MessageSent, ContextMenuOpened -} +} \ No newline at end of file diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/IosApplicationBootstrap.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/IosApplicationBootstrap.kt index c564ebb..fa136b2 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/IosApplicationBootstrap.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/IosApplicationBootstrap.kt @@ -1,3 +1,5 @@ +@file:Suppress("unused") + package ru.fromchat import kotlinx.coroutines.CoroutineScope @@ -5,11 +7,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import ru.fromchat.api.ApiClient -import ru.fromchat.api.AttachmentTransferBootstrap +import ru.fromchat.api.local.workers.AttachmentTransferBootstrap -/** - * iOS cold start (call from [iOSApp] Swift `init`, not from UIViewController lifecycle). - */ object IosApplicationBootstrap { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private var started = false diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/core/Logger.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/Logger.ios.kt similarity index 96% rename from app/shared/src/iosMain/kotlin/ru/fromchat/core/Logger.ios.kt rename to app/shared/src/iosMain/kotlin/ru/fromchat/Logger.ios.kt index 7ec7641..52a2c02 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/core/Logger.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/Logger.ios.kt @@ -1,4 +1,4 @@ -package ru.fromchat.core +package ru.fromchat import platform.Foundation.NSLog diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/MainViewController.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/MainViewController.kt index eb7e297..57ea5fa 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/MainViewController.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/MainViewController.kt @@ -1,9 +1,10 @@ +@file:Suppress("unused") + package ru.fromchat import androidx.compose.ui.window.ComposeUIViewController import ru.fromchat.ui.App -@Suppress("unused") fun MainViewController( startAtProfileUserId: Int?, startAtProfileUsername: String? diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/api/DeferredStartupNetwork.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/DeferredStartupNetwork.ios.kt new file mode 100644 index 0000000..97c6f85 --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/DeferredStartupNetwork.ios.kt @@ -0,0 +1,5 @@ +package ru.fromchat.api + +actual suspend fun syncPushTokenAfterStartup() { + // айфон г@вно поэтому этого не будет потому что не хочу платить кучу денег за App Store +} diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/fcm/FcmRegistration.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/FcmRegistration.ios.kt similarity index 94% rename from app/shared/src/iosMain/kotlin/ru/fromchat/fcm/FcmRegistration.ios.kt rename to app/shared/src/iosMain/kotlin/ru/fromchat/api/FcmRegistration.ios.kt index 28ad1dd..6b8db8a 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/fcm/FcmRegistration.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/FcmRegistration.ios.kt @@ -1,4 +1,4 @@ -package ru.fromchat.fcm +package ru.fromchat.api actual suspend fun uploadPendingFcmTokenIfAvailable() { // iOS does not use FCM token management in this app build. diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/backup/BackupCrypto.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/crypto/backup/BackupCrypto.ios.kt similarity index 95% rename from app/shared/src/iosMain/kotlin/ru/fromchat/crypto/backup/BackupCrypto.ios.kt rename to app/shared/src/iosMain/kotlin/ru/fromchat/api/crypto/backup/BackupCrypto.ios.kt index 7b0c6bb..a2f25cb 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/backup/BackupCrypto.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/crypto/backup/BackupCrypto.ios.kt @@ -1,8 +1,9 @@ -package ru.fromchat.crypto.backup +package ru.fromchat.api.crypto.backup import dev.whyoleg.cryptography.BinarySize.Companion.bits import dev.whyoleg.cryptography.BinarySize.Companion.bytes import dev.whyoleg.cryptography.CryptographyProvider +import dev.whyoleg.cryptography.DelicateCryptographyApi import dev.whyoleg.cryptography.algorithms.AES import dev.whyoleg.cryptography.algorithms.PBKDF2 import dev.whyoleg.cryptography.algorithms.SHA256 @@ -14,7 +15,7 @@ private const val PBKDF2_ITERATIONS = 210_000 private const val IV_SIZE = 12 private const val KEY_SIZE_BYTES = 32 -@OptIn(dev.whyoleg.cryptography.DelicateCryptographyApi::class) +@OptIn(DelicateCryptographyApi::class) actual object BackupCrypto { private val provider get() = CryptographyProvider.Default private val aesGcm get() = provider.get(AES.GCM) diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/crypto/dm/DmCrypto.ios.kt similarity index 95% rename from app/shared/src/iosMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.ios.kt rename to app/shared/src/iosMain/kotlin/ru/fromchat/api/crypto/dm/DmCrypto.ios.kt index 1f92ebd..3513451 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/crypto/dm/DmCrypto.ios.kt @@ -1,19 +1,20 @@ -package ru.fromchat.crypto.dm +package ru.fromchat.api.crypto.dm import com.pr0gramm3r101.utils.crypto.Base64 import com.pr0gramm3r101.utils.require import dev.whyoleg.cryptography.BinarySize.Companion.bits import dev.whyoleg.cryptography.CryptographyProvider +import dev.whyoleg.cryptography.DelicateCryptographyApi import dev.whyoleg.cryptography.algorithms.AES import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import ru.fromchat.crypto.DmCiphertextCorruptedException +import ru.fromchat.api.crypto.DmCiphertextCorruptedException private const val AES_KEY_SIZE = 32 private const val GCM_IV_SIZE = 12 private const val GCM_TAG_SIZE = 16 -@OptIn(dev.whyoleg.cryptography.DelicateCryptographyApi::class) +@OptIn(DelicateCryptographyApi::class) actual object DmCrypto { private val provider get() = CryptographyProvider.Default private val aesGcm get() = provider.get(AES.GCM) diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/dm/PlatformAesGcmStreamDecrypt.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/crypto/dm/PlatformAesGcmStreamDecrypt.ios.kt similarity index 96% rename from app/shared/src/iosMain/kotlin/ru/fromchat/crypto/dm/PlatformAesGcmStreamDecrypt.ios.kt rename to app/shared/src/iosMain/kotlin/ru/fromchat/api/crypto/dm/PlatformAesGcmStreamDecrypt.ios.kt index fdae88e..63db6cb 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/dm/PlatformAesGcmStreamDecrypt.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/crypto/dm/PlatformAesGcmStreamDecrypt.ios.kt @@ -1,8 +1,9 @@ -package ru.fromchat.crypto.dm +package ru.fromchat.api.crypto.dm import com.pr0gramm3r101.utils.files.PlatformFileSystem import dev.whyoleg.cryptography.BinarySize.Companion.bits import dev.whyoleg.cryptography.CryptographyProvider +import dev.whyoleg.cryptography.DelicateCryptographyApi import dev.whyoleg.cryptography.algorithms.AES import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.addressOf @@ -23,7 +24,7 @@ private const val GCM_IV_SIZE = 12 private const val GCM_TAG_SIZE = 16 private const val COPY_BUFFER_BYTES = 256 * 1024 -@OptIn(ExperimentalForeignApi::class, dev.whyoleg.cryptography.DelicateCryptographyApi::class) +@OptIn(ExperimentalForeignApi::class, DelicateCryptographyApi::class) internal actual suspend fun platformAesGcmStreamDecryptMekFile( iv: ByteArray, encryptedPath: String, @@ -63,7 +64,7 @@ internal actual suspend fun platformAesGcmStreamDecryptMekFile( return plaintextBytes } -private suspend fun pumpRawSourceToSink(source: RawSource, sink: kotlinx.io.RawSink) { +private suspend fun pumpRawSourceToSink(source: RawSource, sink: RawSink) { val chunk = Buffer() while (true) { val read = source.readAtMostTo(chunk, COPY_BUFFER_BYTES.toLong()) diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/crypto/transport/TransportCrypto.ios.kt similarity index 96% rename from app/shared/src/iosMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.ios.kt rename to app/shared/src/iosMain/kotlin/ru/fromchat/api/crypto/transport/TransportCrypto.ios.kt index 0914f7d..3cf866a 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/crypto/transport/TransportCrypto.ios.kt @@ -1,4 +1,4 @@ -package ru.fromchat.crypto.transport +package ru.fromchat.api.crypto.transport import com.ionspin.kotlin.crypto.LibsodiumInitializer import com.ionspin.kotlin.crypto.box.Box @@ -7,6 +7,7 @@ import com.ionspin.kotlin.crypto.util.LibsodiumRandom import com.pr0gramm3r101.utils.crypto.Base64 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import ru.fromchat.api.crypto.transport.TransportCiphertext actual object TransportCrypto { actual suspend fun encryptWithTransportKey( diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/transport/TransportFileEncryptor.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/crypto/transport/TransportFileEncryptor.ios.kt similarity index 91% rename from app/shared/src/iosMain/kotlin/ru/fromchat/crypto/transport/TransportFileEncryptor.ios.kt rename to app/shared/src/iosMain/kotlin/ru/fromchat/api/crypto/transport/TransportFileEncryptor.ios.kt index 545bd37..8df22eb 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/transport/TransportFileEncryptor.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/crypto/transport/TransportFileEncryptor.ios.kt @@ -1,11 +1,11 @@ -package ru.fromchat.crypto.transport +package ru.fromchat.api.crypto.transport import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import platform.Foundation.NSFileManager -import ru.fromchat.platform.iosAppendFile -import ru.fromchat.platform.iosFileSize +import com.pr0gramm3r101.utils.iosAppendFile +import com.pr0gramm3r101.utils.iosFileSize @OptIn(ExperimentalForeignApi::class) actual object TransportFileEncryptor { diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/transport/TransportFileKey.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/crypto/transport/TransportFileKey.ios.kt similarity index 87% rename from app/shared/src/iosMain/kotlin/ru/fromchat/crypto/transport/TransportFileKey.ios.kt rename to app/shared/src/iosMain/kotlin/ru/fromchat/api/crypto/transport/TransportFileKey.ios.kt index 6ab4292..150cba6 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/transport/TransportFileKey.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/crypto/transport/TransportFileKey.ios.kt @@ -1,20 +1,21 @@ -package ru.fromchat.crypto.transport +package ru.fromchat.api.crypto.transport import com.ionspin.kotlin.crypto.LibsodiumInitializer import com.ionspin.kotlin.crypto.box.Box import com.pr0gramm3r101.utils.crypto.Base64 import dev.whyoleg.cryptography.BinarySize.Companion.bits import dev.whyoleg.cryptography.CryptographyProvider +import dev.whyoleg.cryptography.DelicateCryptographyApi import dev.whyoleg.cryptography.algorithms.AES import dev.whyoleg.cryptography.random.CryptographyRandom import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext -import ru.fromchat.platform.iosHmacSha256 +import com.pr0gramm3r101.utils.iosHmacSha256 private const val IV_SIZE = 12 -@OptIn(dev.whyoleg.cryptography.DelicateCryptographyApi::class) +@OptIn(DelicateCryptographyApi::class) private val aesGcm get() = CryptographyProvider.Default.get(AES.GCM) internal actual fun deriveTransportFileAesKey( @@ -31,7 +32,7 @@ internal actual fun deriveTransportFileAesKey( return hkdfTransportFileKey(shared) } -@OptIn(dev.whyoleg.cryptography.DelicateCryptographyApi::class) +@OptIn(DelicateCryptographyApi::class) internal actual suspend fun aesGcmEncryptChunk( key: ByteArray, plaintext: ByteArray, diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/core/cache/AttachmentUploadLimits.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/AttachmentUploadLimits.ios.kt similarity index 77% rename from app/shared/src/iosMain/kotlin/ru/fromchat/core/cache/AttachmentUploadLimits.ios.kt rename to app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/AttachmentUploadLimits.ios.kt index f86de20..c981aa9 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/core/cache/AttachmentUploadLimits.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/AttachmentUploadLimits.ios.kt @@ -1,4 +1,4 @@ -package ru.fromchat.core.cache +package ru.fromchat.api.local.cache private const val IOS_MAX_BYTES = 32L * 1024L * 1024L diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/core/files/FileWriteSink.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/FileWriteSink.ios.kt similarity index 96% rename from app/shared/src/iosMain/kotlin/ru/fromchat/core/files/FileWriteSink.ios.kt rename to app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/FileWriteSink.ios.kt index 8a9aeb3..e5fce83 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/core/files/FileWriteSink.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/FileWriteSink.ios.kt @@ -1,4 +1,4 @@ -package ru.fromchat.core.files +package ru.fromchat.api.local.cache import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.addressOf diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/core/cache/FromChatCacheDirs.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.ios.kt similarity index 95% rename from app/shared/src/iosMain/kotlin/ru/fromchat/core/cache/FromChatCacheDirs.ios.kt rename to app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.ios.kt index 90fad39..944b25b 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/core/cache/FromChatCacheDirs.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.ios.kt @@ -1,4 +1,4 @@ -package ru.fromchat.core.cache +package ru.fromchat.api.local.cache import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.coroutines.Dispatchers diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/core/cache/FromChatCacheGeneration.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheGeneration.ios.kt similarity index 98% rename from app/shared/src/iosMain/kotlin/ru/fromchat/core/cache/FromChatCacheGeneration.ios.kt rename to app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheGeneration.ios.kt index ff77cd7..ebf4d74 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/core/cache/FromChatCacheGeneration.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheGeneration.ios.kt @@ -1,4 +1,4 @@ -package ru.fromchat.core.cache +package ru.fromchat.api.local.cache import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.addressOf diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/LocalDecodedImageCache.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/LocalDecodedImageCache.ios.kt similarity index 94% rename from app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/LocalDecodedImageCache.ios.kt rename to app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/LocalDecodedImageCache.ios.kt index 2c5eb9f..ec86fa9 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/LocalDecodedImageCache.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/LocalDecodedImageCache.ios.kt @@ -1,9 +1,10 @@ -@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class, kotlinx.cinterop.BetaInteropApi::class) +@file:OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) -package ru.fromchat.ui.chat +package ru.fromchat.api.local.cache import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.toComposeImageBitmap +import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.ByteVar import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.get @@ -22,6 +23,7 @@ import platform.UIKit.UIGraphicsGetImageFromCurrentImageContext import platform.UIKit.UIImage import platform.UIKit.UIImageOrientation import platform.UIKit.UIImagePNGRepresentation +import ru.fromchat.api.local.download.decodeImageBytes actual object PlatformDecodedBitmapCache { private val cache = mutableMapOf() diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/PendingFileSaveRegistry.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/PendingFileSaveRegistry.ios.kt similarity index 92% rename from app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/PendingFileSaveRegistry.ios.kt rename to app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/PendingFileSaveRegistry.ios.kt index aae6fcd..018ec2d 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/PendingFileSaveRegistry.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/PendingFileSaveRegistry.ios.kt @@ -1,6 +1,6 @@ -@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) +@file:OptIn(ExperimentalForeignApi::class) -package ru.fromchat.ui.chat +package ru.fromchat.api.local.cache import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.addressOf @@ -14,7 +14,7 @@ import platform.Foundation.NSURL import platform.Foundation.create import platform.Foundation.writeToFile import platform.Foundation.writeToURL -import ru.fromchat.core.cache.readOutboundFileBytes +import ru.fromchat.api.local.download.cachedAttachmentFileSize private val copyScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/core/cache/ResumableUploadStorage.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/ResumableUploadStorage.ios.kt similarity index 98% rename from app/shared/src/iosMain/kotlin/ru/fromchat/core/cache/ResumableUploadStorage.ios.kt rename to app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/ResumableUploadStorage.ios.kt index 1a3fd27..4226566 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/core/cache/ResumableUploadStorage.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/ResumableUploadStorage.ios.kt @@ -1,4 +1,4 @@ -package ru.fromchat.core.cache +package ru.fromchat.api.local.cache import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.ByteVar @@ -19,10 +19,10 @@ import platform.Foundation.NSUserDomainMask import platform.Foundation.create import platform.Foundation.dataWithContentsOfURL import platform.Foundation.writeToFile -import ru.fromchat.platform.IosPosixFileReader -import ru.fromchat.platform.iosCopyFile -import ru.fromchat.platform.iosFileSize -import ru.fromchat.platform.iosReadFileRange +import com.pr0gramm3r101.utils.IosPosixFileReader +import com.pr0gramm3r101.utils.iosCopyFile +import com.pr0gramm3r101.utils.iosFileSize +import com.pr0gramm3r101.utils.iosReadFileRange @OptIn(ExperimentalForeignApi::class) private fun uploadDir(instanceId: String): String { diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/api/db/MessageDatabaseLock.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseLock.ios.kt similarity index 88% rename from app/shared/src/iosMain/kotlin/ru/fromchat/api/db/MessageDatabaseLock.ios.kt rename to app/shared/src/iosMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseLock.ios.kt index b6af43e..01ed8a9 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/api/db/MessageDatabaseLock.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseLock.ios.kt @@ -1,4 +1,4 @@ -package ru.fromchat.api.db +package ru.fromchat.api.local.db import platform.Foundation.NSRecursiveLock diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/api/db/MessageDatabaseDriver.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseDriver.ios.kt similarity index 95% rename from app/shared/src/iosMain/kotlin/ru/fromchat/api/db/MessageDatabaseDriver.ios.kt rename to app/shared/src/iosMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseDriver.ios.kt index d5a24ea..49fdfb2 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/api/db/MessageDatabaseDriver.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseDriver.ios.kt @@ -1,4 +1,4 @@ -package ru.fromchat.api.db +package ru.fromchat.api.local.db.store import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.native.NativeSqliteDriver diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/api/AttachmentDownloadForeground.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/download/AttachmentDownloadForeground.ios.kt similarity index 87% rename from app/shared/src/iosMain/kotlin/ru/fromchat/api/AttachmentDownloadForeground.ios.kt rename to app/shared/src/iosMain/kotlin/ru/fromchat/api/local/download/AttachmentDownloadForeground.ios.kt index a0a3980..579d578 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/api/AttachmentDownloadForeground.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/download/AttachmentDownloadForeground.ios.kt @@ -1,4 +1,4 @@ -package ru.fromchat.api +package ru.fromchat.api.local.download actual object AttachmentDownloadForeground { actual fun onFileDownloadStarted(storageKey: String) = Unit diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/DmFileDownloader.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/download/DmFileDownloader.ios.kt similarity index 82% rename from app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/DmFileDownloader.ios.kt rename to app/shared/src/iosMain/kotlin/ru/fromchat/api/local/download/DmFileDownloader.ios.kt index 368fc2e..9940e28 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/DmFileDownloader.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/download/DmFileDownloader.ios.kt @@ -1,8 +1,7 @@ -package ru.fromchat.ui.chat +package ru.fromchat.api.local.download import platform.Foundation.NSURL import platform.UIKit.UIApplication -import ru.fromchat.platform.iosTopViewController actual suspend fun openCachedAttachmentFile( cacheUri: String, diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/api/EncryptedFileDownloader.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/download/EncryptedFileDownloader.ios.kt similarity index 92% rename from app/shared/src/iosMain/kotlin/ru/fromchat/api/EncryptedFileDownloader.ios.kt rename to app/shared/src/iosMain/kotlin/ru/fromchat/api/local/download/EncryptedFileDownloader.ios.kt index ed9bbb7..0ce8c74 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/api/EncryptedFileDownloader.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/download/EncryptedFileDownloader.ios.kt @@ -1,4 +1,4 @@ -package ru.fromchat.api +package ru.fromchat.api.local.download import io.ktor.client.HttpClient import io.ktor.client.engine.darwin.Darwin diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/MessageFileSave.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/download/MessageFileSave.ios.kt similarity index 91% rename from app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/MessageFileSave.ios.kt rename to app/shared/src/iosMain/kotlin/ru/fromchat/api/local/download/MessageFileSave.ios.kt index 055678c..5043a4c 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/MessageFileSave.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/download/MessageFileSave.ios.kt @@ -1,6 +1,6 @@ -@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) +@file:OptIn(ExperimentalForeignApi::class) -package ru.fromchat.ui.chat +package ru.fromchat.api.local.download import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -15,7 +15,10 @@ import platform.Foundation.NSURL import platform.UIKit.UIDocumentPickerDelegateProtocol import platform.UIKit.UIDocumentPickerViewController import platform.darwin.NSObject -import ru.fromchat.platform.iosTopViewController +import ru.fromchat.api.local.cache.DecryptedFileCache +import ru.fromchat.api.local.cache.PendingFileSaveEntry +import ru.fromchat.api.local.cache.PendingFileSaveRegistry +import ru.fromchat.utils.iosTopViewController @Composable actual fun rememberPlatformSaveMessageFile( diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/MessageImageSave.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/download/MessageImageSave.ios.kt similarity index 92% rename from app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/MessageImageSave.ios.kt rename to app/shared/src/iosMain/kotlin/ru/fromchat/api/local/download/MessageImageSave.ios.kt index 8d0577c..06dac81 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/MessageImageSave.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/download/MessageImageSave.ios.kt @@ -1,6 +1,6 @@ -@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) +@file:OptIn(ExperimentalForeignApi::class) -package ru.fromchat.ui.chat +package ru.fromchat.api.local.download import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -10,7 +10,10 @@ import androidx.compose.runtime.setValue import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.addressOf import kotlinx.cinterop.usePinned +import platform.Foundation.NSCachesDirectory import platform.Foundation.NSData +import platform.Foundation.NSDocumentDirectory +import platform.Foundation.NSDownloadsDirectory import platform.Foundation.NSFileManager import platform.Foundation.NSSearchPathForDirectoriesInDomains import platform.Foundation.NSURL @@ -20,7 +23,7 @@ import platform.Foundation.writeToFile import platform.UIKit.UIDocumentPickerDelegateProtocol import platform.UIKit.UIDocumentPickerViewController import platform.darwin.NSObject -import ru.fromchat.platform.iosTopViewController +import ru.fromchat.utils.iosTopViewController private const val EXPORT_SUBDIR = "save_export" @@ -31,7 +34,7 @@ internal fun sanitizeExportFilename(filename: String): String { private fun exportStagingDirectory(): String { val caches = NSSearchPathForDirectoriesInDomains( - platform.Foundation.NSCachesDirectory, + NSCachesDirectory, NSUserDomainMask, true, ).filterIsInstance().firstOrNull().orEmpty() @@ -53,14 +56,14 @@ private fun writeExportStagingFile(filename: String, bytes: ByteArray): String? private fun defaultExportDirectoryUrl(): NSURL? { val manager = NSFileManager.defaultManager manager.URLForDirectory( - platform.Foundation.NSDownloadsDirectory, + NSDownloadsDirectory, NSUserDomainMask, null, false, null, )?.let { return it } return manager.URLForDirectory( - platform.Foundation.NSDocumentDirectory, + NSDocumentDirectory, NSUserDomainMask, null, false, diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/api/outbox/OutboxProcessor.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/send/OutboxProcessor.ios.kt similarity index 92% rename from app/shared/src/iosMain/kotlin/ru/fromchat/api/outbox/OutboxProcessor.ios.kt rename to app/shared/src/iosMain/kotlin/ru/fromchat/api/local/send/OutboxProcessor.ios.kt index a532ae1..1119661 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/api/outbox/OutboxProcessor.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/send/OutboxProcessor.ios.kt @@ -1,4 +1,4 @@ -package ru.fromchat.api.outbox +package ru.fromchat.api.local.send import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/api/AttachmentFileCopyForeground.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/workers/AttachmentFileCopyForeground.ios.kt similarity index 82% rename from app/shared/src/iosMain/kotlin/ru/fromchat/api/AttachmentFileCopyForeground.ios.kt rename to app/shared/src/iosMain/kotlin/ru/fromchat/api/local/workers/AttachmentFileCopyForeground.ios.kt index 3fd284c..7490ba1 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/api/AttachmentFileCopyForeground.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/workers/AttachmentFileCopyForeground.ios.kt @@ -1,4 +1,4 @@ -package ru.fromchat.api +package ru.fromchat.api.local.workers actual object AttachmentFileCopyForeground { actual fun onCopyStarted(storageKey: String, displayLabel: String?) = Unit diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/workers/AttachmentUploadQueue.ios.kt similarity index 75% rename from app/shared/src/iosMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.ios.kt rename to app/shared/src/iosMain/kotlin/ru/fromchat/api/local/workers/AttachmentUploadQueue.ios.kt index a78a475..35f06f4 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/workers/AttachmentUploadQueue.ios.kt @@ -1,8 +1,8 @@ -package ru.fromchat.api +package ru.fromchat.api.local.workers import kotlinx.coroutines.flow.SharedFlow -import ru.fromchat.api.outbox.scheduleOutboxProcessing -import ru.fromchat.core.cache.CacheContext +import ru.fromchat.api.local.send.scheduleOutboxProcessing +import ru.fromchat.api.local.cache.CacheContext actual object AttachmentUploadQueue { actual val progressFlow: SharedFlow = AttachmentUploadNotifier.progressFlow diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/core/DeferredStartupNetwork.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/core/DeferredStartupNetwork.ios.kt deleted file mode 100644 index c034229..0000000 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/core/DeferredStartupNetwork.ios.kt +++ /dev/null @@ -1,5 +0,0 @@ -package ru.fromchat.core - -actual suspend fun syncPushTokenAfterStartup() { - // iOS push registration is handled separately when APNs is wired. -} diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/debug/DebugLogger.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/debug/DebugLogger.ios.kt deleted file mode 100644 index d3d3e6f..0000000 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/debug/DebugLogger.ios.kt +++ /dev/null @@ -1,8 +0,0 @@ -package ru.fromchat.debug - -actual object DebugLogger { - actual fun log(payload: DebugLogPayload) { - // iOS target: no-op for debug logging in this session. - } -} - diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/platform/CurrentDeviceInfo.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/platform/CurrentDeviceInfo.ios.kt deleted file mode 100644 index 1aafc0a..0000000 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/platform/CurrentDeviceInfo.ios.kt +++ /dev/null @@ -1,19 +0,0 @@ -package ru.fromchat.platform - -import platform.UIKit.UIDevice - -private fun String?.trimIfNotBlank(): String? = - this?.trim()?.takeIf { it.isNotEmpty() } - -actual fun currentDeviceInfo(): CurrentDeviceInfo { - val current = UIDevice.currentDevice - - return CurrentDeviceInfo( - osName = current.systemName.trimIfNotBlank(), - osVersion = current.systemVersion.trimIfNotBlank(), - deviceType = "mobile", - deviceName = current.name.trimIfNotBlank(), - brand = "Apple", - model = current.model.trimIfNotBlank() - ) -} diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/BackHandler.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/BackHandler.ios.kt index 62ad2e8..750095d 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/BackHandler.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/BackHandler.ios.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.remember import kotlinx.cinterop.ExperimentalForeignApi import platform.UIKit.UIImpactFeedbackGenerator import platform.UIKit.UIImpactFeedbackStyle +import ru.fromchat.utils.haptic.HapticFeedbackEvent @Composable actual fun BackHandler(enabled: Boolean, onBack: () -> Unit) { diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/ImeScrollWithKeyboard.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/ImeScrollWithKeyboard.ios.kt deleted file mode 100644 index d0ce02f..0000000 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/ImeScrollWithKeyboard.ios.kt +++ /dev/null @@ -1,5 +0,0 @@ -package ru.fromchat.ui - -import androidx.compose.ui.Modifier - -actual fun Modifier.imeScrollWithKeyboard(): Modifier = this diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/calls/CallMediaLayer.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/calls/CallMediaLayer.ios.kt similarity index 83% rename from app/shared/src/iosMain/kotlin/ru/fromchat/calls/CallMediaLayer.ios.kt rename to app/shared/src/iosMain/kotlin/ru/fromchat/ui/calls/CallMediaLayer.ios.kt index 8b0fe20..8e1f785 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/calls/CallMediaLayer.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/calls/CallMediaLayer.ios.kt @@ -1,9 +1,10 @@ -package ru.fromchat.calls +package ru.fromchat.ui.calls import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import ru.fromchat.api.calls.LiveKitConnectSession @Composable actual fun CallMediaLayer( diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/calls/InlineCallChrome.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/calls/InlineCallChrome.ios.kt similarity index 63% rename from app/shared/src/iosMain/kotlin/ru/fromchat/calls/InlineCallChrome.ios.kt rename to app/shared/src/iosMain/kotlin/ru/fromchat/ui/calls/InlineCallChrome.ios.kt index f1bb0d3..81c7c67 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/calls/InlineCallChrome.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/calls/InlineCallChrome.ios.kt @@ -1,3 +1,3 @@ -package ru.fromchat.calls +package ru.fromchat.ui.calls actual val UseInlineInCallChrome: Boolean = false diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/AttachmentFileDownload.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentFileDownload.ios.kt similarity index 90% rename from app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/AttachmentFileDownload.ios.kt rename to app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentFileDownload.ios.kt index 3f46800..e31bda5 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/AttachmentFileDownload.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentFileDownload.ios.kt @@ -1,6 +1,6 @@ -@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) +@file:OptIn(ExperimentalForeignApi::class) -package ru.fromchat.ui.chat +package ru.fromchat.ui.chat.utils import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -10,7 +10,10 @@ import androidx.compose.runtime.setValue import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.addressOf import kotlinx.cinterop.usePinned +import platform.Foundation.NSCachesDirectory import platform.Foundation.NSData +import platform.Foundation.NSDocumentDirectory +import platform.Foundation.NSDownloadsDirectory import platform.Foundation.NSFileManager import platform.Foundation.NSSearchPathForDirectoriesInDomains import platform.Foundation.NSURL @@ -20,7 +23,8 @@ import platform.Foundation.writeToFile import platform.UIKit.UIDocumentPickerDelegateProtocol import platform.UIKit.UIDocumentPickerViewController import platform.darwin.NSObject -import ru.fromchat.platform.iosTopViewController +import ru.fromchat.api.local.download.sanitizeExportFilename +import ru.fromchat.utils.iosTopViewController @Composable actual fun rememberCreateDownloadDestinationLauncher( @@ -63,13 +67,13 @@ actual suspend fun persistExportUriPermissionIfNeeded(exportUri: String) { private fun defaultDownloadsDirectoryUrl(): NSURL? { val manager = NSFileManager.defaultManager return manager.URLForDirectory( - platform.Foundation.NSDownloadsDirectory, + NSDownloadsDirectory, NSUserDomainMask, null, false, null, ) ?: manager.URLForDirectory( - platform.Foundation.NSDocumentDirectory, + NSDocumentDirectory, NSUserDomainMask, null, false, @@ -80,7 +84,7 @@ private fun defaultDownloadsDirectoryUrl(): NSURL? { @OptIn(ExperimentalForeignApi::class) private fun writePickerStagingPlaceholder(filename: String): String? { val caches = NSSearchPathForDirectoriesInDomains( - platform.Foundation.NSCachesDirectory, + NSCachesDirectory, NSUserDomainMask, true, ).filterIsInstance().firstOrNull().orEmpty() diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/AttachmentOpenFeedback.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentOpenFeedback.ios.kt similarity index 80% rename from app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/AttachmentOpenFeedback.ios.kt rename to app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentOpenFeedback.ios.kt index 1182add..1b68a96 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/AttachmentOpenFeedback.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentOpenFeedback.ios.kt @@ -1,4 +1,4 @@ -package ru.fromchat.ui.chat +package ru.fromchat.ui.chat.utils internal actual fun showAttachmentOpenFailed(message: String) { // iOS uses share sheet from openCachedAttachmentFile; no-op here. diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentPickers.ios.kt similarity index 94% rename from app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.ios.kt rename to app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentPickers.ios.kt index fca4c1f..dafb16f 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentPickers.ios.kt @@ -1,4 +1,4 @@ -package ru.fromchat.ui.chat +package ru.fromchat.ui.chat.utils import androidx.compose.runtime.Composable diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/main/settings/SettingsPredictiveBack.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/components/PredictiveBackHandler.kt similarity index 56% rename from app/shared/src/iosMain/kotlin/ru/fromchat/ui/main/settings/SettingsPredictiveBack.ios.kt rename to app/shared/src/iosMain/kotlin/ru/fromchat/ui/components/PredictiveBackHandler.kt index 9c5924b..a6ac4f3 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/main/settings/SettingsPredictiveBack.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/components/PredictiveBackHandler.kt @@ -1,15 +1,14 @@ -package ru.fromchat.ui.main.settings +package ru.fromchat.ui.components import androidx.compose.runtime.Composable @Composable -actual fun SettingsSecurityPredictiveBackHandler( +actual fun PredictiveBackHandler( enabled: Boolean, onProgress: (Float) -> Unit, onCommit: () -> Unit, onCancel: () -> Unit, ) { - // iOS has no Android predictive back; keep state reset. onProgress(0f) } diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/platform/OpenAppNotificationSettings.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.ios.kt similarity index 92% rename from app/shared/src/iosMain/kotlin/ru/fromchat/platform/OpenAppNotificationSettings.ios.kt rename to app/shared/src/iosMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.ios.kt index 8e80644..cbc2119 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/platform/OpenAppNotificationSettings.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.ios.kt @@ -1,4 +1,4 @@ -package ru.fromchat.platform +package ru.fromchat.ui.main.settings import platform.Foundation.NSURL import platform.UIKit.UIApplication @@ -13,4 +13,4 @@ actual fun openAppNotificationSettings(): Boolean { actual fun areAppNotificationsEnabled(): Boolean { return true -} +} \ No newline at end of file diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ProfileLoadErrorMessage.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.ios.kt similarity index 80% rename from app/shared/src/iosMain/kotlin/ru/fromchat/ProfileLoadErrorMessage.ios.kt rename to app/shared/src/iosMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.ios.kt index 062cc7f..12c3595 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/ProfileLoadErrorMessage.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.ios.kt @@ -1,6 +1,5 @@ -package ru.fromchat +package ru.fromchat.ui.profile actual fun showProfileLoadErrorMessage(message: String) { // iOS currently relies on screen-level error UI for deep-link failures. -} - +} \ No newline at end of file diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/platform/IosViewController.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/utils/IosViewController.kt similarity index 50% rename from app/shared/src/iosMain/kotlin/ru/fromchat/platform/IosViewController.kt rename to app/shared/src/iosMain/kotlin/ru/fromchat/utils/IosViewController.kt index 39256e7..c09e325 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/platform/IosViewController.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/utils/IosViewController.kt @@ -1,6 +1,6 @@ -@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) +@file:OptIn(ExperimentalForeignApi::class) -package ru.fromchat.platform +package ru.fromchat.utils import kotlinx.cinterop.ExperimentalForeignApi import platform.UIKit.UIApplication @@ -10,17 +10,19 @@ import platform.UIKit.UIWindowScene /** Topmost view controller suitable for presenting UIKit sheets. */ fun iosTopViewController(): UIViewController? { - val application = UIApplication.sharedApplication - val window = application.connectedScenes - .mapNotNull { it as? UIWindowScene } - .flatMap { scene -> - scene.windows.mapNotNull { it as? UIWindow } - } - .firstOrNull { it.isKeyWindow() } - ?: application.keyWindow - var controller = window?.rootViewController + var controller = UIApplication.sharedApplication.let { + it.connectedScenes + .filterIsInstance() + .flatMap { scene -> + scene.windows.filterIsInstance() + } + .firstOrNull { it.isKeyWindow() } + ?: it.keyWindow + }?.rootViewController + while (controller?.presentedViewController != null) { controller = controller.presentedViewController } + return controller } diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/net/NetworkConnectivity.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/utils/NetworkConnectivity.ios.kt similarity index 74% rename from app/shared/src/iosMain/kotlin/ru/fromchat/net/NetworkConnectivity.ios.kt rename to app/shared/src/iosMain/kotlin/ru/fromchat/utils/NetworkConnectivity.ios.kt index 6b26087..8533d01 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/net/NetworkConnectivity.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/utils/NetworkConnectivity.ios.kt @@ -1,12 +1,10 @@ -package ru.fromchat.net +package ru.fromchat.utils import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -/** - * iOS: wired later to NWPathMonitor; default [true] avoids false "offline" when APIs are absent. - */ +// TODO iOS: wired later to NWPathMonitor; default [true] avoids false "offline" when APIs are absent. actual object NetworkConnectivity { private val _isOnline = MutableStateFlow(true) actual val isOnline: StateFlow = _isOnline.asStateFlow() diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e708b1c..b1b8ef5 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 8042f94..b52fb7e 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,9 @@ -#Sat Sep 06 13:57:07 MSK 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-rc-1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index d2f528e..b9bb139 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,99 +15,114 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -# 64m is too small for the wrapper client JVM on recent JDKs (VM init / code cache); keep this modest but safe. -DEFAULT_JVM_OPTS='"-Xmx512m" "-Xms128m" "-Xshare:off"' +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar -# If the default JDK crashes on startup (e.g. SIGBUS in CodeHeap::allocate during VM init on some -# macOS + JDK combinations), point Gradle at another JDK 21+ without changing your global JAVA_HOME: -# export GRADLE_JAVA_HOME="/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home" -if [ -n "$GRADLE_JAVA_HOME" ] && [ -x "$GRADLE_JAVA_HOME/bin/java" ]; then - JAVA_HOME="$GRADLE_JAVA_HOME" - export JAVA_HOME -fi - -# macOS: when JAVA_HOME is still unset, use the JBR bundled with Android Studio.app (recommended on Apple Silicon). -if [ "$darwin" = "true" ] && [ -z "${JAVA_HOME:-}" ]; then - _STUDIO_JBR="/Applications/Android Studio.app/Contents/jbr/Contents/Home" - if [ -x "$_STUDIO_JBR/bin/java" ]; then - JAVA_HOME="$_STUDIO_JBR" - export JAVA_HOME - fi - unset _STUDIO_JBR -fi # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -116,88 +131,118 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi -fi - -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi - -# For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi - # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" - fi - i=`expr $i + 1` - done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index 7e01ad8..24c62d5 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,19 +13,22 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -33,24 +36,22 @@ set APP_HOME=%DIRNAME% for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -@rem If the JVM crashes on startup, set GRADLE_JAVA_HOME to Temurin/OpenJDK 21+ (see gradle.properties). -if defined GRADLE_JAVA_HOME if exist "%GRADLE_JAVA_HOME%\bin\java.exe" set "JAVA_HOME=%GRADLE_JAVA_HOME%" -set DEFAULT_JVM_OPTS="-Xmx512m" "-Xms128m" "-Xshare:off" +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -58,34 +59,24 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/utils/shared/build.gradle.kts b/utils/shared/build.gradle.kts index 2906e57..322df89 100644 --- a/utils/shared/build.gradle.kts +++ b/utils/shared/build.gradle.kts @@ -12,7 +12,7 @@ kotlin { android { namespace = "com.pr0gramm3r101.utils" - compileSdk = 36 + compileSdk = 37 minSdk = 24 } @@ -39,6 +39,12 @@ kotlin { implementation(libs.jetbrains.kotlinx.coroutines.core) implementation(libs.navigation.compose) implementation(libs.kotlinx.serialization.json) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.cio) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.client.serialization.kotlinx.json) + implementation(libs.ktor.client.websockets) + implementation(libs.ktor.client.logging) } androidMain.dependencies { @@ -52,10 +58,7 @@ kotlin { } iosMain.dependencies { - implementation(libs.ktor.client.core) implementation(libs.ktor.client.darwin) - implementation(libs.ktor.client.content.negotiation) - implementation(libs.ktor.client.serialization.kotlinx.json) implementation(libs.kotlinx.datetime) implementation(libs.multiplatform.settings) implementation(libs.multiplatform.settings.coroutines) diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/ImeScrollWithKeyboard.android.kt b/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/Compose.android.kt similarity index 85% rename from app/shared/src/androidMain/kotlin/ru/fromchat/ui/ImeScrollWithKeyboard.android.kt rename to utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/Compose.android.kt index bfd7cb2..e090eb1 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/ImeScrollWithKeyboard.android.kt +++ b/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/Compose.android.kt @@ -1,8 +1,8 @@ -package ru.fromchat.ui +package com.pr0gramm3r101.utils import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.imeNestedScroll import androidx.compose.ui.Modifier @OptIn(ExperimentalLayoutApi::class) -actual fun Modifier.imeScrollWithKeyboard(): Modifier = this.imeNestedScroll() +actual fun Modifier.imeScrollWithKeyboard(): Modifier = this.imeNestedScroll() \ No newline at end of file diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/SystemBarsVisibility.android.kt b/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/SystemBarsVisibility.android.kt similarity index 96% rename from app/shared/src/androidMain/kotlin/ru/fromchat/ui/SystemBarsVisibility.android.kt rename to utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/SystemBarsVisibility.android.kt index 767422a..84875e9 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/SystemBarsVisibility.android.kt +++ b/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/SystemBarsVisibility.android.kt @@ -1,4 +1,4 @@ -package ru.fromchat.ui +package com.pr0gramm3r101.utils import android.app.Activity import androidx.compose.runtime.Composable diff --git a/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/Utils.android.kt b/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/Utils.android.kt index ac858ee..54b394d 100644 --- a/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/Utils.android.kt +++ b/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/Utils.android.kt @@ -830,4 +830,29 @@ inline fun String.decodeWindows1251() = String( ) @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S) -actual val materialYouAvailable get() = Build.VERSION.SDK_INT >= 31 \ No newline at end of file +actual val materialYouAvailable get() = Build.VERSION.SDK_INT >= 31 + +private fun String?.trimIfNotBlank(): String? = + this?.trim()?.takeIf { it.isNotEmpty() } + +actual fun currentDeviceInfo(): CurrentDeviceInfo { + val osName = "Android" + val osVersion = Build.VERSION.RELEASE.trimIfNotBlank() ?: Build.VERSION.SDK_INT.toString() + val brand = Build.BRAND.trimIfNotBlank() ?: Build.MANUFACTURER.trimIfNotBlank() + val model = Build.MODEL.trimIfNotBlank() + + return CurrentDeviceInfo( + osName = osName, + osVersion = osVersion, + deviceType = "mobile", + deviceName = model?.let { + if (it.contains( + brand.orEmpty(), + ignoreCase = true + ) + ) it else listOfNotNull(brand, it).joinToString(" ") + }, + brand = brand, + model = model + ) +} diff --git a/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/components/Category.kt b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/components/Category.kt new file mode 100644 index 0000000..62017d7 --- /dev/null +++ b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/components/Category.kt @@ -0,0 +1,198 @@ +@file:Suppress("UnusedReceiverParameter", "NOTHING_TO_INLINE") + +package com.pr0gramm3r101.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults.cardColors +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +object CategoryDefaults { + val margin = PaddingValues(start = 16.dp, end = 16.dp, bottom = 20.dp) + val containerColor @Composable get() = MaterialTheme.colorScheme.surfaceContainer + val dividerThickness: Dp = 3.dp + val shape @Composable get() = MaterialTheme.shapes.extraLarge +} + +@Composable +private fun CategoryBase( + modifier: Modifier = Modifier, + title: String? = null, + margin: PaddingValues = CategoryDefaults.margin, + containerColor: Color = CategoryDefaults.containerColor, + backgroundColor: Color = Color.Transparent, + content: @Composable ColumnScope.() -> Unit +) { + CompositionLocalProvider( + LocalDividerColor provides Color.Transparent, + LocalDividerThickness provides CategoryDefaults.dividerThickness, + LocalContainerColor provides containerColor + ) { + if (title != null) { + Text( + text = title, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding( + bottom = 10.dp, + start = margin.calculateStartPadding( + LocalLayoutDirection.current + ) + 16.dp, + end = margin.calculateEndPadding( + LocalLayoutDirection.current, + ) + 16.dp + ) + ) + } + + Card( + modifier = modifier + .padding(margin) + .fillMaxWidth(), + shape = CategoryDefaults.shape, + colors = cardColors(containerColor = backgroundColor), + content = content + ) + } +} + +@Composable +fun LazyItemScope.Category( + modifier: Modifier = Modifier, + title: String? = null, + margin: PaddingValues = CategoryDefaults.margin, + containerColor: Color = CategoryDefaults.containerColor, + backgroundColor: Color = Color.Transparent, + content: @Composable ColumnScope.() -> Unit +) { + CategoryBase( + modifier = modifier, + title = title, + margin = margin, + containerColor = containerColor, + backgroundColor = backgroundColor, + content = content + ) +} + +@Composable +fun ColumnScope.Category( + modifier: Modifier = Modifier, + title: String? = null, + margin: PaddingValues = CategoryDefaults.margin, + containerColor: Color = CategoryDefaults.containerColor, + backgroundColor: Color = Color.Transparent, + content: @Composable ColumnScope.() -> Unit +) { + CategoryBase( + modifier = modifier, + title = title, + margin = margin, + containerColor = containerColor, + backgroundColor = backgroundColor, + content = content + ) +} + +class CategoryScope { + internal val items = mutableListOf<@Composable () -> Unit>() + + fun item(content: @Composable () -> Unit) { + items.add(content) + } +} + +fun LazyListScope.Category( + modifier: Modifier = Modifier, + title: String? = null, + margin: PaddingValues = CategoryDefaults.margin, + containerColor: Color? = null, + content: CategoryScope.() -> Unit +) { + val scope = CategoryScope().apply(content) + val items = scope.items + + item { + Spacer(Modifier.height(margin.calculateTopPadding())) + } + + if (title != null) { + item { + Text( + text = title, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding( + bottom = 10.dp, + start = margin.calculateStartPadding(LocalLayoutDirection.current) + 16.dp, + end = margin.calculateEndPadding(LocalLayoutDirection.current) + 16.dp + ) + ) + } + } + + items.forEachIndexed { index, composableItem -> + item { + CompositionLocalProvider( + LocalDividerColor provides Color.Transparent, + LocalDividerThickness provides CategoryDefaults.dividerThickness, + LocalContainerColor provides (containerColor ?: CategoryDefaults.containerColor) + ) { + Box( + modifier = modifier + .padding( + start = margin.calculateStartPadding(LocalLayoutDirection.current), + end = margin.calculateEndPadding(LocalLayoutDirection.current) + ) + .clip( + RoundedCornerShape( + topStart = + if (index == 0) + CategoryDefaults.shape.topStart + else CornerSize(0.dp), + topEnd = + if (index == 0) + CategoryDefaults.shape.topEnd + else CornerSize(0.dp), + bottomStart = + if (index == items.lastIndex) + CategoryDefaults.shape.bottomStart + else CornerSize(0.dp), + bottomEnd = + if (index == items.lastIndex) + CategoryDefaults.shape.bottomEnd + else CornerSize(0.dp) + ) + ) + ) { + composableItem() + } + } + } + } + + item { + Spacer(Modifier.height(margin.calculateBottomPadding())) + } +} \ No newline at end of file diff --git a/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/components/Components.kt b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/components/Components.kt index 381f71e..73b24c1 100644 --- a/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/components/Components.kt +++ b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/components/Components.kt @@ -52,7 +52,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonColors import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.ListItem -import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProvideTextStyle @@ -68,6 +67,7 @@ import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.MutableState +import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -77,12 +77,14 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.pr0gramm3r101.utils.conditional import com.pr0gramm3r101.utils.invoke import com.pr0gramm3r101.utils.left import com.pr0gramm3r101.utils.link @@ -95,6 +97,18 @@ import tech.annexflow.constraintlayout.compose.ConstraintLayoutScope import tech.annexflow.constraintlayout.compose.Dimension import kotlin.random.Random +val LocalDividerColor = compositionLocalOf { null } +val LocalDividerThickness = compositionLocalOf { null } +val LocalBeforeDividerRadius = compositionLocalOf { null } +val LocalContainerColor = compositionLocalOf { null } + +object ListItemDefaults { + val dividerColor @Composable get() = DividerDefaults.color + val thickness = DividerDefaults.Thickness + val beforeDividerRadius = 8.dp + val containerColor = Color.Transparent +} + @Composable fun ListItem( modifier: Modifier = Modifier, @@ -103,11 +117,13 @@ fun ListItem( supportingText: String? = null, leadingContent: (@Composable () -> Unit)? = null, trailingContent: (@Composable ConstraintLayoutScope.() -> Unit)? = null, + containerColor: Color = LocalContainerColor.current ?: ListItemDefaults.containerColor, enabled: Boolean = true, divider: Boolean = false, - dividerColor: Color = DividerDefaults.color, - dividerThickness: Dp = DividerDefaults.Thickness, + dividerColor: Color = LocalDividerColor.current ?: ListItemDefaults.dividerColor, + dividerThickness: Dp = LocalDividerThickness.current ?: ListItemDefaults.thickness, dividerAnimated: Boolean = false, + beforeDividerRadius: Dp = LocalBeforeDividerRadius.current ?: ListItemDefaults.beforeDividerRadius, onClick: (() -> Unit)? = null, bodyOnClick: (() -> Unit)? = null, leadingAndBodyShared: Boolean = false, @@ -134,13 +150,14 @@ fun ListItem( ProvideStyle { ConstraintLayout( - modifier = Modifier.let { - var mod = it.fillMaxWidth() - if (onClick != null) { - mod += Modifier.clickable(onClick = onClick) + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(beforeDividerRadius)) + .background(containerColor) + .conditional(onClick != null) { + clickable(onClick = onClick!!) } - mod + modifier - } + .then(modifier) ) { val (leading, listItem, trailing, btm) = createRefs() @@ -167,31 +184,30 @@ fun ListItem( leadingContent() } } + ListItem( headlineContent = { Text(headline) }, supportingContent = { if (supportingText != null) Text(supportingText) }, modifier = Modifier - .let { - var mod = it.constrainAs(listItem) { - top link parent.top - bottom link - if (bottomContent != null) btm.top - else parent.bottom + .constrainAs(listItem) { + top link parent.top + bottom link + if (bottomContent != null) btm.top + else parent.bottom + left link + if (leadingContent != null) leading.right + else parent.left + right link + if (trailingContent != null) trailing.left + else parent.right - left link - if (leadingContent != null) leading.right - else parent.left - right link - if (trailingContent != null) trailing.left - else parent.right - width = Dimension.fillToConstraints - } - if (bodyOnClick != null && !leadingAndBodyShared) { - mod += Modifier.clickable(onClick = bodyOnClick) - } - mod + bodyModifier - }, - colors = ListItemDefaults.colors( + width = Dimension.fillToConstraints + } + .conditional(bodyOnClick != null && !leadingAndBodyShared) { + Modifier.clickable(onClick = bodyOnClick!!) + } + .then(bodyModifier), + colors = androidx.compose.material3.ListItemDefaults.colors( containerColor = Color.Transparent ) ) @@ -203,19 +219,17 @@ fun ListItem( .constrainAs(listItem) { top link parent.top bottom link - if (bottomContent != null) btm.top - else parent.bottom + if (bottomContent != null) btm.top + else parent.bottom left link parent.left right link - if (trailingContent != null) trailing.left - else parent.right + if (trailingContent != null) trailing.left + else parent.right width = Dimension.fillToConstraints } - + - if (bodyOnClick != null) - Modifier.clickable(onClick = bodyOnClick) - else - Modifier, + .conditional(bodyOnClick != null) { + Modifier.clickable(onClick = bodyOnClick!!) + }, verticalAlignment = Alignment.CenterVertically ) { leadingBody() @@ -243,6 +257,7 @@ fun ListItem( content = trailingContent ) } + if (bottomContent != null) { Box( modifier = Modifier @@ -259,22 +274,26 @@ fun ListItem( } } } - if (dividerAnimated) { - AnimatedVisibility( - visible = divider, - enter = expandVertically(), - exit = shrinkVertically() - ) { + + when { + dividerAnimated && divider -> { + AnimatedVisibility( + visible = divider, + enter = expandVertically(), + exit = shrinkVertically() + ) { + HorizontalDivider( + color = dividerColor, + thickness = dividerThickness + ) + } + } + divider -> { HorizontalDivider( color = dividerColor, thickness = dividerThickness ) } - } else if (divider) { - HorizontalDivider( - color = dividerColor, - thickness = dividerThickness - ) } } } @@ -289,8 +308,8 @@ inline fun SwitchListItem( noinline onCheckedChange: (Boolean) -> Unit, crossinline listItemOnClick: () -> Unit = { onCheckedChange(!checked) }, divider: Boolean = false, - dividerColor: Color = DividerDefaults.color, - dividerThickness: Dp = DividerDefaults.Thickness, + dividerColor: Color = LocalDividerColor.current ?: DividerDefaults.color, + dividerThickness: Dp = LocalDividerThickness.current ?: DividerDefaults.Thickness, dividerAnimated: Boolean = false, enabled: Boolean = true ) { @@ -337,8 +356,8 @@ inline fun SeparatedSwitchListItem( noinline onCheckedChange: (Boolean) -> Unit, noinline bodyOnClick: () -> Unit, divider: Boolean = false, - dividerColor: Color = DividerDefaults.color, - dividerThickness: Dp = DividerDefaults.Thickness, + dividerColor: Color = LocalDividerColor.current ?: DividerDefaults.color, + dividerThickness: Dp = LocalDividerThickness.current ?: DividerDefaults.Thickness, dividerAnimated: Boolean = false, enabled: Boolean = true ) { @@ -1062,34 +1081,3 @@ inline fun SwipeToDismissBackground( } } -object CategoryDefaults { - val margin = PaddingValues(start = 16.dp, end = 16.dp, bottom = 20.dp) - val dividerColor @Composable get() = MaterialTheme.colorScheme.surface - val dividerThickness: Dp = 2.dp -} - -@Composable -inline fun ColumnScope.Category( - modifier: Modifier = Modifier, - title: String? = null, - margin: PaddingValues = CategoryDefaults.margin, - containerColor: Color = MaterialTheme.colorScheme.surfaceContainer, - noinline content: @Composable ColumnScope.() -> Unit -) { - if (title != null) { - Text( - text = title, - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(bottom = 10.dp, start = 32.dp, end = 32.dp) - ) - } - Card( - modifier = modifier - .padding(margin) - .fillMaxWidth(), - shape = MaterialTheme.shapes.extraLarge, - colors = cardColors(containerColor = containerColor), - content = content - ) -} \ No newline at end of file diff --git a/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/Compose.kt b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/Compose.kt index eacf860..d8437ad 100644 --- a/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/Compose.kt +++ b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/Compose.kt @@ -1,9 +1,41 @@ +@file:Suppress("NOTHING_TO_INLINE") + package com.pr0gramm3r101.utils +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.Indication +import androidx.compose.foundation.OverscrollEffect +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.exclude +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.composed +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp import androidx.navigation.NavController +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch /** * Navigates to the specified route and clears the entire back stack. @@ -44,4 +76,107 @@ inline fun Modifier.conditional( } else { this + `else`(this) } -} \ No newline at end of file +} + +inline fun Dp.toPx(density: Density) = with(density) { this@toPx.toPx() } + +inline fun Modifier.verticalScroll( + enabled: Boolean = true, + flingBehavior: FlingBehavior? = null, + reverseScrolling: Boolean = false +) = composed { + this@verticalScroll.verticalScroll( + rememberScrollState(), + enabled, + flingBehavior, + reverseScrolling + ) +} + +inline fun Modifier.verticalScroll( + overscrollEffect: OverscrollEffect?, + enabled: Boolean = true, + flingBehavior: FlingBehavior? = null, + reverseScrolling: Boolean = false +) = composed { + this@verticalScroll.verticalScroll( + rememberScrollState(), + overscrollEffect, + enabled, + flingBehavior, + reverseScrolling + ) +} + +inline fun Int.toDp(density: Density) = with(density) { this@toDp.toDp() } + +@Suppress("NOTHING_TO_INLINE") +inline fun WindowInsets.exclude(sides: WindowInsetsSides) = exclude(this.only(sides)) + +/** + * Scale-down touch effect: scales to [scale] on press and back to 1f on release/cancel. + * When [interactionSource] is null: optionally wraps with [clickable] when [onClick] is non-null. + * When [interactionSource] is non-null: uses it for scale only (caller adds clickable on child so ripple scales with content). + * [indication] when non-null is used for the clickable (e.g. ripple); null = no indication. + * [clipShape] when non-null clips the scaled result. + * [animationSpec] drives the scale transition (default is a spring). + */ +fun Modifier.scaleOnPress( + scale: Float = 0.96f, + onClick: (() -> Unit)? = null, + indication: Indication? = null, + clipShape: Shape? = null, + interactionSource: MutableInteractionSource? = null, + animationSpec: AnimationSpec = spring() +): Modifier = composed { + val source = interactionSource ?: remember { MutableInteractionSource() } + var pressed by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + LaunchedEffect(source) { + var releaseJob: Job? = null + source.interactions.collect { interaction -> + when (interaction) { + is PressInteraction.Press -> { + releaseJob?.cancel() + releaseJob = null + pressed = true + } + + is PressInteraction.Release, is PressInteraction.Cancel -> { + releaseJob = scope.launch { + delay(80) + pressed = false + } + } + } + } + } + + val scaleValue by animateFloatAsState( + targetValue = if (pressed) scale else 1f, + animationSpec = animationSpec, + label = "scaleOnPress" + ) + + Modifier + .conditional(onClick != null && interactionSource == null) { + clickable( + interactionSource = source, + indication = indication, + onClick = onClick!! + ) + } + .conditional(clipShape != null) { clip(clipShape!!) } + .graphicsLayer( + scaleX = scaleValue, + scaleY = scaleValue, + transformOrigin = TransformOrigin.Center + ) +} + +/** + * Android: ties scroll to IME insets for smoother keyboard transitions (see Compose keyboard animations). + * Other platforms: no-op. + */ +expect fun Modifier.imeScrollWithKeyboard(): Modifier \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/utils/Ktor.kt b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/Ktor.kt similarity index 70% rename from app/shared/src/commonMain/kotlin/ru/fromchat/utils/Ktor.kt rename to utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/Ktor.kt index 3aef0f5..a78313d 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/utils/Ktor.kt +++ b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/Ktor.kt @@ -1,5 +1,6 @@ @file:Suppress("NOTHING_TO_INLINE") -package ru.fromchat.utils + +package com.pr0gramm3r101.utils import io.ktor.client.HttpClientConfig import io.ktor.client.plugins.ClientRequestException @@ -33,7 +34,7 @@ internal inline fun Configuration.json(crossinline settings: JsonBuilder.() -> U } /** - * Installs [ContentNegotiation] with JSON support and custom settings in a Ktor client config. + * Installs [io.ktor.client.plugins.contentnegotiation.ContentNegotiation] with JSON support and custom settings in a Ktor client config. * @param settings Lambda to configure the [JsonBuilder]. */ inline fun HttpClientConfig<*>.jsonConfig( @@ -45,8 +46,8 @@ inline fun HttpClientConfig<*>.jsonConfig( } /** - * Installs [DefaultRequest] in a Ktor client config. - * @param settings Lambda to configure the [DefaultRequest.DefaultRequestBuilder]. + * Installs [io.ktor.client.plugins.DefaultRequest] in a Ktor client config. + * @param settings Lambda to configure the [io.ktor.client.plugins.DefaultRequest.DefaultRequestBuilder]. */ inline fun HttpClientConfig<*>.defaultRequest( crossinline settings: DefaultRequest.DefaultRequestBuilder.() -> Unit @@ -55,8 +56,8 @@ inline fun HttpClientConfig<*>.defaultRequest( } /** - * Installs [Logging] in a Ktor client config. - * @param settings Lambda to configure the [LoggingConfig]. + * Installs [io.ktor.client.plugins.logging.Logging] in a Ktor client config. + * @param settings Lambda to configure the [io.ktor.client.plugins.logging.LoggingConfig]. */ inline fun HttpClientConfig<*>.logging( crossinline settings: LoggingConfig.() -> Unit @@ -65,7 +66,7 @@ inline fun HttpClientConfig<*>.logging( } /** - * Installs [Logging] in a Ktor client config with default settings (all logs). + * Installs [io.ktor.client.plugins.logging.Logging] in a Ktor client config with default settings (all logs). */ inline fun HttpClientConfig<*>.logging() = logging { logger = Logger.SIMPLE @@ -74,10 +75,10 @@ inline fun HttpClientConfig<*>.logging() = logging { /** * Throws if the HTTP response status code is an error (>=400). - * @return The [HttpResponse] if successful. - * @throws ClientRequestException on 4xx error code. - * @throws ServerResponseException on 5xx error code. - * @throws ResponseException on unknown error code. + * @return The [io.ktor.client.statement.HttpResponse] if successful. + * @throws io.ktor.client.plugins.ClientRequestException on 4xx error code. + * @throws io.ktor.client.plugins.ServerResponseException on 5xx error code. + * @throws io.ktor.client.plugins.ResponseException on unknown error code. */ suspend fun HttpResponse.failOnError() = when (status.value) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/SystemBarsVisibility.kt b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/SystemBarsVisibility.kt similarity index 88% rename from app/shared/src/commonMain/kotlin/ru/fromchat/ui/SystemBarsVisibility.kt rename to utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/SystemBarsVisibility.kt index 3761466..bb2ba65 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/SystemBarsVisibility.kt +++ b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/SystemBarsVisibility.kt @@ -1,4 +1,4 @@ -package ru.fromchat.ui +package com.pr0gramm3r101.utils import androidx.compose.runtime.Composable import androidx.compose.runtime.compositionLocalOf diff --git a/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/Utils.kt b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/Utils.kt index e218dee..32dc7d2 100644 --- a/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/Utils.kt +++ b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/Utils.kt @@ -167,4 +167,15 @@ expect val materialYouAvailable: Boolean inline fun T.require( message: String, condition: (T) -> Boolean): T { require(condition(this)) { message } return this -} \ No newline at end of file +} + +data class CurrentDeviceInfo( + val osName: String? = null, + val osVersion: String? = null, + val deviceType: String? = null, + val deviceName: String? = null, + val brand: String? = null, + val model: String? = null +) + +expect fun currentDeviceInfo(): CurrentDeviceInfo diff --git a/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/Compose.ios.kt b/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/Compose.ios.kt new file mode 100644 index 0000000..ec96d9e --- /dev/null +++ b/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/Compose.ios.kt @@ -0,0 +1,5 @@ +package com.pr0gramm3r101.utils + +import androidx.compose.ui.Modifier + +actual fun Modifier.imeScrollWithKeyboard(): Modifier = this \ No newline at end of file diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/platform/IosHmac.kt b/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/IosHmac.kt similarity index 90% rename from app/shared/src/iosMain/kotlin/ru/fromchat/platform/IosHmac.kt rename to utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/IosHmac.kt index 6e436ec..f22dfef 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/platform/IosHmac.kt +++ b/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/IosHmac.kt @@ -1,10 +1,8 @@ -package ru.fromchat.platform +package com.pr0gramm3r101.utils import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.UByteVar import kotlinx.cinterop.addressOf import kotlinx.cinterop.convert -import kotlinx.cinterop.refTo import kotlinx.cinterop.usePinned import platform.CoreCrypto.CCHmac import platform.CoreCrypto.kCCHmacAlgSHA256 @@ -12,6 +10,7 @@ import platform.CoreCrypto.kCCHmacAlgSHA256 @OptIn(ExperimentalForeignApi::class) internal fun iosHmacSha256(key: ByteArray, data: ByteArray): ByteArray { val mac = ByteArray(32) + key.usePinned { keyPinned -> data.usePinned { dataPinned -> mac.usePinned { macPinned -> @@ -26,5 +25,6 @@ internal fun iosHmacSha256(key: ByteArray, data: ByteArray): ByteArray { } } } + return mac -} +} \ No newline at end of file diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/platform/IosPosixFiles.kt b/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/IosPosixFiles.kt similarity index 89% rename from app/shared/src/iosMain/kotlin/ru/fromchat/platform/IosPosixFiles.kt rename to utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/IosPosixFiles.kt index e293462..7113808 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/platform/IosPosixFiles.kt +++ b/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/IosPosixFiles.kt @@ -1,4 +1,4 @@ -package ru.fromchat.platform +package com.pr0gramm3r101.utils import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.addressOf @@ -14,14 +14,14 @@ import platform.posix.fseek import platform.posix.fwrite @OptIn(ExperimentalForeignApi::class) -internal fun iosFileSize(path: String): Long { +fun iosFileSize(path: String): Long { if (!NSFileManager.defaultManager.fileExistsAtPath(path)) return 0L val attrs = NSFileManager.defaultManager.attributesOfItemAtPath(path, null) ?: return 0L return (attrs["NSFileSize"] as? NSNumber)?.longValue ?: 0L } @OptIn(ExperimentalForeignApi::class) -internal fun iosReadFileRange(path: String, offset: Long, length: Int): ByteArray { +fun iosReadFileRange(path: String, offset: Long, length: Int): ByteArray { if (length <= 0) return ByteArray(0) val file = fopen(path, "rb") ?: error("Failed to open file") try { @@ -40,7 +40,7 @@ internal fun iosReadFileRange(path: String, offset: Long, length: Int): ByteArra } @OptIn(ExperimentalForeignApi::class) -internal fun iosAppendFile(path: String, bytes: ByteArray) { +fun iosAppendFile(path: String, bytes: ByteArray) { if (bytes.isEmpty()) return val file = fopen(path, "ab") ?: error("Failed to open file for append") try { @@ -56,7 +56,7 @@ internal fun iosAppendFile(path: String, bytes: ByteArray) { } @OptIn(ExperimentalForeignApi::class) -internal fun iosCopyFile(sourcePath: String, destinationPath: String) { +fun iosCopyFile(sourcePath: String, destinationPath: String) { val input = fopen(sourcePath, "rb") ?: error("Failed to open source file") val output = fopen(destinationPath, "wb") ?: run { fclose(input) @@ -83,7 +83,7 @@ internal fun iosCopyFile(sourcePath: String, destinationPath: String) { } @OptIn(ExperimentalForeignApi::class) -internal class IosPosixFileReader( +class IosPosixFileReader( private val path: String, ) { private val file = fopen(path, "rb") ?: error("Failed to open file") diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/SystemBarsVisibility.ios.kt b/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/SystemBarsVisibility.ios.kt similarity index 58% rename from app/shared/src/iosMain/kotlin/ru/fromchat/ui/SystemBarsVisibility.ios.kt rename to utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/SystemBarsVisibility.ios.kt index 10ea514..ec68626 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/SystemBarsVisibility.ios.kt +++ b/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/SystemBarsVisibility.ios.kt @@ -1,11 +1,12 @@ -package ru.fromchat.ui +package com.pr0gramm3r101.utils import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +// TODO iOS 13+ uses scene-based API; status bar control requires native Swift/ObjC bridge @Composable actual fun rememberSystemBarsController(): ((Boolean) -> Unit)? { return remember { - { _ -> /* iOS 13+ uses scene-based API; status bar control requires native Swift/ObjC bridge */ } + { _ -> } } } diff --git a/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/Utils.ios.kt b/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/Utils.ios.kt index 746dbf3..b9b984a 100644 --- a/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/Utils.ios.kt +++ b/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/Utils.ios.kt @@ -5,6 +5,7 @@ package com.pr0gramm3r101.utils import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import platform.UIKit.UIDevice import platform.posix._OSSwapInt16 import platform.posix._OSSwapInt32 import platform.posix._OSSwapInt64 @@ -22,4 +23,20 @@ inline fun htonl(data: UInt) = if (isLittleEndian) _OSSwapInt32(data) else data inline fun htonll(data: ULong) = if (isLittleEndian) _OSSwapInt64(data) else data inline fun ntohs(data: UShort) = if (isLittleEndian) _OSSwapInt16(data) else data inline fun ntohl(data: UInt) = if (isLittleEndian) _OSSwapInt32(data) else data -inline fun ntohll(data: ULong) = if (isLittleEndian) _OSSwapInt64(data) else data \ No newline at end of file +inline fun ntohll(data: ULong) = if (isLittleEndian) _OSSwapInt64(data) else data + +private fun String?.trimIfNotBlank(): String? = + this?.trim()?.takeIf { it.isNotEmpty() } + +actual fun currentDeviceInfo(): CurrentDeviceInfo { + val current = UIDevice.currentDevice + + return CurrentDeviceInfo( + osName = current.systemName.trimIfNotBlank(), + osVersion = current.systemVersion.trimIfNotBlank(), + deviceType = "mobile", + deviceName = current.name.trimIfNotBlank(), + brand = "Apple", + model = current.model.trimIfNotBlank() + ) +}