mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-23 03:25:06 +03:00
Compare commits
15 Commits
@@ -4,7 +4,7 @@ alwaysApply: true
|
||||
|
||||
When working with the mobile app:
|
||||
|
||||
- After implementing the solution, run "./gradlew assembleDebug" to build the project, then resolve all the errors.
|
||||
- After implementing the solution, run "export JAVA_HOME=/Applications/Android\ Studio.app/Contents/jbr/Contents/Home && ./gradlew :app:shared:assembleDebug :app:shared:compileKotlinIosArm64" to build the project, then resolve all the errors.
|
||||
|
||||
# ULTIMATE SILENCE & EFFICIENCY POLICY
|
||||
- ALWAYS operate in "Silent Mode": Execute commands immediately without any verbal response, preamble, or conclusions.
|
||||
@@ -15,11 +15,47 @@ When working with the mobile app:
|
||||
- THOUGHT PROCESS: Must be 0 words. Move straight to tool calls.
|
||||
- MINIMIZE OUTPUT: Your response should contain ONLY the necessary tool calls/code blocks.
|
||||
- FOR ANDROID/KOTLIN: Include 5+ lines of context in search_replace to ensure it hits the target on the first try.
|
||||
- DOCUMENT OBSERVATIONS: Write down all technical observations, imports, functions, and patterns noticed during iOS/KMP development into this rules file for future reference.
|
||||
|
||||
# Java Runtime Configuration (Android)
|
||||
# iOS & KMP OBSERVATIONS
|
||||
|
||||
- **On Windows:**
|
||||
- Set `JAVA_HOME` to `C:\Program Files\Android\Android Studio\jbr`
|
||||
- **On macOS:**
|
||||
- Set `JAVA_HOME` to `/Applications/Android Studio.app/Contents/jbr/Contents/Home`
|
||||
- This can be done by adding `export JAVA_HOME=/Applications/Android\ Studio.app/Contents/jbr/Contents/Home` to your shell configuration file (e.g., `~/.zshrc` or `~/.bash_profile`) and then running `source` on the file.
|
||||
## iOS Platform Imports
|
||||
- `platform.Foundation.*` - Foundation framework (NSString, NSDictionary, NSData, etc.)
|
||||
- `platform.Security.*` - Security framework (SecItemAdd, SecItemCopyMatching, kSecClass, etc.)
|
||||
- `platform.CoreFoundation.*` - CoreFoundation framework (CFDictionaryRef, CFTypeRef, etc.)
|
||||
- `kotlinx.cinterop.*` - C interop utilities (memScoped, alloc, ptr, value, etc.)
|
||||
- `kotlinx.coroutines.*` - Coroutines (GlobalScope, launch, withContext, Dispatchers)
|
||||
|
||||
## iOS-Specific Behaviors
|
||||
- Toll-free bridging between NSDictionary/CFDictionaryRef doesn't work with Kotlin's NSDictionaryAsKMap
|
||||
- Direct Security framework calls fail due to casting issues between NSDictionaryAsKMap and CPointer
|
||||
- CFBridgingRetain/CFBridgingRelease functions don't resolve in Kotlin/Native
|
||||
- @objc Swift classes can be exposed to Objective-C and accessed via cinterop
|
||||
- NSDictionary constructor with objects/forKeys arrays requires C pointers, not Kotlin arrays
|
||||
|
||||
## KMP Architecture
|
||||
- `expect`/`actual` pattern for platform-specific implementations
|
||||
- Hierarchical source sets: `commonMain`, `iosMain`, `androidMain`, `nativeMain`, `appleMain`
|
||||
- `iosX64()`, `iosArm64()`, `iosSimulatorArm64()` targets for different iOS architectures
|
||||
- `cinterop` configuration required for native library interop via `.def` files
|
||||
- `kotlin.mpp.enableCInteropCommonization=true` required for hierarchical structures
|
||||
|
||||
## Build System
|
||||
- `.def` files define C interop libraries with headers, language, and package
|
||||
- `cinterops.create("name")` or `val name by cinterops.creating` for cinterop setup
|
||||
- `definitionFile.set(file("path"))` to specify .def file location
|
||||
- Different HTTP clients: `io.ktor.client.engine.darwin.Darwin` for iOS, `okhttp` for Android
|
||||
|
||||
## Runtime Patterns
|
||||
- `@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)` for experimental C interop
|
||||
- `@DelicateCoroutinesApi` annotation for GlobalScope.launch
|
||||
- `memScoped { }` for memory-safe C interop operations
|
||||
- `GlobalScope.launch { }` for fire-and-forget background operations on iOS
|
||||
- Platform-specific logging with `ru.fromchat.core.Logger`
|
||||
|
||||
## C Interop Gotchas
|
||||
- NSDictionaryAsKMap (Kotlin's internal NSDictionary wrapper) ≠ NSDictionary (Foundation object)
|
||||
- Security framework expects strict CFDictionaryRef types, not NSDictionary toll-free bridging
|
||||
- C interop paths must be relative to the .def file location or use compilerOpts
|
||||
- Complex Objective-C frameworks like Security are unreliable with direct Kotlin interop
|
||||
- Use Swift → Objective-C → Kotlin cinterop chain for complex native operations
|
||||
@@ -0,0 +1,11 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "gradle" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -17,21 +17,22 @@ kotlin {
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Описываем задачу копирования с созданием правильной структуры
|
||||
val fixComposeResourcesStructure = tasks.register<Copy>("fixComposeResourcesStructure") {
|
||||
val sharedProject = rootProject.project(":app:shared")
|
||||
|
||||
// Откуда берем (исходные сгенерированные файлы)
|
||||
val sourceDir = sharedProject.layout.buildDirectory
|
||||
from(
|
||||
sharedProject
|
||||
.layout
|
||||
.buildDirectory
|
||||
.dir("generated/compose/resourceGenerator/preparedResources/commonMain/composeResources")
|
||||
)
|
||||
|
||||
// Куда кладем (создаем промежуточную папку с ПРАВИЛЬНЫМ путем)
|
||||
val outputDir = layout.buildDirectory.dir("intermediates/fixed_compose_res/composeResources/ru.fromchat")
|
||||
into(
|
||||
layout
|
||||
.buildDirectory
|
||||
.dir("intermediates/fixed_compose_res/composeResources/ru.fromchat")
|
||||
)
|
||||
|
||||
from(sourceDir)
|
||||
into(outputDir)
|
||||
|
||||
// Убеждаемся, что генерация в shared уже прошла
|
||||
dependsOn(sharedProject.tasks.matching { it.name.contains("prepareComposeResources", ignoreCase = true) })
|
||||
dependsOn(sharedProject.tasks.matching { it.name.contains("copyNonXmlValueResources", ignoreCase = true) })
|
||||
}
|
||||
@@ -55,7 +56,7 @@ android {
|
||||
}
|
||||
|
||||
storeFile = file("keys/release.jks")
|
||||
keyAlias = "release"
|
||||
keyAlias = "key0"
|
||||
storePassword = keystoreProperties["storePassword"].toString()
|
||||
keyPassword = keystoreProperties["keyPassword"].toString()
|
||||
enableV3Signing = true
|
||||
@@ -97,6 +98,10 @@ tasks.withType<MergeSourceSetFolders>().configureEach {
|
||||
dependsOn(fixComposeResourcesStructure)
|
||||
}
|
||||
|
||||
tasks.matching { it.name.contains("lintVital", ignoreCase = true) }.configureEach {
|
||||
dependsOn(fixComposeResourcesStructure)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
|
||||
BIN
Binary file not shown.
-5
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict />
|
||||
</plist>
|
||||
@@ -17,11 +17,16 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<string>0.2.5</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<string>025</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
@@ -46,10 +51,5 @@
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -6,7 +6,7 @@ plugins {
|
||||
alias(libs.plugins.compose.multiplatform)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.android.kotlin.multiplatform.library)
|
||||
kotlin("plugin.serialization") version "1.9.22"
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
|
||||
@@ -67,4 +67,6 @@
|
||||
<string name="typing_single">%1$s is typing…</string>
|
||||
<string name="typing_two">%1$s and %2$s are typing…</string>
|
||||
<string name="typing_many">%1$s, %2$s and %3$d more are typing…</string>
|
||||
|
||||
<string name="more">More</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
package ru.fromchat
|
||||
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.format.char
|
||||
|
||||
val DATETIME_FORMAT = LocalDateTime.Format {
|
||||
day()
|
||||
char('.')
|
||||
monthNumber()
|
||||
char('.')
|
||||
year()
|
||||
|
||||
char(' ')
|
||||
|
||||
hour()
|
||||
char(':')
|
||||
minute()
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
package ru.fromchat.api
|
||||
|
||||
import com.pr0gramm3r101.utils.settings.secureSettings
|
||||
import com.pr0gramm3r101.utils.settings.settings
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.plugins.HttpResponseValidator
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.plugins.defaultRequest
|
||||
import io.ktor.client.plugins.logging.LogLevel
|
||||
import io.ktor.client.plugins.logging.Logger
|
||||
import io.ktor.client.plugins.logging.Logging
|
||||
@@ -20,7 +24,6 @@ import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
import ru.fromchat.core.config.Config
|
||||
import ru.fromchat.utils.failOnError
|
||||
import kotlin.concurrent.Volatile
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@@ -52,6 +55,36 @@ object ApiClient {
|
||||
install(WebSockets) {
|
||||
pingInterval = 5000.milliseconds // Send a ping every 5 seconds to keep the connection alive
|
||||
}
|
||||
|
||||
// Set default auth header for all requests
|
||||
defaultRequest {
|
||||
token?.let { authToken ->
|
||||
bearerAuth(authToken)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle HTTP errors and auth errors globally
|
||||
HttpResponseValidator {
|
||||
validateResponse { response ->
|
||||
// Handle auth errors
|
||||
if (response.status.value == 401 || response.status.value == 403) {
|
||||
// Clear invalid token and notify about auth error
|
||||
token = null
|
||||
user = null
|
||||
onAuthError?.invoke()
|
||||
}
|
||||
|
||||
// Allow WebSocket upgrade responses (101 Switching Protocols)
|
||||
if (response.status.value == 101) {
|
||||
return@validateResponse
|
||||
}
|
||||
|
||||
// Throw exception for non-2xx status codes (like failOnError())
|
||||
if (response.status.value !in 200..299) {
|
||||
throw io.ktor.client.plugins.ClientRequestException(response, response.status.description)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile
|
||||
@@ -60,17 +93,38 @@ object ApiClient {
|
||||
@Volatile
|
||||
var user: User? = null
|
||||
|
||||
// Global auth error handler
|
||||
var onAuthError: (() -> Unit)? = null
|
||||
|
||||
// Load persisted token and user info
|
||||
suspend fun loadPersistedData() {
|
||||
try {
|
||||
val savedToken = secureSettings.getString("auth_token", "")
|
||||
token = savedToken
|
||||
if (!token.isNullOrEmpty()) {
|
||||
val userInfo = settings.getString("user_info", "")
|
||||
if (userInfo.isNotEmpty()) {
|
||||
user = json.decodeFromString(userInfo)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ru.fromchat.core.Logger.e("ApiClient", "Error loading persisted data", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
suspend fun login(request: LoginRequest) =
|
||||
http
|
||||
.post("${Config.apiBaseUrl}/login") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(request.also { ru.fromchat.core.Logger.d("ApiClient", "Login request: $it") })
|
||||
setBody(request)
|
||||
}
|
||||
.failOnError()
|
||||
.body<LoginResponse>()
|
||||
.also {
|
||||
token = it.token
|
||||
user = it.user
|
||||
secureSettings.putString("auth_token", it.token)
|
||||
settings.putString("user_info", json.encodeToString(it.user))
|
||||
}
|
||||
|
||||
suspend fun register(request: RegisterRequest) =
|
||||
@@ -79,39 +133,55 @@ object ApiClient {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(request)
|
||||
}
|
||||
.failOnError()
|
||||
|
||||
suspend fun getMessages(limit: Int = 50, beforeId: Int? = null) =
|
||||
http
|
||||
.get("${Config.apiBaseUrl}/get_messages") {
|
||||
contentType(ContentType.Application.Json)
|
||||
bearerAuth(token ?: throw IllegalStateException("Not authenticated"))
|
||||
parameter("limit", limit)
|
||||
beforeId?.let { parameter("before_id", it) }
|
||||
}
|
||||
.failOnError()
|
||||
.body<MessagesResponse>()
|
||||
|
||||
suspend fun send(message: String) =
|
||||
http
|
||||
.post("${Config.apiBaseUrl}/send_message") {
|
||||
contentType(ContentType.Application.Json)
|
||||
bearerAuth(token!!)
|
||||
setBody(SendMessageRequest(message))
|
||||
}
|
||||
.failOnError()
|
||||
.body<SendMessageResponse>()
|
||||
|
||||
|
||||
suspend fun logout(authToken: String) {
|
||||
// Don't throw on logout errors, just try to logout
|
||||
// Validate token by fetching user profile
|
||||
suspend fun validateToken(): Boolean {
|
||||
try {
|
||||
http.get("${Config.apiBaseUrl}/logout") {
|
||||
bearerAuth(authToken)
|
||||
http
|
||||
.get("${Config.apiBaseUrl}/api/user/profile")
|
||||
return true // Token is valid if no exception thrown
|
||||
} catch (e: io.ktor.client.plugins.ClientRequestException) {
|
||||
// Check if it's an auth error (401/403)
|
||||
if (e.response.status.value == 401 || e.response.status.value == 403) {
|
||||
return false // Token is invalid
|
||||
}
|
||||
// For other HTTP errors, re-throw (don't treat as token invalid)
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
// For network/other errors, re-throw (don't treat as token invalid)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
suspend fun logout() {
|
||||
try {
|
||||
http.get("${Config.apiBaseUrl}/logout")
|
||||
} catch (e: Exception) {
|
||||
// Ignore logout errors
|
||||
}
|
||||
|
||||
secureSettings.remove("auth_token")
|
||||
settings.remove("user_info")
|
||||
token = null
|
||||
user = null
|
||||
}
|
||||
|
||||
// WebSocket send helpers
|
||||
|
||||
@@ -60,9 +60,7 @@ data class Message(
|
||||
val reply_to: Message? = null,
|
||||
val client_message_id: String? = null,
|
||||
val reactions: List<ReactionData>? = null
|
||||
) {
|
||||
val utcTimestamp = "${timestamp}Z"
|
||||
}
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SendMessageRequest(
|
||||
|
||||
@@ -36,8 +36,3 @@ data class TypingUpdateData(
|
||||
val userId: Int,
|
||||
val username: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class WebSocketAuthMessage(
|
||||
val token: String
|
||||
)
|
||||
@@ -8,19 +8,75 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import ru.fromchat.ui.Theme
|
||||
|
||||
/**
|
||||
* Server configuration data
|
||||
*/
|
||||
data class ServerConfigData(
|
||||
val serverUrl: String,
|
||||
val httpsEnabled: Boolean
|
||||
)
|
||||
|
||||
object Settings {
|
||||
private const val MATERIAL_YOU_KEY = "materialYou"
|
||||
private const val THEME_KEY = "theme"
|
||||
private const val SERVER_URL_KEY = "server_url"
|
||||
private const val HTTPS_ENABLED_KEY = "https_enabled"
|
||||
|
||||
private val settings = Settings()
|
||||
|
||||
private fun runIO(block: suspend CoroutineScope.() -> Unit) {
|
||||
CoroutineScope(Dispatchers.IO).launch(block = block)
|
||||
}
|
||||
|
||||
private fun serverConfigNotInitialized(): Nothing
|
||||
= throw IllegalStateException("Server config not initialized")
|
||||
|
||||
var materialYou: Boolean
|
||||
get() = runBlocking { settings.getBoolean("materialYou", true) }
|
||||
set(value) = runIO { settings.putBoolean("materialYou", value) }
|
||||
get() = runBlocking { settings.getBoolean(MATERIAL_YOU_KEY, true) }
|
||||
set(value) = runIO { settings.putBoolean(MATERIAL_YOU_KEY, value) }
|
||||
|
||||
var theme: Theme
|
||||
get() = runBlocking { Theme.entries[settings.getInt("theme", Theme.AsSystem.ordinal)] }
|
||||
set(value) = runIO { settings.putInt("theme", value.ordinal) }
|
||||
get() = runBlocking { Theme.entries[settings.getInt(THEME_KEY, Theme.AsSystem.ordinal)] }
|
||||
set(value) = runIO { settings.putInt(THEME_KEY, value.ordinal) }
|
||||
|
||||
var serverUrl: String
|
||||
get() = runBlocking {
|
||||
settings.getString(SERVER_URL_KEY).ifEmpty {
|
||||
serverConfigNotInitialized()
|
||||
}
|
||||
}
|
||||
set(value) = runIO { settings.putString(SERVER_URL_KEY, value) }
|
||||
|
||||
var httpsEnabled: Boolean
|
||||
get() = runBlocking {
|
||||
if (settings.contains(HTTPS_ENABLED_KEY))
|
||||
settings.getBoolean(HTTPS_ENABLED_KEY, true)
|
||||
else serverConfigNotInitialized()
|
||||
}
|
||||
set(value) = runIO { settings.putBoolean(HTTPS_ENABLED_KEY, value) }
|
||||
|
||||
val hasServerConfig get() = try {
|
||||
serverUrl
|
||||
httpsEnabled
|
||||
true
|
||||
} catch (_: IllegalStateException) {
|
||||
false
|
||||
}
|
||||
|
||||
var serverConfig: ServerConfigData
|
||||
get() {
|
||||
if (!hasServerConfig) {
|
||||
serverUrl = "fromchat.ru"
|
||||
httpsEnabled = true
|
||||
|
||||
return ServerConfigData("fromchat.ru", true)
|
||||
}
|
||||
|
||||
return ServerConfigData(serverUrl, httpsEnabled)
|
||||
}
|
||||
set(value) {
|
||||
serverUrl = value.serverUrl
|
||||
httpsEnabled = value.httpsEnabled
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package ru.fromchat.core.config
|
||||
|
||||
import com.pr0gramm3r101.utils.storage.ServerConfigData
|
||||
import com.pr0gramm3r101.utils.storage.ServerConfigStorage
|
||||
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
|
||||
@@ -13,21 +13,24 @@ object Config {
|
||||
private val _serverConfig = MutableStateFlow<ServerConfigData?>(null)
|
||||
val serverConfig: StateFlow<ServerConfigData?> = _serverConfig.asStateFlow()
|
||||
|
||||
private val config
|
||||
get() = _serverConfig.value ?: throw IllegalStateException("Server configuration not initialized")
|
||||
private val config: ServerConfigData get() {
|
||||
if (_serverConfig.value == null) initialize()
|
||||
|
||||
return (_serverConfig.value ?: IllegalStateException("Server configuration not initialized")) as ServerConfigData
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize configuration by loading from storage
|
||||
*/
|
||||
suspend fun initialize() {
|
||||
_serverConfig.value = ServerConfigStorage.getConfig()
|
||||
fun initialize() {
|
||||
_serverConfig.value = Settings.serverConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Update server configuration
|
||||
*/
|
||||
suspend fun updateServerConfig(config: ServerConfigData) {
|
||||
ServerConfigStorage.saveConfig(config)
|
||||
fun updateServerConfig(config: ServerConfigData) {
|
||||
Settings.serverConfig = config
|
||||
_serverConfig.value = config
|
||||
}
|
||||
|
||||
@@ -42,9 +45,4 @@ object Config {
|
||||
*/
|
||||
val webSocketUrl
|
||||
get() = "${if (config.httpsEnabled) "wss" else "ws"}://${config.serverUrl}/api/chat/ws"
|
||||
|
||||
/**
|
||||
* Checks if server configuration exists
|
||||
*/
|
||||
suspend fun hasServerConfig() = ServerConfigStorage.hasConfiguration()
|
||||
}
|
||||
|
||||
@@ -3,9 +3,6 @@ package ru.fromchat.ui
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.End
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Start
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
@@ -15,7 +12,6 @@ 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.unit.IntOffset
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
@@ -25,8 +21,7 @@ import androidx.navigation.NavController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.WebSocketManager
|
||||
import ru.fromchat.core.config.Config
|
||||
import ru.fromchat.ui.auth.LoginScreen
|
||||
@@ -35,35 +30,26 @@ import ru.fromchat.ui.chat.PublicChatScreen
|
||||
import ru.fromchat.ui.main.MainScreen
|
||||
import ru.fromchat.ui.setup.ServerConfigScreen
|
||||
|
||||
val LocalNavController = compositionLocalOf<NavController> { error("") }
|
||||
val LocalNavController = compositionLocalOf<NavController> { error("NavController not provided") }
|
||||
|
||||
@Composable
|
||||
fun App() {
|
||||
var startDestination by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Initialize config and check server configuration on startup
|
||||
LaunchedEffect(Unit) {
|
||||
coroutineScope {
|
||||
launch {
|
||||
try {
|
||||
// Initialize config
|
||||
runCatching {
|
||||
Config.initialize()
|
||||
}
|
||||
|
||||
// Check if server is configured
|
||||
val serverConfigured = Config.hasServerConfig()
|
||||
// Load persisted token and user data
|
||||
ApiClient.loadPersistedData()
|
||||
|
||||
// Determine which screen to show
|
||||
startDestination = if (!serverConfigured) {
|
||||
"serverConfig"
|
||||
} else {
|
||||
"login"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// On error, start with server config
|
||||
startDestination = "serverConfig"
|
||||
}
|
||||
}
|
||||
}
|
||||
// Now determine start destination based on loaded token
|
||||
val hasToken = ApiClient.token?.isNotEmpty() == true
|
||||
startDestination = if (hasToken) "chat" else "login"
|
||||
|
||||
ru.fromchat.core.Logger.d("App", "Navigation decision - hasToken: $hasToken, token: ${ApiClient.token?.take(10) ?: "null"}")
|
||||
ru.fromchat.core.Logger.d("App", "Starting at screen: $startDestination")
|
||||
}
|
||||
|
||||
// Observe lifecycle events to manage WebSocket connection
|
||||
@@ -93,17 +79,20 @@ fun App() {
|
||||
val navController = rememberNavController()
|
||||
val animationSpec = tween<IntOffset>(400)
|
||||
|
||||
// Set up global auth error handler
|
||||
LaunchedEffect(navController) {
|
||||
ApiClient.onAuthError = {
|
||||
ru.fromchat.core.Logger.d("App", "Global auth error handler triggered, navigating to login")
|
||||
navController.navigate("login") {
|
||||
popUpTo("chat") { inclusive = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalNavController provides navController
|
||||
) {
|
||||
if (startDestination == null) {
|
||||
Box(
|
||||
modifier = androidx.compose.ui.Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
if (startDestination != null) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination!!,
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
package ru.fromchat.ui.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Login
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
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
|
||||
@@ -33,24 +43,73 @@ import ru.fromchat.Res
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.LoginRequest
|
||||
import ru.fromchat.api.apiRequest
|
||||
import ru.fromchat.change_server
|
||||
import ru.fromchat.error_unexpected
|
||||
import ru.fromchat.fill_all_fields
|
||||
import ru.fromchat.login
|
||||
import ru.fromchat.login_d
|
||||
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.username
|
||||
import ru.fromchat.welcome
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
onLoginSuccess: () -> Unit,
|
||||
onNavigateToRegister: () -> Unit
|
||||
) {
|
||||
val errorUnexpected = stringResource(Res.string.error_unexpected)
|
||||
val navController = LocalNavController.current
|
||||
|
||||
Scaffold(contentWindowInsets = WindowInsets.safeDrawing) { innerPadding ->
|
||||
Scaffold(
|
||||
contentWindowInsets = WindowInsets.safeDrawing,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
actions = {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Box(Modifier.wrapContentSize(Alignment.TopEnd)) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
expanded = true
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.MoreVert,
|
||||
contentDescription = stringResource(Res.string.more)
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false } // Закрыть при нажатии вне меню
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(stringResource(Res.string.change_server))
|
||||
},
|
||||
onClick = {
|
||||
expanded = false
|
||||
navController.navigate("serverConfig")
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Filled.Storage,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
title = {}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var alert by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package ru.fromchat.ui.chat
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import kotlin.math.abs
|
||||
@@ -7,90 +8,44 @@ import kotlin.math.abs
|
||||
/**
|
||||
* Get gradient brush for own messages
|
||||
*/
|
||||
fun getMessageGradient(isDark: Boolean): Brush {
|
||||
return if (isDark) {
|
||||
Brush.linearGradient(
|
||||
colors = listOf(
|
||||
fun getMessageGradient(isDark: Boolean) = Brush.linearGradient(
|
||||
colors = if (isDark) {
|
||||
listOf(
|
||||
Color(0xFF9333EA),
|
||||
Color(0xFF6366F1),
|
||||
Color(0xFF2F68C5)
|
||||
),
|
||||
start = androidx.compose.ui.geometry.Offset(0f, 0f),
|
||||
end = androidx.compose.ui.geometry.Offset(1000f, 1000f)
|
||||
)
|
||||
} else {
|
||||
Brush.linearGradient(
|
||||
colors = listOf(
|
||||
listOf(
|
||||
Color(0xFFB794F6),
|
||||
Color(0xFF818CF8),
|
||||
Color(0xFF60A5FA)
|
||||
),
|
||||
start = androidx.compose.ui.geometry.Offset(0f, 0f),
|
||||
end = androidx.compose.ui.geometry.Offset(1000f, 1000f)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
start = Offset(0f, 0f),
|
||||
end = Offset(1000f, 1000f)
|
||||
)
|
||||
|
||||
/**
|
||||
* Get background gradient brushes for chat background
|
||||
* Get gradient brush for own messages
|
||||
*/
|
||||
fun getBackgroundGradients(isDark: Boolean): List<Brush> {
|
||||
return if (isDark) {
|
||||
fun getReplyMessageGradient(isDark: Boolean) = Brush.linearGradient(
|
||||
colors = if (isDark) {
|
||||
listOf(
|
||||
Brush.radialGradient(
|
||||
colors = listOf(
|
||||
Color(0x26DBA1F9),
|
||||
Color.Transparent
|
||||
),
|
||||
center = androidx.compose.ui.geometry.Offset(0.2f, 0.8f),
|
||||
radius = 500f
|
||||
),
|
||||
Brush.radialGradient(
|
||||
colors = listOf(
|
||||
Color(0x26F3B7BE),
|
||||
Color.Transparent
|
||||
),
|
||||
center = androidx.compose.ui.geometry.Offset(0.8f, 0.2f),
|
||||
radius = 500f
|
||||
),
|
||||
Brush.radialGradient(
|
||||
colors = listOf(
|
||||
Color(0x1AD0C1DA),
|
||||
Color.Transparent
|
||||
),
|
||||
center = androidx.compose.ui.geometry.Offset(0.4f, 0.4f),
|
||||
radius = 500f
|
||||
)
|
||||
Color(0xFF5f11a6),
|
||||
Color(0xFF1418da),
|
||||
Color(0xFF1b3d73)
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
Brush.radialGradient(
|
||||
colors = listOf(
|
||||
Color(0x15B794F6),
|
||||
Color.Transparent
|
||||
),
|
||||
center = androidx.compose.ui.geometry.Offset(0.2f, 0.8f),
|
||||
radius = 500f
|
||||
),
|
||||
Brush.radialGradient(
|
||||
colors = listOf(
|
||||
Color(0x15F3B7BE),
|
||||
Color.Transparent
|
||||
),
|
||||
center = androidx.compose.ui.geometry.Offset(0.8f, 0.2f),
|
||||
radius = 500f
|
||||
),
|
||||
Brush.radialGradient(
|
||||
colors = listOf(
|
||||
Color(0x0DD0C1DA),
|
||||
Color.Transparent
|
||||
),
|
||||
center = androidx.compose.ui.geometry.Offset(0.4f, 0.4f),
|
||||
radius = 500f
|
||||
Color(0xFF7836ee),
|
||||
Color(0xFF2034f3),
|
||||
Color(0xFF076eed)
|
||||
)
|
||||
},
|
||||
start = Offset(0f, 0f),
|
||||
end = Offset(1000f, 1000f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a consistent gradient from a name for avatar fallback
|
||||
@@ -102,21 +57,21 @@ fun generateGradientFromName(name: String): Brush {
|
||||
val b = abs((hash / 65536) % 256)
|
||||
|
||||
// Create two colors based on hash for gradient
|
||||
val color1 = Color(
|
||||
return Brush.linearGradient(
|
||||
colors = listOf(
|
||||
Color(
|
||||
red = (r + 100).coerceIn(0, 255) / 255f,
|
||||
green = (g + 100).coerceIn(0, 255) / 255f,
|
||||
blue = (b + 100).coerceIn(0, 255) / 255f
|
||||
)
|
||||
val color2 = Color(
|
||||
),
|
||||
Color(
|
||||
red = (r + 50).coerceIn(0, 255) / 255f,
|
||||
green = (g + 50).coerceIn(0, 255) / 255f,
|
||||
blue = (b + 50).coerceIn(0, 255) / 255f
|
||||
)
|
||||
|
||||
return Brush.linearGradient(
|
||||
colors = listOf(color1, color2),
|
||||
start = androidx.compose.ui.geometry.Offset(0f, 0f),
|
||||
end = androidx.compose.ui.geometry.Offset(100f, 100f)
|
||||
),
|
||||
start = Offset(0f, 0f),
|
||||
end = Offset(100f, 100f)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
package ru.fromchat.ui.chat
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.background
|
||||
@@ -20,15 +25,16 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Reply
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -41,6 +47,7 @@ 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.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
@@ -56,6 +63,78 @@ import ru.fromchat.Res
|
||||
import ru.fromchat.api.Message
|
||||
import ru.fromchat.message_placeholder
|
||||
|
||||
@Composable
|
||||
private fun <T> AnimatedPreviewBar(
|
||||
state: T?,
|
||||
content: @Composable (T) -> Unit
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = state != null,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
var lastState by remember { mutableStateOf(state) }
|
||||
|
||||
LaunchedEffect(state) {
|
||||
if (state != null) {
|
||||
lastState = state
|
||||
}
|
||||
}
|
||||
|
||||
if (state != null || lastState != null) {
|
||||
content(state ?: lastState!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PreviewBar(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 12.dp, end = 6.dp, top = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Close",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalHazeMaterialsApi::class)
|
||||
@Composable
|
||||
fun ChatInput(
|
||||
@@ -77,6 +156,7 @@ fun ChatInput(
|
||||
if (text.isNotBlank()) {
|
||||
typingJob?.cancel()
|
||||
typingHandler.sendTyping()
|
||||
@Suppress("AssignedValueIsNeverRead")
|
||||
typingJob = scope.launch {
|
||||
delay(3000) // 3 seconds
|
||||
typingHandler.stopTyping()
|
||||
@@ -87,52 +167,18 @@ fun ChatInput(
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier.windowInsetsPadding(WindowInsets.navigationBars)
|
||||
) {
|
||||
// Reply preview
|
||||
replyTo?.let { reply ->
|
||||
ReplyPreviewBar(
|
||||
replyTo = reply,
|
||||
onClose = onClearReply,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.hazeEffect(hazeState, style = HazeMaterials.thick())
|
||||
)
|
||||
}
|
||||
|
||||
// Edit preview
|
||||
editingMessage?.let { edit ->
|
||||
EditPreviewBar(
|
||||
message = edit,
|
||||
onClose = onClearEdit,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.hazeEffect(hazeState, style = HazeMaterials.thick())
|
||||
)
|
||||
}
|
||||
|
||||
// Input field with blur
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color.Transparent)
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||
.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
|
||||
) {
|
||||
val shape = RoundedCornerShape(24.dp)
|
||||
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = onTextChange,
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.border(
|
||||
Dp.Hairline,
|
||||
MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
|
||||
@@ -142,7 +188,32 @@ fun ChatInput(
|
||||
.hazeEffect(
|
||||
state = hazeState,
|
||||
style = HazeMaterials.thin()
|
||||
),
|
||||
)
|
||||
) {
|
||||
AnimatedPreviewBar(replyTo) { replyTo ->
|
||||
PreviewBar(
|
||||
icon = Icons.AutoMirrored.Filled.Reply,
|
||||
title = "Replying to ${replyTo.username}",
|
||||
subtitle = replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "",
|
||||
onClose = { onClearReply() }
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedPreviewBar(editingMessage) { message ->
|
||||
PreviewBar(
|
||||
icon = Icons.Filled.Edit,
|
||||
title = "Editing message",
|
||||
subtitle = message.content.take(50) + if (message.content.length > 50) "..." else "",
|
||||
onClose = { onClearEdit() }
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = onTextChange,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.animateContentSize(),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(Res.string.message_placeholder),
|
||||
@@ -199,92 +270,3 @@ fun ChatInput(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplyPreviewBar(
|
||||
replyTo: Message,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier, // hazeEffect moved to ChatInput where it's called
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = Color.Transparent
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Replying to ${replyTo.username}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Close",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditPreviewBar(
|
||||
message: Message,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier, // hazeEffect moved to ChatInput where it's called
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = Color.Transparent
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Editing message",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = message.content.take(50) + if (message.content.length > 50) "..." else "",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Close",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,13 +188,12 @@ fun ChatScreen(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = panelState.title,
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
|
||||
AnimatedContent(
|
||||
targetState = currentTypingUsers.isNotEmpty(),
|
||||
transitionSpec = {
|
||||
@@ -207,9 +206,6 @@ fun ChatScreen(
|
||||
typingUsers = currentTypingUsers.map { it.username },
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
} else {
|
||||
// Empty space to maintain height
|
||||
Box(modifier = Modifier.height(0.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -279,7 +275,10 @@ fun ChatScreen(
|
||||
replyTo = replyTo,
|
||||
editingMessage = editingMessage,
|
||||
onClearReply = { replyTo = null },
|
||||
onClearEdit = { editingMessage = null },
|
||||
onClearEdit = {
|
||||
editingMessage = null
|
||||
inputText = ""
|
||||
},
|
||||
hazeState = hazeState
|
||||
)
|
||||
}
|
||||
@@ -308,12 +307,13 @@ fun ChatScreen(
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(), // Fill the entire space of the Box
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp, alignment = Alignment.Bottom)
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp, alignment = Alignment.Bottom),
|
||||
reverseLayout = true
|
||||
) {
|
||||
item { Spacer(Modifier.height(innerPadding.calculateTopPadding())) } // Spacer for TopAppBar
|
||||
item { Spacer(Modifier.height(innerPadding.calculateBottomPadding())) } // Spacer for chat input
|
||||
|
||||
items(
|
||||
items = panelState.messages,
|
||||
items = panelState.messages.reversed(),
|
||||
key = { it.id }
|
||||
) { message ->
|
||||
val isAuthor = message.user_id == currentUserId
|
||||
@@ -350,18 +350,22 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
item { Spacer(Modifier.height(innerPadding.calculateBottomPadding())) } // Spacer for chat input
|
||||
item { Spacer(Modifier.height(innerPadding.calculateTopPadding())) } // Spacer for TopAppBar
|
||||
}
|
||||
}
|
||||
|
||||
// Context menu
|
||||
@Suppress("AssignedValueIsNeverRead")
|
||||
MessageContextMenu(
|
||||
state = contextMenuState,
|
||||
isAuthor = contextMenuState.message?.user_id == currentUserId,
|
||||
onDismiss = { contextMenuState = contextMenuState.copy(isOpen = false) },
|
||||
onReply = { message ->
|
||||
replyTo = message
|
||||
if (editingMessage != null) {
|
||||
editingMessage = null
|
||||
inputText = ""
|
||||
}
|
||||
},
|
||||
onEdit = { message ->
|
||||
editingMessage = message
|
||||
|
||||
@@ -12,9 +12,12 @@ import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
@@ -27,12 +30,12 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.pr0gramm3r101.utils.conditional
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import ru.fromchat.api.Message
|
||||
@@ -92,18 +95,11 @@ fun MessageItem(
|
||||
.widthIn(max = 280.dp),
|
||||
horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start
|
||||
) {
|
||||
// Reply preview
|
||||
message.reply_to?.let { replyTo ->
|
||||
ReplyPreview(
|
||||
replyTo = replyTo,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Message bubble
|
||||
val isDark = isSystemInDarkTheme()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(IntrinsicSize.Min)
|
||||
.clip(
|
||||
RoundedCornerShape(
|
||||
topStart = 20.dp,
|
||||
@@ -112,9 +108,11 @@ fun MessageItem(
|
||||
bottomEnd = if (isAuthor) 8.dp else 20.dp
|
||||
)
|
||||
)
|
||||
.then(
|
||||
if (isAuthor) {
|
||||
Modifier.shadow(
|
||||
.conditional(
|
||||
isAuthor,
|
||||
`if` = {
|
||||
it
|
||||
.shadow(
|
||||
elevation = 8.dp,
|
||||
shape = RoundedCornerShape(
|
||||
topStart = 20.dp,
|
||||
@@ -124,23 +122,13 @@ fun MessageItem(
|
||||
),
|
||||
spotColor = if (isDark) Color(0x66000000) else Color(0x33000000)
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
.background(getMessageGradient(isDark))
|
||||
},
|
||||
`else` = {
|
||||
background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||
}
|
||||
)
|
||||
.background(
|
||||
brush = if (isAuthor) {
|
||||
getMessageGradient(isDark)
|
||||
} else {
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
.padding(top = 6.dp)
|
||||
) {
|
||||
Column {
|
||||
// Username inside bubble (for received messages)
|
||||
@@ -150,10 +138,59 @@ fun MessageItem(
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Reply preview
|
||||
message.reply_to?.let { replyTo ->
|
||||
Box(
|
||||
Modifier.padding(bottom = 4.dp, start = 6.dp, end = 6.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.fillMaxWidth()
|
||||
.height(IntrinsicSize.Min)
|
||||
.conditional(
|
||||
isAuthor,
|
||||
`if` = {
|
||||
background(getReplyMessageGradient(isDark))
|
||||
},
|
||||
`else` = {
|
||||
background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
}
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
.width(3.dp)
|
||||
.fillMaxHeight()
|
||||
)
|
||||
|
||||
Column(
|
||||
Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = replyTo.username,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontSize = 11.sp
|
||||
)
|
||||
Text(
|
||||
text = replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Message content
|
||||
Text(
|
||||
text = message.content,
|
||||
@@ -162,12 +199,14 @@ fun MessageItem(
|
||||
Color.White
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
)
|
||||
|
||||
// Timestamp and edited indicator
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
modifier = Modifier
|
||||
.padding(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
@@ -202,45 +241,17 @@ fun MessageItem(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplyPreview(
|
||||
replyTo: Message,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp)
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = replyTo.username,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontSize = 11.sp
|
||||
)
|
||||
Text(
|
||||
text = replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalTime
|
||||
private fun formatTime(timestamp: String): String {
|
||||
return try {
|
||||
val instant = Instant.parse(timestamp)
|
||||
val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault())
|
||||
val hour = localDateTime.hour.toString().padStart(2, '0')
|
||||
val minute = localDateTime.minute.toString().padStart(2, '0')
|
||||
"$hour:$minute"
|
||||
} catch (e: Exception) {
|
||||
Instant.parse(timestamp).toLocalDateTime(TimeZone.currentSystemDefault()).let {
|
||||
"${
|
||||
it.hour.toString().padStart(2, '0')
|
||||
}:${
|
||||
it.minute.toString().padStart(2, '0')
|
||||
}"
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Fallback: try parsing without timezone if it fails
|
||||
try {
|
||||
val parts = timestamp.split("T")
|
||||
@@ -254,9 +265,8 @@ private fun formatTime(timestamp: String): String {
|
||||
} else {
|
||||
""
|
||||
}
|
||||
} catch (e2: Exception) {
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ class PublicChatPanel(
|
||||
scope.launch {
|
||||
typingHandler.typingUsers.collect { users ->
|
||||
Logger.d("PublicChatPanel", "Typing users updated in handler: ${users.map { it.username }}")
|
||||
updateState { it.copy(typingUsers = users) }
|
||||
updateState { it.copy(typingUsers = users.filter { it.userId != currentUserId }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ class PublicChatPanel(
|
||||
}
|
||||
setHasMoreMessages(false) // TODO: Implement has_more from API
|
||||
messagesLoaded = true
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
// Handle error
|
||||
} finally {
|
||||
setLoading(false)
|
||||
@@ -84,7 +84,7 @@ class PublicChatPanel(
|
||||
}
|
||||
}
|
||||
setHasMoreMessages(false) // TODO: Implement has_more from API
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
// Handle error
|
||||
} finally {
|
||||
setLoadingMore(false)
|
||||
|
||||
@@ -4,9 +4,12 @@ import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.keyframes
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -16,7 +19,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
@@ -63,67 +65,54 @@ private fun formatTypingText(typingUsers: List<String>): String {
|
||||
@Composable
|
||||
private fun TypingDots() {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "typing_dots")
|
||||
val duration = 1000
|
||||
val initialScale = 1f
|
||||
val targetScale = 1.3f
|
||||
|
||||
val dot1Alpha by infiniteTransition.animateFloat(
|
||||
initialValue = 0.3f,
|
||||
targetValue = 1f,
|
||||
// Функция-хелпер для создания анимации с задержкой
|
||||
@Composable
|
||||
fun animateDotScale(delay: Int) = infiniteTransition.animateFloat(
|
||||
initialValue = initialScale,
|
||||
targetValue = initialScale, // Возвращаемся в начало
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = keyframes {
|
||||
durationMillis = 1400
|
||||
0.3f at 0
|
||||
1f at 200
|
||||
0.3f at 400
|
||||
durationMillis = duration
|
||||
initialScale at delay // Начало подъема
|
||||
targetScale at delay + 200 // Пик
|
||||
initialScale at delay + 400 // Возврат
|
||||
initialScale at duration // Удержание до конца цикла
|
||||
}
|
||||
),
|
||||
label = "dot1"
|
||||
label = "dot_scale"
|
||||
)
|
||||
|
||||
val dot2Alpha by infiniteTransition.animateFloat(
|
||||
initialValue = 0.3f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = keyframes {
|
||||
durationMillis = 1400
|
||||
0.3f at 200
|
||||
1f at 400
|
||||
0.3f at 600
|
||||
}
|
||||
),
|
||||
label = "dot2"
|
||||
)
|
||||
val dot1Scale by animateDotScale(delay = 0)
|
||||
val dot2Scale by animateDotScale(delay = 200)
|
||||
val dot3Scale by animateDotScale(delay = 400)
|
||||
|
||||
val dot3Alpha by infiniteTransition.animateFloat(
|
||||
initialValue = 0.3f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = keyframes {
|
||||
durationMillis = 1400
|
||||
0.3f at 400
|
||||
1f at 600
|
||||
0.3f at 800
|
||||
}
|
||||
),
|
||||
label = "dot3"
|
||||
)
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Dot(alpha = dot1Alpha)
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Dot(alpha = dot2Alpha)
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Dot(alpha = dot3Alpha)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp) // Вместо Spacer
|
||||
) {
|
||||
Dot(scale = dot1Scale, maxScale = targetScale)
|
||||
Dot(scale = dot2Scale, maxScale = targetScale)
|
||||
Dot(scale = dot3Scale, maxScale = targetScale)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Dot(alpha: Float) {
|
||||
@Suppress("SameParameterValue")
|
||||
private fun Dot(maxScale: Float, scale: Float) {
|
||||
Box(
|
||||
modifier = Modifier.size(4.dp * maxScale),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.width(4.dp)
|
||||
.height(4.dp)
|
||||
.alpha(alpha),
|
||||
.width(4.dp * scale)
|
||||
.height(4.dp * scale),
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
) {}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -188,20 +188,6 @@ fun SettingsTab(
|
||||
onClick = {
|
||||
// Logout and navigate to server config when server changes
|
||||
scope.launch {
|
||||
// Logout from current server
|
||||
try {
|
||||
ApiClient.token?.let { token ->
|
||||
ApiClient.logout(token)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Ignore logout errors (server might be unreachable)
|
||||
}
|
||||
|
||||
// Clear API client state
|
||||
ApiClient.token = null
|
||||
ApiClient.user = null
|
||||
WebSocketManager.shutdown()
|
||||
|
||||
navController.navigate("serverConfig")
|
||||
}
|
||||
},
|
||||
@@ -222,9 +208,7 @@ fun SettingsTab(
|
||||
scope.launch {
|
||||
// Logout
|
||||
try {
|
||||
ApiClient.token?.let { token ->
|
||||
ApiClient.logout(token)
|
||||
}
|
||||
ApiClient.logout()
|
||||
} catch (e: Exception) {
|
||||
// Ignore logout errors
|
||||
}
|
||||
|
||||
@@ -40,19 +40,18 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.pr0gramm3r101.utils.navigateAndWipeBackStack
|
||||
import com.pr0gramm3r101.utils.storage.ServerConfigData
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.WebSocketManager
|
||||
import ru.fromchat.back
|
||||
import ru.fromchat.core.ServerConfigData
|
||||
import ru.fromchat.core.config.Config
|
||||
import ru.fromchat.https_enabled
|
||||
import ru.fromchat.save_continue
|
||||
import ru.fromchat.server_config_subtitle
|
||||
import ru.fromchat.server_config_title
|
||||
import ru.fromchat.server_url_hint
|
||||
import ru.fromchat.server_url_label
|
||||
import ru.fromchat.ui.LocalNavController
|
||||
|
||||
@@ -63,16 +62,16 @@ fun ServerConfigScreen() {
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
|
||||
// Load existing config if available
|
||||
var serverUrl by remember { mutableStateOf("") }
|
||||
var httpsEnabled by remember { mutableStateOf(false) }
|
||||
var serverUrl by remember { mutableStateOf("fromchat.ru") }
|
||||
var httpsEnabled by remember { mutableStateOf(true) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val config = Config.serverConfig.value
|
||||
if (config != null) {
|
||||
serverUrl = config.serverUrl
|
||||
httpsEnabled = config.httpsEnabled
|
||||
Config.serverConfig.value?.let {
|
||||
serverUrl = it.serverUrl
|
||||
httpsEnabled = it.httpsEnabled
|
||||
}
|
||||
}
|
||||
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
@@ -82,12 +81,14 @@ fun ServerConfigScreen() {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
if (navController.currentBackStackEntry != null) {
|
||||
IconButton(onClick = navController::navigateUp) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(Res.string.back)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
@@ -122,7 +123,7 @@ fun ServerConfigScreen() {
|
||||
value = serverUrl,
|
||||
onValueChange = { serverUrl = it },
|
||||
label = { Text(stringResource(Res.string.server_url_label)) },
|
||||
placeholder = { Text(stringResource(Res.string.server_url_hint)) },
|
||||
placeholder = { Text("fromchat.ru") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
@@ -147,16 +148,11 @@ fun ServerConfigScreen() {
|
||||
onClick = {
|
||||
isLoading = true
|
||||
scope.launch {
|
||||
val config = ServerConfigData(serverUrl, httpsEnabled)
|
||||
Config.updateServerConfig(config)
|
||||
Config.updateServerConfig(ServerConfigData(serverUrl, httpsEnabled))
|
||||
|
||||
// Ensure we're logged out when server config changes
|
||||
try {
|
||||
ApiClient.token?.let { token ->
|
||||
ApiClient.logout(token)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Ignore logout errors
|
||||
runCatching {
|
||||
ApiClient.logout()
|
||||
}
|
||||
|
||||
// Clear API client state
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
package ru.fromchat
|
||||
|
||||
import ru.fromchat.ui.App
|
||||
import androidx.compose.ui.window.ComposeUIViewController
|
||||
import ru.fromchat.ui.App
|
||||
|
||||
@Suppress("unused")
|
||||
fun MainViewController() = ComposeUIViewController { App() }
|
||||
|
||||
|
||||
@@ -4,10 +4,33 @@ import io.ktor.client.HttpClient
|
||||
import io.ktor.client.HttpClientConfig
|
||||
import io.ktor.client.engine.darwin.Darwin
|
||||
import io.ktor.client.engine.darwin.DarwinClientEngineConfig
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
import io.ktor.client.plugins.defaultRequest
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.http.headers
|
||||
|
||||
actual fun createPlatformHttpClient(
|
||||
block: HttpClientConfig<*>.() -> Unit
|
||||
): HttpClient {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return HttpClient(Darwin, block as HttpClientConfig<DarwinClientEngineConfig>.() -> Unit)
|
||||
return HttpClient(Darwin) {
|
||||
// Configure default request headers to ensure UTF-8 encoding
|
||||
defaultRequest {
|
||||
contentType(ContentType.Application.Json)
|
||||
headers {
|
||||
append("Accept-Charset", "utf-8")
|
||||
append("Content-Type", "application/json; charset=utf-8")
|
||||
}
|
||||
}
|
||||
|
||||
// Add timeout configuration
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 30000
|
||||
connectTimeoutMillis = 30000
|
||||
}
|
||||
|
||||
// Apply the passed configuration block
|
||||
block(this)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,14 @@ gson = "2.13.2"
|
||||
kotlinxIoBytestring = "0.8.2"
|
||||
kotlinxCoroutinesCore = "1.10.2"
|
||||
kotlinxIoCore = "0.8.2"
|
||||
kotlinxSerializationJson = "1.9.0"
|
||||
multiplatformSettings = "1.3.0"
|
||||
serialization = "2.3.0"
|
||||
serialization-json = "1.9.0"
|
||||
material = "1.13.0"
|
||||
activityKtx = "1.12.2"
|
||||
navigationCompose = "2.9.1"
|
||||
datastore = "1.2.0"
|
||||
security-crypto = "1.1.0-alpha06"
|
||||
ktor = "3.3.3"
|
||||
slf4j = "1.7.36"
|
||||
kotlinxDatetime = "0.7.1"
|
||||
@@ -45,12 +48,16 @@ kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", versio
|
||||
biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" }
|
||||
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
|
||||
kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinxIoCore" }
|
||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
|
||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization-json" }
|
||||
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
||||
androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" }
|
||||
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatformSettings" }
|
||||
multiplatform-settings-coroutines = { module = "com.russhwolf:multiplatform-settings-coroutines", version.ref = "multiplatformSettings" }
|
||||
multiplatform-settings-serialization = { module = "com.russhwolf:multiplatform-settings-serialization", version.ref = "multiplatformSettings" }
|
||||
navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
|
||||
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||
datastore-core = { group = "androidx.datastore", name = "datastore-core", version.ref = "datastore" }
|
||||
security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "security-crypto" }
|
||||
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
|
||||
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
|
||||
ktor-client-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
||||
@@ -76,3 +83,4 @@ kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref
|
||||
android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
|
||||
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version = "2.3.0" }
|
||||
android-library = { id = "com.android.library", version.ref = "agp" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "serialization" }
|
||||
+291
-36
@@ -1,46 +1,301 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 1. Setup
|
||||
# --- Setup ---
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# 2. Variables
|
||||
TAG=$(git describe --tags --abbrev=0)
|
||||
# shellcheck disable=SC2207
|
||||
TAGS=($(git --no-pager tag --sort -v:refname | xargs))
|
||||
PREVTAG=${TAGS[1]}
|
||||
ASSET_PATH=./app/build/outputs/apk/release/app-release.apk
|
||||
DISPLAY_NAME=FromChat.$TAG.apk
|
||||
# --- Arguments ---
|
||||
IS_PRERELEASE=false
|
||||
|
||||
# 3. Build release variant
|
||||
./gradlew assembleRelease
|
||||
|
||||
# 4. Move locally
|
||||
mkdir -p releases
|
||||
mv "$ASSET_PATH" "releases/$DISPLAY_NAME"
|
||||
ASSET_PATH=./releases/$DISPLAY_NAME
|
||||
|
||||
# 6. Release to GitHub
|
||||
prerelease_opt=$([[ "$TAG" != v*-pre* ]] || echo "--prerelease")
|
||||
|
||||
new_release() {
|
||||
gh release create \
|
||||
"$TAG" \
|
||||
--generate-notes \
|
||||
--notes-start-tag "$PREVTAG" \
|
||||
"$prerelease_opt" \
|
||||
"$ASSET_PATH"
|
||||
show_help() {
|
||||
echo "Usage: $0 [arguments]"
|
||||
echo ""
|
||||
echo "Arguments:"
|
||||
echo " --pre Enable pre-release"
|
||||
echo " --help Show this message"
|
||||
}
|
||||
|
||||
edit_existing() {
|
||||
gh release edit \
|
||||
"$TAG" \
|
||||
"$prerelease_opt"
|
||||
gh release upload \
|
||||
"$TAG" \
|
||||
--clobber \
|
||||
"$ASSET_PATH"
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--pre)
|
||||
IS_PRERELEASE=true
|
||||
shift
|
||||
;;
|
||||
--help)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo -e "\033[0;31mError: Unknown argument '$arg'\033[0m"
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- Colors ---
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
MAGENTA='\033[0;35m'
|
||||
NC='\033[0m'
|
||||
BOLD='\033[1m'
|
||||
|
||||
# --- Logging ---
|
||||
info() { echo -e "${BLUE}ℹ${NC} $1"; }
|
||||
success() { echo -e "${GREEN}✓${NC} $1"; }
|
||||
warning() { echo -e "${YELLOW}⚠${NC} $1"; }
|
||||
error() { echo -e "${RED}✗${NC} $1"; }
|
||||
step() { echo -e "\n${CYAN}${BOLD}→${NC} ${BOLD}$1${NC}"; }
|
||||
substep() { echo -e " ${GREEN}•${NC} $1"; }
|
||||
|
||||
echo -e "${MAGENTA}${BOLD}🚀 FromChat KMP Release Pipeline${NC}"
|
||||
|
||||
# --- 1. Version Input & Validation ---
|
||||
step "Configuration"
|
||||
echo -en " ${GREEN}•${NC} Release version: "
|
||||
read -r USER_INPUT
|
||||
|
||||
# Regex for x, x.y, or x.y.z
|
||||
VERSION_REGEX="^[0-9]+(\.[0-9]+)*$"
|
||||
# shellcheck disable=SC2001
|
||||
CLEAN_VERSION=$(echo "$USER_INPUT" | LC_ALL=C sed 's/^v//')
|
||||
|
||||
if [[ ! $CLEAN_VERSION =~ $VERSION_REGEX ]]; then
|
||||
error "Error: Version must be in format x, x.y, or x.y.z (e.g. 1.2.3)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TAG="v$CLEAN_VERSION"
|
||||
VERSION_STR="$CLEAN_VERSION"
|
||||
# shellcheck disable=SC2001
|
||||
BUILD_NUMBER=$(echo "$VERSION_STR" | LC_ALL=C sed 's/[^0-9]//g')
|
||||
[[ -z "$BUILD_NUMBER" ]] && BUILD_NUMBER=1
|
||||
|
||||
# Check if tag exists
|
||||
RECREATE_TAG=false
|
||||
if git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
echo -n " " && warning "Tag $TAG already exists."
|
||||
echo -en " ${GREEN}•${NC} Delete it and move to the new commit? (y/N): "
|
||||
read -r CONFIRM
|
||||
if [[ "$CONFIRM" =~ ^[Yy]$ ]]; then
|
||||
RECREATE_TAG=true
|
||||
else
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
CURRENT_BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || git rev-parse --short HEAD)
|
||||
STASH_MARKER="release_stash_$(date +%s)"
|
||||
HAS_STASHED=false
|
||||
|
||||
restore_git_state() {
|
||||
if [ "$HAS_STASHED" = true ]; then
|
||||
STASH_ID=$(git stash list | grep "$STASH_MARKER" | head -n 1 | cut -d':' -f1)
|
||||
if [[ -n "$STASH_ID" ]]; then
|
||||
git stash pop "$STASH_ID" > /dev/null 2>&1 || true
|
||||
fi
|
||||
HAS_STASHED=false
|
||||
fi
|
||||
}
|
||||
|
||||
git push --tags
|
||||
new_release || edit_existing
|
||||
trap restore_git_state EXIT INT TERM
|
||||
|
||||
# --- 2. Git Cleanup & Safety ---
|
||||
if [[ -n $(git status --short) ]]; then
|
||||
git stash push --include-untracked -m "$STASH_MARKER" > /dev/null 2>&1
|
||||
HAS_STASHED=true
|
||||
fi
|
||||
|
||||
git fetch origin "$CURRENT_BRANCH" > /dev/null 2>&1
|
||||
LOCAL_HASH=$(git rev-parse HEAD)
|
||||
REMOTE_HASH=$(git rev-parse "origin/$CURRENT_BRANCH")
|
||||
|
||||
if [ "$LOCAL_HASH" != "$REMOTE_HASH" ]; then
|
||||
if ! git merge-base --is-ancestor "$REMOTE_HASH" "$LOCAL_HASH"; then
|
||||
error "Remote branch has commits you don't have. Please pull first."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Constants ---
|
||||
BUILD_DIR="$(pwd)/build"
|
||||
DESC_FILE="$BUILD_DIR/.release_desc.md"
|
||||
RELEASES_DIR="$(pwd)/releases"
|
||||
mkdir -p "$RELEASES_DIR"
|
||||
export GRADLE_OPTS="-Dorg.gradle.jvmargs=-Xmx8g -Dkotlin.daemon.jvm.options=-Xmx8g"
|
||||
|
||||
# --- 3. Build Functions ---
|
||||
|
||||
build_android() {
|
||||
step "Building Android Release"
|
||||
if ./gradlew :app:android:assembleRelease; then
|
||||
APK_SRC=$(find . -name "*release.apk" | head -n 1)
|
||||
if [[ -f "$APK_SRC" ]]; then
|
||||
DISPLAY_NAME="FromChat-$TAG-android.apk"
|
||||
cp "$APK_SRC" "$RELEASES_DIR/$DISPLAY_NAME"
|
||||
ANDROID_ASSET="$RELEASES_DIR/$DISPLAY_NAME"
|
||||
success "Android APK ready: ${CYAN}$DISPLAY_NAME${NC}"
|
||||
else
|
||||
error "APK not found"; exit 1
|
||||
fi
|
||||
else
|
||||
error "Android build failed"; exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
build_ios() {
|
||||
step "Building iOS Release"
|
||||
if [[ "$OSTYPE" != "darwin"* ]]; then
|
||||
warning "iOS build requires macOS. Skipping."
|
||||
return
|
||||
fi
|
||||
|
||||
export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
|
||||
# shellcheck disable=SC2155
|
||||
[[ ! -d "$DEVELOPER_DIR" ]] && export DEVELOPER_DIR=$(xcode-select -p)
|
||||
|
||||
IOS_PROJECT_DIR="app/ios"
|
||||
[[ ! -d "$IOS_PROJECT_DIR" ]] && IOS_PROJECT_DIR="iosApp"
|
||||
PLIST_PATH=$(find "$IOS_PROJECT_DIR" -name "Info.plist" | head -n 1)
|
||||
|
||||
if [[ -f "$PLIST_PATH" ]]; then
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$PLIST_PATH" || true
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION_STR" "$PLIST_PATH" || true
|
||||
fi
|
||||
|
||||
substep "Xcode Archiving..."
|
||||
rm -rf "$BUILD_DIR/ios" && mkdir -p "$BUILD_DIR/ios"
|
||||
if ! xcodebuild -project "$IOS_PROJECT_DIR/iosApp.xcodeproj" \
|
||||
-scheme iOS \
|
||||
-configuration Release \
|
||||
-sdk iphoneos \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-derivedDataPath "$BUILD_DIR/ios" \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
CODE_SIGNING_REQUIRED=NO \
|
||||
clean build > "$BUILD_DIR/ios/build.log" 2>&1
|
||||
then
|
||||
error "iOS build failed. Check: $BUILD_DIR/ios/build.log"; exit 1
|
||||
fi
|
||||
|
||||
substep "Packaging to ${CYAN}.ipa${NC}..."
|
||||
APP_BUNDLE_PATH=$(find "$BUILD_DIR/ios/Build/Products/Release-iphoneos" -name "*.app" -type d | head -n 1)
|
||||
|
||||
if [[ -n "$APP_BUNDLE_PATH" ]]; then
|
||||
IPA_NAME="FromChat-$TAG-ios-unsigned.ipa"
|
||||
IPA_PATH="$RELEASES_DIR/$IPA_NAME"
|
||||
|
||||
# Create Payload structure inside build dir
|
||||
PAYLOAD_STAGE="$BUILD_DIR/ios/ipa_stage"
|
||||
rm -rf "$PAYLOAD_STAGE" && mkdir -p "$PAYLOAD_STAGE/Payload"
|
||||
|
||||
# Use cp -R to dereference symlinks and copy actual files into the stage
|
||||
# This is safe because it's only the final bundle
|
||||
cp -R "$APP_BUNDLE_PATH" "$PAYLOAD_STAGE/Payload/"
|
||||
|
||||
# Zip from the stage directory
|
||||
(cd "$PAYLOAD_STAGE" && zip -r "$IPA_PATH" Payload > /dev/null 2>&1)
|
||||
|
||||
# Cleanup stage
|
||||
rm -rf "$PAYLOAD_STAGE"
|
||||
|
||||
if [[ -f "$IPA_PATH" ]]; then
|
||||
IOS_ASSET="$IPA_PATH"
|
||||
success "iOS IPA ready: ${CYAN}$IPA_NAME${NC}"
|
||||
else
|
||||
error "IPA generation failed (file not found in releases)"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
error "Could not find .app bundle"; exit 1
|
||||
fi
|
||||
|
||||
git checkout -- "$PLIST_PATH" > /dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
build_android
|
||||
build_ios
|
||||
|
||||
# --- 4. Git Finalizing ---
|
||||
|
||||
IOS_PROJECT_DIR="app/ios"
|
||||
[[ ! -d "$IOS_PROJECT_DIR" ]] && IOS_PROJECT_DIR="iosApp"
|
||||
PLIST_PATH=$(find "$IOS_PROJECT_DIR" -name "Info.plist" | head -n 1)
|
||||
|
||||
if [[ -f "$PLIST_PATH" ]]; then
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$PLIST_PATH" || true
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION_STR" "$PLIST_PATH" || true
|
||||
git add "$PLIST_PATH" > /dev/null 2>&1
|
||||
|
||||
if ! git diff --cached --quiet; then
|
||||
if git commit --amend --no-edit > /dev/null 2>&1; then
|
||||
substep "Info.plist updated (amend)."
|
||||
else
|
||||
error "Failed to amend commit."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$RECREATE_TAG" == true ]]; then
|
||||
git tag -d "$TAG" > /dev/null 2>&1 || true
|
||||
git push origin :refs/tags/"$TAG" > /dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
if ! git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
git tag -a "$TAG" -m "Release $TAG" > /dev/null 2>&1
|
||||
fi
|
||||
|
||||
git push origin "$CURRENT_BRANCH" --force-with-lease --tags > /dev/null 2>&1
|
||||
|
||||
# --- 5. GitHub Release ---
|
||||
|
||||
prepare_description() {
|
||||
echo -e "<!-- Release Description -->" > "$DESC_FILE"
|
||||
nano "$DESC_FILE"
|
||||
CLEAN_DESC=$(LC_ALL=C perl -0777 -pe 's/<!--.*?-->//gs' "$DESC_FILE" | LC_ALL=C sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
|
||||
rm -f "$DESC_FILE"
|
||||
if [[ -n "$CLEAN_DESC" ]]; then
|
||||
echo "$CLEAN_DESC" > "$DESC_FILE"
|
||||
NOTES_ARG=("-F" "$DESC_FILE")
|
||||
else
|
||||
NOTES_ARG=("--generate-notes")
|
||||
fi
|
||||
}
|
||||
|
||||
publish_github() {
|
||||
step "GitHub Release"
|
||||
if ! command -v gh &> /dev/null; then return; fi
|
||||
|
||||
ASSETS=()
|
||||
[[ -f "$ANDROID_ASSET" ]] && ASSETS+=("$ANDROID_ASSET")
|
||||
[[ -f "$IOS_ASSET" ]] && ASSETS+=("$IOS_ASSET")
|
||||
[ ${#ASSETS[@]} -eq 0 ] && return
|
||||
|
||||
prepare_description
|
||||
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
substep "Editing existing release..."
|
||||
EDIT_ARGS=("--draft=false")
|
||||
[[ "$IS_PRERELEASE" == true ]] && EDIT_ARGS+=("--prerelease") || EDIT_ARGS+=("--prerelease=false")
|
||||
[[ -f "$DESC_FILE" ]] && EDIT_ARGS+=("-F" "$DESC_FILE")
|
||||
gh release edit "$TAG" "${EDIT_ARGS[@]}" 1> /dev/null
|
||||
substep "Uploading files..."
|
||||
gh release upload "$TAG" "${ASSETS[@]}" --clobber 1> /dev/null
|
||||
else
|
||||
substep "Creating the release..."
|
||||
PR_FLAG=""
|
||||
[[ "$IS_PRERELEASE" == true ]] && PR_FLAG="--prerelease"
|
||||
gh release create "$TAG" "${NOTES_ARG[@]}" $PR_FLAG --draft=false "${ASSETS[@]}" 1> /dev/null
|
||||
fi
|
||||
rm -f "$DESC_FILE"
|
||||
success "Success!"
|
||||
}
|
||||
|
||||
publish_github
|
||||
|
||||
restore_git_state
|
||||
trap - EXIT INT TERM
|
||||
echo -e "\n${GREEN}${BOLD}✨ Release $TAG completed successfully!${NC}"
|
||||
@@ -40,6 +40,7 @@ kotlin {
|
||||
implementation(compose.materialIconsExtended)
|
||||
implementation(libs.jetbrains.kotlinx.coroutines.core)
|
||||
implementation(libs.navigation.compose)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
}
|
||||
|
||||
androidMain.dependencies {
|
||||
@@ -49,10 +50,19 @@ kotlin {
|
||||
implementation(libs.biometric)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.material)
|
||||
implementation(libs.security.crypto)
|
||||
}
|
||||
|
||||
iosMain.dependencies {
|
||||
// nothing
|
||||
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)
|
||||
// Multiplatform settings for iOS Keychain support
|
||||
implementation(libs.multiplatform.settings)
|
||||
implementation(libs.multiplatform.settings.coroutines)
|
||||
implementation(libs.multiplatform.settings.serialization)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -720,6 +720,7 @@ val ActivityInfo.launchIntent get() = Intent().apply {
|
||||
flags = FLAG_ACTIVITY_NEW_TASK
|
||||
}
|
||||
|
||||
@SuppressLint("QueryPermissionsNeeded")
|
||||
@RequiresPermission(Manifest.permission.QUERY_ALL_PACKAGES)
|
||||
fun ActivityInfo.isLauncher(context: Context): Boolean {
|
||||
with (context) {
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package com.pr0gramm3r101.utils.settings
|
||||
|
||||
import androidx.core.content.edit
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import com.pr0gramm3r101.utils.UtilsLibrary.context
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class AndroidSecureSettings : Settings {
|
||||
private val masterKey by lazy {
|
||||
MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
}
|
||||
|
||||
private val encryptedPrefs by lazy {
|
||||
EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"secure_storage",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun putString(key: String, value: String) = withContext(Dispatchers.IO) {
|
||||
encryptedPrefs.edit { putString(key, value) }
|
||||
}
|
||||
|
||||
override suspend fun getString(key: String, default: String) = withContext(Dispatchers.IO) {
|
||||
encryptedPrefs.getString(key, default) ?: default
|
||||
}
|
||||
|
||||
override suspend fun putInt(key: String, value: Int) = withContext(Dispatchers.IO) {
|
||||
encryptedPrefs.edit { putInt(key, value) }
|
||||
}
|
||||
|
||||
override suspend fun getInt(key: String, default: Int) = withContext(Dispatchers.IO) {
|
||||
encryptedPrefs.getInt(key, default)
|
||||
}
|
||||
|
||||
override suspend fun putLong(key: String, value: Long) = withContext(Dispatchers.IO) {
|
||||
encryptedPrefs.edit { putLong(key, value) }
|
||||
}
|
||||
|
||||
override suspend fun getLong(key: String, default: Long) = withContext(Dispatchers.IO) {
|
||||
encryptedPrefs.getLong(key, default)
|
||||
}
|
||||
|
||||
override suspend fun putFloat(key: String, value: Float) = withContext(Dispatchers.IO) {
|
||||
encryptedPrefs.edit { putFloat(key, value) }
|
||||
}
|
||||
|
||||
override suspend fun getFloat(key: String, default: Float) = withContext(Dispatchers.IO) {
|
||||
encryptedPrefs.getFloat(key, default)
|
||||
}
|
||||
|
||||
override suspend fun putBoolean(key: String, value: Boolean) = withContext(Dispatchers.IO) {
|
||||
encryptedPrefs.edit { putBoolean(key, value) }
|
||||
}
|
||||
|
||||
override suspend fun getBoolean(key: String, default: Boolean) = withContext(Dispatchers.IO) {
|
||||
encryptedPrefs.getBoolean(key, default)
|
||||
}
|
||||
|
||||
override suspend fun putStringSet(key: String, value: Set<String>) = withContext(Dispatchers.IO) {
|
||||
encryptedPrefs.edit { putStringSet(key, value) }
|
||||
}
|
||||
|
||||
override suspend fun getStringSet(key: String, default: Set<String>) = withContext(Dispatchers.IO) {
|
||||
encryptedPrefs.getStringSet(key, default) ?: default
|
||||
}
|
||||
|
||||
override suspend fun remove(key: String) = withContext(Dispatchers.IO) {
|
||||
encryptedPrefs.edit { remove(key) }
|
||||
}
|
||||
|
||||
override suspend fun contains(key: String) = withContext(Dispatchers.IO) {
|
||||
encryptedPrefs.contains(key)
|
||||
}
|
||||
}
|
||||
|
||||
+11
-4
@@ -79,10 +79,6 @@ class AndroidSettings(): Settings {
|
||||
preferences[stringSetPreferencesKey(key)] ?: default
|
||||
}.first()
|
||||
|
||||
override suspend fun putStringList(key: String, value: List<String>) = putStringSet(key, value.toSet())
|
||||
|
||||
override suspend fun getStringList(key: String, default: List<String>) = getStringSet(key, default.toSet()).toList()
|
||||
|
||||
override suspend fun remove(key: String) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences.remove(stringPreferencesKey(key))
|
||||
@@ -93,4 +89,15 @@ class AndroidSettings(): Settings {
|
||||
preferences.remove(stringSetPreferencesKey(key))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun contains(key: String): Boolean {
|
||||
return dataStore.data.map { preferences ->
|
||||
preferences.contains(stringPreferencesKey(key)) ||
|
||||
preferences.contains(intPreferencesKey(key)) ||
|
||||
preferences.contains(longPreferencesKey(key)) ||
|
||||
preferences.contains(floatPreferencesKey(key)) ||
|
||||
preferences.contains(booleanPreferencesKey(key)) ||
|
||||
preferences.contains(stringSetPreferencesKey(key))
|
||||
}.first()
|
||||
}
|
||||
}
|
||||
-128
@@ -1,128 +0,0 @@
|
||||
package com.pr0gramm3r101.utils.settings
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.floatPreferencesKey
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
class AndroidAsyncSettings : AsyncSettings {
|
||||
private val dataStore: DataStore<Preferences>
|
||||
get() = DataStoreSingleton.dataStore
|
||||
|
||||
override suspend fun putString(key: String, value: String) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[stringPreferencesKey(key)] = value
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getString(key: String, default: String): String {
|
||||
return dataStore.data.map { preferences ->
|
||||
preferences[stringPreferencesKey(key)] ?: default
|
||||
}.first()
|
||||
}
|
||||
|
||||
override suspend fun putInt(key: String, value: Int) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[intPreferencesKey(key)] = value
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getInt(key: String, default: Int): Int {
|
||||
return dataStore.data.map { preferences ->
|
||||
preferences[intPreferencesKey(key)] ?: default
|
||||
}.first()
|
||||
}
|
||||
|
||||
override suspend fun putLong(key: String, value: Long) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[longPreferencesKey(key)] = value
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getLong(key: String, default: Long): Long {
|
||||
return dataStore.data.map { preferences ->
|
||||
preferences[longPreferencesKey(key)] ?: default
|
||||
}.first()
|
||||
}
|
||||
|
||||
override suspend fun putFloat(key: String, value: Float) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[floatPreferencesKey(key)] = value
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getFloat(key: String, default: Float): Float {
|
||||
return dataStore.data.map { preferences ->
|
||||
preferences[floatPreferencesKey(key)] ?: default
|
||||
}.first()
|
||||
}
|
||||
|
||||
override suspend fun putBoolean(key: String, value: Boolean) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[booleanPreferencesKey(key)] = value
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getBoolean(key: String, default: Boolean): Boolean {
|
||||
return dataStore.data.map { preferences ->
|
||||
preferences[booleanPreferencesKey(key)] ?: default
|
||||
}.first()
|
||||
}
|
||||
|
||||
override suspend fun putStringSet(key: String, value: Set<String>) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[stringSetPreferencesKey(key)] = value
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getStringSet(key: String, default: Set<String>): Set<String> {
|
||||
return dataStore.data.map { preferences ->
|
||||
preferences[stringSetPreferencesKey(key)] ?: default
|
||||
}.first()
|
||||
}
|
||||
|
||||
override suspend fun putStringList(key: String, value: List<String>) {
|
||||
putStringSet(key, value.toSet())
|
||||
}
|
||||
|
||||
override suspend fun getStringList(key: String, default: List<String>): List<String> {
|
||||
return getStringSet(key, default.toSet()).toList()
|
||||
}
|
||||
|
||||
override suspend fun remove(key: String) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences.remove(stringPreferencesKey(key))
|
||||
preferences.remove(intPreferencesKey(key))
|
||||
preferences.remove(longPreferencesKey(key))
|
||||
preferences.remove(floatPreferencesKey(key))
|
||||
preferences.remove(booleanPreferencesKey(key))
|
||||
preferences.remove(stringSetPreferencesKey(key))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun contains(key: String): Boolean {
|
||||
return dataStore.data.map { preferences ->
|
||||
preferences.contains(stringPreferencesKey(key)) ||
|
||||
preferences.contains(intPreferencesKey(key)) ||
|
||||
preferences.contains(longPreferencesKey(key)) ||
|
||||
preferences.contains(floatPreferencesKey(key)) ||
|
||||
preferences.contains(booleanPreferencesKey(key)) ||
|
||||
preferences.contains(stringSetPreferencesKey(key))
|
||||
}.first()
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
dataStore.edit { preferences ->
|
||||
preferences.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actual val asyncSettings: AsyncSettings = AndroidAsyncSettings()
|
||||
+1
-2
@@ -12,6 +12,5 @@ import com.pr0gramm3r101.utils.UtilsLibrary
|
||||
object DataStoreSingleton {
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
||||
|
||||
val dataStore: DataStore<Preferences>
|
||||
get() = UtilsLibrary.context.dataStore
|
||||
val dataStore get() = UtilsLibrary.context.dataStore
|
||||
}
|
||||
|
||||
+2
@@ -1,3 +1,5 @@
|
||||
package com.pr0gramm3r101.utils.settings
|
||||
|
||||
actual val settings: Settings get() = AndroidSettings()
|
||||
actual val secureSettings: Settings
|
||||
get() = AndroidSecureSettings()
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.pr0gramm3r101.utils
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.NavController
|
||||
|
||||
/**
|
||||
@@ -21,18 +22,22 @@ fun NavController.navigateAndWipeBackStack(route: String, launchSingleTop: Boole
|
||||
val currentRoute = currentBackStackEntry?.destination?.route
|
||||
|
||||
// Navigate to the new route, removing the current screen as well
|
||||
if (currentRoute != null && currentRoute != route) {
|
||||
navigate(route) {
|
||||
if (currentRoute != null && currentRoute != route) {
|
||||
popUpTo(currentRoute) {
|
||||
inclusive = true
|
||||
}
|
||||
this.launchSingleTop = launchSingleTop
|
||||
}
|
||||
} else {
|
||||
// Fallback: just navigate if current route is same or null
|
||||
navigate(route) {
|
||||
this.launchSingleTop = launchSingleTop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline fun Modifier.conditional(
|
||||
condition: Boolean,
|
||||
`else`: Modifier.(Modifier) -> Modifier = { Modifier },
|
||||
`if`: Modifier.(Modifier) -> Modifier
|
||||
) = if (condition) {
|
||||
this + `if`(this)
|
||||
} else {
|
||||
this + `else`(this)
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package com.pr0gramm3r101.utils.settings
|
||||
|
||||
/**
|
||||
* Async version of Settings API using suspend functions
|
||||
*/
|
||||
interface AsyncSettings {
|
||||
suspend fun putString(key: String, value: String)
|
||||
suspend fun getString(key: String, default: String = ""): String
|
||||
|
||||
suspend fun putInt(key: String, value: Int)
|
||||
suspend fun getInt(key: String, default: Int = 0): Int
|
||||
|
||||
suspend fun putLong(key: String, value: Long)
|
||||
suspend fun getLong(key: String, default: Long = 0L): Long
|
||||
|
||||
suspend fun putFloat(key: String, value: Float)
|
||||
suspend fun getFloat(key: String, default: Float = 0f): Float
|
||||
|
||||
suspend fun putBoolean(key: String, value: Boolean)
|
||||
suspend fun getBoolean(key: String, default: Boolean = false): Boolean
|
||||
|
||||
suspend fun putStringSet(key: String, value: Set<String>)
|
||||
suspend fun getStringSet(key: String, default: Set<String> = emptySet()): Set<String>
|
||||
|
||||
suspend fun putStringList(key: String, value: List<String>)
|
||||
suspend fun getStringList(key: String, default: List<String> = emptyList()): List<String>
|
||||
|
||||
suspend fun remove(key: String)
|
||||
|
||||
suspend fun contains(key: String): Boolean
|
||||
suspend fun clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Global async settings instance
|
||||
*/
|
||||
expect val asyncSettings: AsyncSettings
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
package com.pr0gramm3r101.utils.settings
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
interface Settings {
|
||||
companion object {
|
||||
inline fun get() = settings
|
||||
@@ -26,8 +30,25 @@ interface Settings {
|
||||
suspend fun putStringSet(key: String, value: Set<String>)
|
||||
suspend fun getStringSet(key: String, default: Set<String> = emptySet()): Set<String>
|
||||
|
||||
suspend fun putStringList(key: String, value: List<String>)
|
||||
suspend fun getStringList(key: String, default: List<String> = emptyList()): List<String>
|
||||
suspend fun putStringList(key: String, value: List<String>) = withContext(Dispatchers.Default) {
|
||||
putString(key, Json.encodeToString(value))
|
||||
}
|
||||
|
||||
suspend fun getStringList(key: String, default: List<String> = emptyList()) = withContext(Dispatchers.Default) {
|
||||
try {
|
||||
getString(key, "").let {
|
||||
if (it.isEmpty()) {
|
||||
default
|
||||
} else {
|
||||
Json.decodeFromString<List<String>>(it)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
default
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun remove(key: String)
|
||||
|
||||
suspend fun contains(key: String): Boolean
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
package com.pr0gramm3r101.utils.settings
|
||||
|
||||
expect val settings: Settings
|
||||
expect val secureSettings: Settings
|
||||
@@ -1,29 +0,0 @@
|
||||
package com.pr0gramm3r101.utils.storage
|
||||
|
||||
/**
|
||||
* Authentication token storage
|
||||
*/
|
||||
object AuthStorage {
|
||||
private val storage = NamespacedStorage("auth")
|
||||
|
||||
suspend fun getToken(): String? {
|
||||
val token = storage.getString("token")
|
||||
return token.ifEmpty { null }
|
||||
}
|
||||
|
||||
suspend fun setToken(token: String?) {
|
||||
if (token != null) {
|
||||
storage.putString("token", token)
|
||||
} else {
|
||||
storage.remove("token")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearToken() {
|
||||
storage.remove("token")
|
||||
}
|
||||
|
||||
suspend fun putString(key: String, value: String) = storage.putString(key, value)
|
||||
suspend fun getString(key: String, default: String = ""): String = storage.getString(key, default)
|
||||
suspend fun remove(key: String) = storage.remove(key)
|
||||
}
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
package com.pr0gramm3r101.utils.storage
|
||||
|
||||
/**
|
||||
* Server configuration data
|
||||
*/
|
||||
data class ServerConfigData(
|
||||
val serverUrl: String,
|
||||
val httpsEnabled: Boolean
|
||||
)
|
||||
|
||||
/**
|
||||
* Server configuration storage
|
||||
*/
|
||||
object ServerConfigStorage {
|
||||
private val storage = NamespacedStorage("server_config")
|
||||
|
||||
private const val SERVER_URL_KEY = "server_url"
|
||||
private const val HTTPS_ENABLED_KEY = "https_enabled"
|
||||
|
||||
suspend fun getServerUrl(): String? {
|
||||
val url = storage.getString(SERVER_URL_KEY)
|
||||
return url.ifEmpty { null }
|
||||
}
|
||||
|
||||
suspend fun setServerUrl(url: String) {
|
||||
storage.putString(SERVER_URL_KEY, url)
|
||||
}
|
||||
|
||||
suspend fun getHttpsEnabled(): Boolean? {
|
||||
return if (storage.contains(HTTPS_ENABLED_KEY)) {
|
||||
storage.getBoolean(HTTPS_ENABLED_KEY, true)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setHttpsEnabled(enabled: Boolean) {
|
||||
storage.putBoolean(HTTPS_ENABLED_KEY, enabled)
|
||||
}
|
||||
|
||||
suspend fun hasConfiguration(): Boolean {
|
||||
return getServerUrl() != null
|
||||
}
|
||||
|
||||
suspend fun getConfig(): ServerConfigData {
|
||||
val url = getServerUrl() ?: throw IllegalStateException("Server URL not found in storage")
|
||||
val https = getHttpsEnabled() ?: true
|
||||
return ServerConfigData(url, https)
|
||||
}
|
||||
|
||||
suspend fun saveConfig(config: ServerConfigData) {
|
||||
setServerUrl(config.serverUrl)
|
||||
setHttpsEnabled(config.httpsEnabled)
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package com.pr0gramm3r101.utils.storage
|
||||
|
||||
import com.pr0gramm3r101.utils.settings.AsyncSettings
|
||||
import com.pr0gramm3r101.utils.settings.asyncSettings
|
||||
|
||||
/**
|
||||
* Generic key-value storage using AsyncSettings
|
||||
*/
|
||||
class Storage(private val settings: AsyncSettings = asyncSettings) {
|
||||
suspend fun putString(key: String, value: String) = settings.putString(key, value)
|
||||
suspend fun getString(key: String, default: String = ""): String = settings.getString(key, default)
|
||||
|
||||
suspend fun putInt(key: String, value: Int) = settings.putInt(key, value)
|
||||
suspend fun getInt(key: String, default: Int = 0): Int = settings.getInt(key, default)
|
||||
|
||||
suspend fun putLong(key: String, value: Long) = settings.putLong(key, value)
|
||||
suspend fun getLong(key: String, default: Long = 0L): Long = settings.getLong(key, default)
|
||||
|
||||
suspend fun putFloat(key: String, value: Float) = settings.putFloat(key, value)
|
||||
suspend fun getFloat(key: String, default: Float = 0f): Float = settings.getFloat(key, default)
|
||||
|
||||
suspend fun putBoolean(key: String, value: Boolean) = settings.putBoolean(key, value)
|
||||
suspend fun getBoolean(key: String, default: Boolean = false): Boolean = settings.getBoolean(key, default)
|
||||
|
||||
suspend fun putStringSet(key: String, value: Set<String>) = settings.putStringSet(key, value)
|
||||
suspend fun getStringSet(key: String, default: Set<String> = emptySet()): Set<String> = settings.getStringSet(key, default)
|
||||
|
||||
suspend fun putStringList(key: String, value: List<String>) = settings.putStringList(key, value)
|
||||
suspend fun getStringList(key: String, default: List<String> = emptyList()): List<String> = settings.getStringList(key, default)
|
||||
|
||||
suspend fun remove(key: String) = settings.remove(key)
|
||||
suspend fun contains(key: String): Boolean = settings.contains(key)
|
||||
suspend fun clear() = settings.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Namespaced storage for specific features
|
||||
*/
|
||||
class NamespacedStorage(private val prefix: String, private val storage: Storage = Storage()) {
|
||||
private fun key(name: String) = "$prefix:$name"
|
||||
|
||||
suspend fun putString(name: String, value: String) = storage.putString(key(name), value)
|
||||
suspend fun getString(name: String, default: String = ""): String = storage.getString(key(name), default)
|
||||
|
||||
suspend fun putInt(name: String, value: Int) = storage.putInt(key(name), value)
|
||||
suspend fun getInt(name: String, default: Int = 0): Int = storage.getInt(key(name), default)
|
||||
|
||||
suspend fun putLong(name: String, value: Long) = storage.putLong(key(name), value)
|
||||
suspend fun getLong(name: String, default: Long = 0L): Long = storage.getLong(key(name), default)
|
||||
|
||||
suspend fun putFloat(name: String, value: Float) = storage.putFloat(key(name), value)
|
||||
suspend fun getFloat(name: String, default: Float = 0f): Float = storage.getFloat(key(name), default)
|
||||
|
||||
suspend fun putBoolean(name: String, value: Boolean) = storage.putBoolean(key(name), value)
|
||||
suspend fun getBoolean(name: String, default: Boolean = false): Boolean = storage.getBoolean(key(name), default)
|
||||
|
||||
suspend fun remove(name: String) = storage.remove(key(name))
|
||||
suspend fun contains(name: String): Boolean = storage.contains(key(name))
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package com.pr0gramm3r101.utils.settings
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import platform.Foundation.NSBundle
|
||||
import platform.Foundation.NSUserDefaults
|
||||
|
||||
class IosAsyncSettings : AsyncSettings {
|
||||
private val defaults: NSUserDefaults = NSUserDefaults.standardUserDefaults
|
||||
|
||||
override suspend fun putString(key: String, value: String): Unit = withContext(Dispatchers.Default) {
|
||||
defaults.setObject(value, key)
|
||||
defaults.synchronize()
|
||||
}
|
||||
|
||||
override suspend fun getString(key: String, default: String): String = withContext(Dispatchers.Default) {
|
||||
defaults.stringForKey(key) ?: default
|
||||
}
|
||||
|
||||
override suspend fun putInt(key: String, value: Int): Unit = withContext(Dispatchers.Default) {
|
||||
defaults.setInteger(value.toLong(), key)
|
||||
defaults.synchronize()
|
||||
}
|
||||
|
||||
override suspend fun getInt(key: String, default: Int): Int = withContext(Dispatchers.Default) {
|
||||
defaults.integerForKey(key).toInt()
|
||||
}
|
||||
|
||||
override suspend fun putLong(key: String, value: Long): Unit = withContext(Dispatchers.Default) {
|
||||
defaults.setObject(value, key)
|
||||
defaults.synchronize()
|
||||
}
|
||||
|
||||
override suspend fun getLong(key: String, default: Long): Long = withContext(Dispatchers.Default) {
|
||||
(defaults.objectForKey(key) as? platform.Foundation.NSNumber)?.longValue ?: default
|
||||
}
|
||||
|
||||
override suspend fun putFloat(key: String, value: Float): Unit = withContext(Dispatchers.Default) {
|
||||
defaults.setFloat(value, key)
|
||||
defaults.synchronize()
|
||||
}
|
||||
|
||||
override suspend fun getFloat(key: String, default: Float): Float = withContext(Dispatchers.Default) {
|
||||
defaults.floatForKey(key)
|
||||
}
|
||||
|
||||
override suspend fun putBoolean(key: String, value: Boolean): Unit = withContext(Dispatchers.Default) {
|
||||
defaults.setBool(value, key)
|
||||
defaults.synchronize()
|
||||
}
|
||||
|
||||
override suspend fun getBoolean(key: String, default: Boolean): Boolean = withContext(Dispatchers.Default) {
|
||||
if (defaults.objectForKey(key) != null) {
|
||||
defaults.boolForKey(key)
|
||||
} else {
|
||||
default
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun putStringSet(key: String, value: Set<String>): Unit = withContext(Dispatchers.Default) {
|
||||
defaults.setObject(value.toList(), key)
|
||||
defaults.synchronize()
|
||||
}
|
||||
|
||||
override suspend fun getStringSet(key: String, default: Set<String>): Set<String> = withContext(Dispatchers.Default) {
|
||||
val list = defaults.objectForKey(key) as? List<*> ?: return@withContext default
|
||||
list.filterIsInstance<String>().toSet()
|
||||
}
|
||||
|
||||
override suspend fun putStringList(key: String, value: List<String>): Unit = withContext(Dispatchers.Default) {
|
||||
defaults.setObject(value, key)
|
||||
defaults.synchronize()
|
||||
}
|
||||
|
||||
override suspend fun getStringList(key: String, default: List<String>): List<String> = withContext(Dispatchers.Default) {
|
||||
val list = defaults.objectForKey(key) as? List<*> ?: return@withContext default
|
||||
list.filterIsInstance<String>()
|
||||
}
|
||||
|
||||
override suspend fun remove(key: String): Unit = withContext(Dispatchers.Default) {
|
||||
defaults.removeObjectForKey(key)
|
||||
defaults.synchronize()
|
||||
}
|
||||
|
||||
override suspend fun contains(key: String): Boolean = withContext(Dispatchers.Default) {
|
||||
defaults.objectForKey(key) != null
|
||||
}
|
||||
|
||||
override suspend fun clear(): Unit = withContext(Dispatchers.Default) {
|
||||
val bundleId = NSBundle.mainBundle.bundleIdentifier ?: ""
|
||||
val domain = defaults.persistentDomainForName(bundleId)
|
||||
domain?.keys?.forEach { key ->
|
||||
defaults.removeObjectForKey(key as String)
|
||||
}
|
||||
defaults.synchronize()
|
||||
}
|
||||
}
|
||||
|
||||
actual val asyncSettings: AsyncSettings = IosAsyncSettings()
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.pr0gramm3r101.utils.settings
|
||||
|
||||
import com.russhwolf.settings.ExperimentalSettingsApi
|
||||
import com.russhwolf.settings.ExperimentalSettingsImplementation
|
||||
import com.russhwolf.settings.KeychainSettings
|
||||
import com.russhwolf.settings.coroutines.toSuspendSettings
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalSettingsApi::class, ExperimentalSettingsImplementation::class)
|
||||
class IosSecureSettings : Settings {
|
||||
private val suspendSettings = KeychainSettings(service = "ru.fromchat.secure").toSuspendSettings()
|
||||
|
||||
override suspend fun putString(key: String, value: String) = suspendSettings.putString(key, value)
|
||||
|
||||
override suspend fun getString(key: String, default: String) = suspendSettings.getString(key, default)
|
||||
|
||||
override suspend fun putInt(key: String, value: Int) = suspendSettings.putInt(key, value)
|
||||
|
||||
override suspend fun getInt(key: String, default: Int) = suspendSettings.getInt(key, default)
|
||||
|
||||
override suspend fun putLong(key: String, value: Long) = suspendSettings.putLong(key, value)
|
||||
|
||||
override suspend fun getLong(key: String, default: Long) = suspendSettings.getLong(key, default)
|
||||
|
||||
override suspend fun putFloat(key: String, value: Float) = suspendSettings.putFloat(key, value)
|
||||
|
||||
override suspend fun getFloat(key: String, default: Float) = suspendSettings.getFloat(key, default)
|
||||
|
||||
override suspend fun putBoolean(key: String, value: Boolean) = suspendSettings.putBoolean(key, value)
|
||||
|
||||
override suspend fun getBoolean(key: String, default: Boolean) = suspendSettings.getBoolean(key, default)
|
||||
|
||||
override suspend fun putStringSet(key: String, value: Set<String>) = withContext(Dispatchers.IO) {
|
||||
putStringList(key, value.toList())
|
||||
}
|
||||
|
||||
override suspend fun getStringSet(key: String, default: Set<String>) = withContext(Dispatchers.IO) {
|
||||
getStringList(key, default.toList()).toSet()
|
||||
}
|
||||
|
||||
override suspend fun remove(key: String) = suspendSettings.remove(key)
|
||||
|
||||
override suspend fun contains(key: String) = suspendSettings.hasKey(key)
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.pr0gramm3r101.utils.settings
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.withContext
|
||||
import platform.Foundation.NSUserDefaults
|
||||
|
||||
// TODO fix
|
||||
class IosSettings : Settings {
|
||||
private val defaults: NSUserDefaults = NSUserDefaults.standardUserDefaults
|
||||
|
||||
@@ -42,4 +44,8 @@ class IosSettings : Settings {
|
||||
}
|
||||
|
||||
override suspend fun remove(key: String) = defaults.removeObjectForKey(key)
|
||||
|
||||
override suspend fun contains(key: String): Boolean = withContext(Dispatchers.IO) {
|
||||
defaults.objectForKey(key) != null
|
||||
}
|
||||
}
|
||||
+2
@@ -1,3 +1,5 @@
|
||||
package com.pr0gramm3r101.utils.settings
|
||||
|
||||
actual val settings: Settings get() = IosSettings()
|
||||
actual val secureSettings: Settings
|
||||
get() = IosSecureSettings()
|
||||
Reference in New Issue
Block a user