diff --git a/.idea/artifacts/utils.xml b/.idea/artifacts/utils.xml
new file mode 100644
index 0000000..4b6a6a6
--- /dev/null
+++ b/.idea/artifacts/utils.xml
@@ -0,0 +1,8 @@
+
+
+ $PROJECT_DIR$/utils/build/libs
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 639c779..031d3d1 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -10,7 +10,8 @@
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
deleted file mode 100644
index 1d96489..0000000
--- a/app/build.gradle.kts
+++ /dev/null
@@ -1,91 +0,0 @@
-
-import org.jetbrains.kotlin.gradle.dsl.JvmTarget
-import java.io.FileInputStream
-import java.util.Properties
-
-plugins {
- alias(libs.plugins.android.application)
- alias(libs.plugins.kotlin.android)
- alias(libs.plugins.kotlin.compose)
- alias(libs.plugins.kotlin.serialization)
-}
-
-kotlin {
- compilerOptions {
- jvmTarget.set(JvmTarget.JVM_11)
- freeCompilerArgs.add("-Xwarning-level=NOTHING_TO_INLINE:disabled")
- }
-}
-
-android {
- namespace = "ru.fromchat"
- compileSdk = 36
-
- defaultConfig {
- applicationId = "ru.fromchat"
- minSdk = 24
- targetSdk = 36
- versionCode = 1
- versionName = "1.0"
- }
-
- signingConfigs {
- create("release") {
- val keystoreProperties = Properties().apply {
- load(FileInputStream(rootProject.file("keys/keystore.properties")))
- }
- storeFile = rootProject.file("keys/release.jks")
- keyAlias = "release"
- storePassword = keystoreProperties["storePassword"].toString()
- keyPassword = keystoreProperties["keyPassword"].toString()
- enableV3Signing = true
- }
- }
-
- buildTypes {
- release {
- isMinifyEnabled = true
- isShrinkResources = true
- signingConfig = signingConfigs.getByName("release")
-
- proguardFiles(
- getDefaultProguardFile("proguard-android-optimize.txt"),
- "proguard-rules.pro"
- )
- }
- }
-
- compileOptions {
- sourceCompatibility = JavaVersion.VERSION_11
- targetCompatibility = JavaVersion.VERSION_11
- }
-
- buildFeatures {
- compose = true
- }
-}
-
-dependencies {
- implementation(libs.androidx.core.ktx)
- implementation(libs.androidx.lifecycle.runtime.ktx)
- implementation(libs.androidx.activity.compose)
- implementation(platform(libs.androidx.compose.bom))
- implementation(libs.androidx.ui)
- implementation(libs.androidx.ui.graphics)
- implementation(libs.androidx.ui.tooling.preview)
- implementation(libs.androidx.material3)
- implementation(libs.androidx.navigation.compose)
- implementation(libs.androidx.material.icons.extended)
- implementation(libs.kotlinx.datetime)
-
- implementation(libs.ktor.client.core)
- implementation(libs.ktor.client.okhttp)
- implementation(libs.ktor.client.websockets)
- implementation(libs.ktor.client.logging)
- implementation(libs.ktor.client.content.negotiation)
- implementation(libs.ktor.serialization.json)
- implementation(libs.kotlinx.serialization.json)
- implementation(libs.haze)
-
- debugImplementation(libs.androidx.ui.tooling)
-}
\ No newline at end of file
diff --git a/app/src/androidTest/java/ru/fromchat/ExampleInstrumentedTest.kt b/app/src/androidTest/java/ru/fromchat/ExampleInstrumentedTest.kt
deleted file mode 100644
index 1bc1fbd..0000000
--- a/app/src/androidTest/java/ru/fromchat/ExampleInstrumentedTest.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-package ru.fromchat
-
-import androidx.test.platform.app.InstrumentationRegistry
-import androidx.test.ext.junit.runners.AndroidJUnit4
-
-import org.junit.Test
-import org.junit.runner.RunWith
-
-import org.junit.Assert.*
-
-/**
- * Instrumented test, which will execute on an Android device.
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-@RunWith(AndroidJUnit4::class)
-class ExampleInstrumentedTest {
- @Test
- fun useAppContext() {
- // Context of the app under test.
- val appContext = InstrumentationRegistry.getInstrumentation().targetContext
- assertEquals("ru.fromchat", appContext.packageName)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/ru/fromchat/utils/HashUtils.kt b/app/src/main/java/ru/fromchat/utils/HashUtils.kt
deleted file mode 100644
index 5e4d378..0000000
--- a/app/src/main/java/ru/fromchat/utils/HashUtils.kt
+++ /dev/null
@@ -1,69 +0,0 @@
-package ru.fromchat.utils
-
-import android.util.Base64
-import javax.crypto.Mac
-import javax.crypto.spec.SecretKeySpec
-
-object HashUtils {
- /**
- * Derive authentication secret using HKDF (HMAC-based Key Derivation Function)
- * Matches the frontend implementation in deriveAuthSecret
- *
- * @param username The username
- * @param password The plain password
- * @return Base64-encoded 32-byte derived secret
- */
- fun deriveAuthSecret(username: String, password: String): String {
- val salt = "fromchat.user:$username".toByteArray(Charsets.UTF_8)
- val ikm = password.toByteArray(Charsets.UTF_8)
- val info = "auth-secret".toByteArray(Charsets.UTF_8)
- val length = 32
-
- val prk = hkdfExtract(salt, ikm)
- val okm = hkdfExpand(prk, info, length)
-
- return Base64.encodeToString(okm, Base64.NO_WRAP)
- }
-
- /**
- * HKDF Extract step: PRK = HMAC-Hash(salt, IKM)
- */
- private fun hkdfExtract(salt: ByteArray, ikm: ByteArray): ByteArray {
- val mac = Mac.getInstance("HmacSHA256")
- val keySpec = SecretKeySpec(salt, "HmacSHA256")
- mac.init(keySpec)
- return mac.doFinal(ikm)
- }
-
- /**
- * HKDF Expand step: OKM = HKDF-Expand(PRK, info, L)
- */
- private fun hkdfExpand(prk: ByteArray, info: ByteArray, length: Int): ByteArray {
- val blocks = mutableListOf()
- var previous = ByteArray(0)
- var counter = 1
-
- while (blocks.sumOf { it.size } < length) {
- val mac = Mac.getInstance("HmacSHA256")
- val keySpec = SecretKeySpec(prk, "HmacSHA256")
- mac.init(keySpec)
-
- val input = previous + info + byteArrayOf(counter.toByte())
- previous = mac.doFinal(input)
- blocks.add(previous)
- counter++
- }
-
- val result = ByteArray(length)
- var offset = 0
- for (block in blocks) {
- val toCopy = minOf(block.size, length - offset)
- System.arraycopy(block, 0, result, offset, toCopy)
- offset += toCopy
- if (offset >= length) break
- }
-
- return result
- }
-}
-
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp
deleted file mode 100644
index c209e78..0000000
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
deleted file mode 100644
index b2dfe3d..0000000
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp
deleted file mode 100644
index 4f0f1d6..0000000
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
deleted file mode 100644
index 62b611d..0000000
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
deleted file mode 100644
index 948a307..0000000
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
deleted file mode 100644
index 1b9a695..0000000
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
deleted file mode 100644
index 28d4b77..0000000
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
deleted file mode 100644
index 9287f50..0000000
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
deleted file mode 100644
index aa7d642..0000000
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
deleted file mode 100644
index 9126ae3..0000000
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/build.gradle.kts b/build.gradle.kts
index 5ba8ae0..2a710f8 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,7 +1,11 @@
-// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
- alias(libs.plugins.android.application) apply false
- alias(libs.plugins.kotlin.android) apply false
- alias(libs.plugins.kotlin.compose) apply false
- alias(libs.plugins.kotlin.serialization) apply false
+ // this is necessary to avoid the plugins to be loaded multiple times
+ // in each subproject's classloader
+ alias(libs.plugins.androidApplication) apply false
+ alias(libs.plugins.androidLibrary) apply false
+ alias(libs.plugins.composeMultiplatform) apply false
+ alias(libs.plugins.composeCompiler) apply false
+ alias(libs.plugins.kotlinMultiplatform) apply false
+ alias(libs.plugins.androidKotlinMultiplatformLibrary) apply false
+ alias(libs.plugins.jetbrains.kotlin.android) apply false
}
\ No newline at end of file
diff --git a/app/.gitignore b/composeApp/.gitignore
similarity index 100%
rename from app/.gitignore
rename to composeApp/.gitignore
diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts
new file mode 100644
index 0000000..9976c03
--- /dev/null
+++ b/composeApp/build.gradle.kts
@@ -0,0 +1,146 @@
+import java.io.FileInputStream
+import java.util.Properties
+
+plugins {
+ alias(libs.plugins.kotlinMultiplatform)
+ alias(libs.plugins.androidApplication)
+ alias(libs.plugins.composeMultiplatform)
+ alias(libs.plugins.composeCompiler)
+ kotlin("plugin.serialization") version "1.9.22"
+}
+
+kotlin {
+ androidTarget()
+
+ compilerOptions {
+ freeCompilerArgs.addAll("-Xexpect-actual-classes")
+ }
+
+ listOf(
+ iosX64(),
+ iosArm64(),
+ iosSimulatorArm64()
+ ).forEach { iosTarget ->
+ iosTarget.binaries.framework {
+ baseName = "ComposeApp"
+ isStatic = true
+ }
+ }
+
+ sourceSets {
+ androidMain.dependencies {
+ implementation(compose.preview)
+ implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.core.splashscreen)
+ implementation(libs.androidx.adaptive.android)
+ implementation(libs.ktor.client.okhttp)
+ implementation(libs.slf4j.android)
+ }
+
+ commonMain.dependencies {
+ implementation(compose.runtime)
+ implementation(compose.foundation)
+ implementation(compose.material3)
+ implementation(compose.ui)
+ implementation(compose.components.resources)
+ implementation(compose.components.uiToolingPreview)
+ implementation(libs.constraintlayout)
+ implementation(libs.navigation.compose)
+ implementation(compose.materialIconsExtended)
+
+ // Serialization
+ implementation(libs.kotlinx.serialization.json)
+
+ implementation(libs.kotlinx.io.core)
+
+ // Ktor
+ implementation(libs.ktor.client.core)
+ implementation(libs.ktor.client.content.negotiation)
+ implementation(libs.ktor.client.serialization.kotlinx.json)
+ implementation(libs.ktor.client.websockets)
+ implementation(libs.ktor.client.logging)
+
+ // Datetime
+ implementation(libs.kotlinx.datetime)
+
+ implementation(project(":utils"))
+ }
+
+ iosMain.dependencies {
+ implementation(libs.jetbrains.kotlinx.io.bytestring)
+ implementation(libs.jetbrains.kotlinx.coroutines.core)
+ implementation(libs.ktor.client.darwin)
+ }
+ }
+}
+
+android {
+ namespace = "ru.fromchat"
+ compileSdk = 36
+
+ defaultConfig {
+ applicationId = "ru.fromchat"
+ minSdk = 24
+ targetSdk = 36
+ versionCode = 1
+ versionName = "1.0"
+ }
+
+ signingConfigs {
+ create("release") {
+ val keystoreProperties = Properties().apply {
+ load(FileInputStream(rootProject.file("keys/keystore.properties")))
+ }
+ storeFile = rootProject.file("keys/release.jks")
+ keyAlias = "release"
+ storePassword = keystoreProperties["storePassword"].toString()
+ keyPassword = keystoreProperties["keyPassword"].toString()
+ enableV3Signing = true
+ }
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = true
+ isShrinkResources = true
+ signingConfig = signingConfigs.getByName("release")
+
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+}
+
+dependencies {
+ debugImplementation(compose.uiTooling)
+}
+
+compose.resources {
+ publicResClass = false
+ packageOfResClass = "ru.fromchat"
+ generateResClass = auto
+}
+
+tasks.register("generateResourceAccessors") {
+ dependsOn(
+ *(
+ tasks.filter {
+ it.name.startsWith("generateResourceAccessors") &&
+ !it.name.matches("^(:${project.name})?generateResourceAccessors$".toRegex())
+ }.toTypedArray()
+ )
+ )
+}
\ No newline at end of file
diff --git a/app/proguard-rules.pro b/composeApp/proguard-rules.pro
similarity index 100%
rename from app/proguard-rules.pro
rename to composeApp/proguard-rules.pro
diff --git a/app/src/main/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml
similarity index 100%
rename from app/src/main/AndroidManifest.xml
rename to composeApp/src/androidMain/AndroidManifest.xml
diff --git a/app/src/main/java/ru/fromchat/MainActivity.kt b/composeApp/src/androidMain/kotlin/ru/fromchat/MainActivity.kt
similarity index 99%
rename from app/src/main/java/ru/fromchat/MainActivity.kt
rename to composeApp/src/androidMain/kotlin/ru/fromchat/MainActivity.kt
index 1506a1c..dd3140b 100644
--- a/app/src/main/java/ru/fromchat/MainActivity.kt
+++ b/composeApp/src/androidMain/kotlin/ru/fromchat/MainActivity.kt
@@ -14,4 +14,4 @@ class MainActivity : ComponentActivity() {
App()
}
}
-}
\ No newline at end of file
+}
diff --git a/composeApp/src/androidMain/kotlin/ru/fromchat/api/AndroidKtorLogger.kt b/composeApp/src/androidMain/kotlin/ru/fromchat/api/AndroidKtorLogger.kt
new file mode 100644
index 0000000..0c4aa07
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/ru/fromchat/api/AndroidKtorLogger.kt
@@ -0,0 +1,16 @@
+package ru.fromchat.api
+
+import android.util.Log
+import io.ktor.client.plugins.logging.Logger
+
+/**
+ * Custom Android logger for Ktor that uses Android's Log system
+ */
+object AndroidKtorLogger : Logger {
+ private const val TAG = "Ktor"
+
+ override fun log(message: String) {
+ Log.d(TAG, message)
+ }
+}
+
diff --git a/composeApp/src/androidMain/kotlin/ru/fromchat/api/ApiClient.android.kt b/composeApp/src/androidMain/kotlin/ru/fromchat/api/ApiClient.android.kt
new file mode 100644
index 0000000..dbc758c
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/ru/fromchat/api/ApiClient.android.kt
@@ -0,0 +1,13 @@
+package ru.fromchat.api
+
+import io.ktor.client.HttpClient
+import io.ktor.client.HttpClientConfig
+import io.ktor.client.engine.okhttp.OkHttp
+import io.ktor.client.engine.okhttp.OkHttpConfig
+
+actual fun createPlatformHttpClient(
+ block: HttpClientConfig<*>.() -> Unit
+): HttpClient {
+ @Suppress("UNCHECKED_CAST")
+ return HttpClient(OkHttp, block as HttpClientConfig.() -> Unit)
+}
diff --git a/composeApp/src/androidMain/kotlin/ru/fromchat/core/Logger.android.kt b/composeApp/src/androidMain/kotlin/ru/fromchat/core/Logger.android.kt
new file mode 100644
index 0000000..9f132a3
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/ru/fromchat/core/Logger.android.kt
@@ -0,0 +1,21 @@
+package ru.fromchat.core
+
+import android.util.Log
+
+actual object Logger {
+ actual fun d(tag: String, message: String, throwable: Throwable?) {
+ Log.d(tag, message, throwable)
+ }
+
+ actual fun i(tag: String, message: String, throwable: Throwable?) {
+ Log.i(tag, message, throwable)
+ }
+
+ actual fun w(tag: String, message: String, throwable: Throwable?) {
+ Log.w(tag, message, throwable)
+ }
+
+ actual fun e(tag: String, message: String, throwable: Throwable?) {
+ Log.e(tag, message, throwable)
+ }
+}
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml
similarity index 100%
rename from app/src/main/res/drawable/ic_launcher_background.xml
rename to composeApp/src/androidMain/res/drawable/ic_launcher_background.xml
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/composeApp/src/androidMain/res/drawable/ic_launcher_foreground.xml
similarity index 100%
rename from app/src/main/res/drawable/ic_launcher_foreground.xml
rename to composeApp/src/androidMain/res/drawable/ic_launcher_foreground.xml
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml
similarity index 100%
rename from app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
rename to composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml
similarity index 100%
rename from app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
rename to composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml
diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.webp b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 0000000..e69de29
diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.webp b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..e69de29
diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.webp b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 0000000..e69de29
diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.webp b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..e69de29
diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.webp b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 0000000..e69de29
diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.webp b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..e69de29
diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.webp b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..e69de29
diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.webp b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..e69de29
diff --git a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.webp b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..e69de29
diff --git a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.webp b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..e69de29
diff --git a/app/src/main/res/values/colors.xml b/composeApp/src/androidMain/res/values/colors.xml
similarity index 100%
rename from app/src/main/res/values/colors.xml
rename to composeApp/src/androidMain/res/values/colors.xml
diff --git a/app/src/main/res/values/strings.xml b/composeApp/src/androidMain/res/values/strings.xml
similarity index 100%
rename from app/src/main/res/values/strings.xml
rename to composeApp/src/androidMain/res/values/strings.xml
diff --git a/app/src/main/res/values/themes.xml b/composeApp/src/androidMain/res/values/themes.xml
similarity index 100%
rename from app/src/main/res/values/themes.xml
rename to composeApp/src/androidMain/res/values/themes.xml
diff --git a/app/src/main/res/xml/backup_rules.xml b/composeApp/src/androidMain/res/xml/backup_rules.xml
similarity index 100%
rename from app/src/main/res/xml/backup_rules.xml
rename to composeApp/src/androidMain/res/xml/backup_rules.xml
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/composeApp/src/androidMain/res/xml/data_extraction_rules.xml
similarity index 100%
rename from app/src/main/res/xml/data_extraction_rules.xml
rename to composeApp/src/androidMain/res/xml/data_extraction_rules.xml
diff --git a/app/src/main/java/ru/fromchat/Constants.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/Constants.kt
similarity index 100%
rename from app/src/main/java/ru/fromchat/Constants.kt
rename to composeApp/src/commonMain/kotlin/ru/fromchat/Constants.kt
diff --git a/app/src/main/java/ru/fromchat/api/ApiClient.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
similarity index 85%
rename from app/src/main/java/ru/fromchat/api/ApiClient.kt
rename to composeApp/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
index 0edf560..66dc638 100644
--- a/app/src/main/java/ru/fromchat/api/ApiClient.kt
+++ b/composeApp/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
@@ -1,12 +1,12 @@
package ru.fromchat.api
-import android.util.Log
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
+import io.ktor.client.plugins.logging.SIMPLE
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.request.bearerAuth
import io.ktor.client.request.delete
@@ -20,6 +20,15 @@ import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import ru.fromchat.API_HOST
import ru.fromchat.utils.failOnError
+import kotlin.concurrent.Volatile
+
+/**
+ * Creates a platform-specific HTTP client that supports WebSockets
+ * The config block is applied to configure plugins like WebSockets, JSON, etc.
+ */
+expect fun createPlatformHttpClient(
+ block: io.ktor.client.HttpClientConfig<*>.() -> Unit = {}
+): HttpClient
object ApiClient {
val json = Json {
@@ -28,19 +37,14 @@ object ApiClient {
encodeDefaults = true
}
- val http = HttpClient {
+ val http = createPlatformHttpClient {
install(ContentNegotiation) {
json(json)
}
install(Logging) {
+ logger = Logger.SIMPLE
level = LogLevel.INFO
- logger = object : Logger {
- override fun log(message: String) {
- // Replace with Logcat if needed
- println(message)
- }
- }
}
install(WebSockets)
@@ -58,7 +62,7 @@ object ApiClient {
http
.post("$API_HOST/api/login") {
contentType(ContentType.Application.Json)
- setBody(request.also { Log.d("ApiClient", "Login request: $it") })
+ setBody(request.also { ru.fromchat.core.Logger.d("ApiClient", "Login request: $it") })
}
.failOnError()
.body()
diff --git a/app/src/main/java/ru/fromchat/api/Models.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/api/Models.kt
similarity index 100%
rename from app/src/main/java/ru/fromchat/api/Models.kt
rename to composeApp/src/commonMain/kotlin/ru/fromchat/api/Models.kt
diff --git a/app/src/main/java/ru/fromchat/api/Utils.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/api/Utils.kt
similarity index 84%
rename from app/src/main/java/ru/fromchat/api/Utils.kt
rename to composeApp/src/commonMain/kotlin/ru/fromchat/api/Utils.kt
index 34948c0..48b5ec2 100644
--- a/app/src/main/java/ru/fromchat/api/Utils.kt
+++ b/composeApp/src/commonMain/kotlin/ru/fromchat/api/Utils.kt
@@ -1,6 +1,6 @@
package ru.fromchat.api
-import android.util.Log
+import ru.fromchat.core.Logger
import io.ktor.client.call.body
import io.ktor.client.plugins.ClientRequestException
@@ -20,12 +20,12 @@ suspend inline fun apiRequest(
"Unexpected error"
}
- Log.e("API", "API request failed: $message, exception:", e)
+ Logger.e("API", "API request failed: $message", e)
onError(message, e)
return Result.failure(e)
} catch (e: Exception) {
- Log.e("API", "API request failed:", e)
+ Logger.e("API", "API request failed: ${e.message}", e)
onError("Unexpected error", e)
return Result.failure(e)
}
diff --git a/app/src/main/java/ru/fromchat/api/WebSocketManager.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/api/WebSocketManager.kt
similarity index 82%
rename from app/src/main/java/ru/fromchat/api/WebSocketManager.kt
rename to composeApp/src/commonMain/kotlin/ru/fromchat/api/WebSocketManager.kt
index 6a1503e..e0916be 100644
--- a/app/src/main/java/ru/fromchat/api/WebSocketManager.kt
+++ b/composeApp/src/commonMain/kotlin/ru/fromchat/api/WebSocketManager.kt
@@ -1,6 +1,6 @@
package ru.fromchat.api
-import android.util.Log
+import ru.fromchat.core.Logger
import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.client.request.url
@@ -45,7 +45,7 @@ object WebSocketManager {
@Volatile private var session: DefaultClientWebSocketSession? = null
fun connect() {
- Log.d("WebSocketManager", "Connecting to WebSocket")
+ Logger.d("WebSocketManager", "Connecting to WebSocket")
if (connecting) return
connecting = true
@@ -61,27 +61,27 @@ object WebSocketManager {
session = this
connecting = false
- Log.d("WebSocketManager", "WebSocket connected")
+ Logger.d("WebSocketManager", "WebSocket connected")
for (frame in incoming) {
val text = (frame as? Frame.Text)?.readText() ?: continue
- Log.d("WebSocketManager", "Received payload: $text")
+ Logger.d("WebSocketManager", "Received payload: $text")
try {
val msg = json.decodeFromString(text)
globalHandlers.forEach { it(msg) }
_messages.emit(msg)
} catch (e: Throwable) {
- Log.w("WebSocketManager", "Received malformed payload:", e)
+ Logger.w("WebSocketManager", "Received malformed payload: ${e.message}", e)
// ignore malformed
}
}
}
} catch (e: Throwable) {
- Log.w("WebSocketManager", "An error occurred:", e)
+ Logger.w("WebSocketManager", "An error occurred: ${e.message}", e)
connecting = false
session = null
delay(3000)
} finally {
- Log.w("WebSocketManager", "WebSocket disconnected")
+ Logger.w("WebSocketManager", "WebSocket disconnected")
session = null
connecting = false
}
@@ -95,24 +95,24 @@ object WebSocketManager {
try {
session.send(Frame.Text(json.encodeToString(message)))
} catch (e: Exception) {
- Log.e("WebSocketManager", "Failed to send message", e)
+ Logger.e("WebSocketManager", "Failed to send message: ${e.message}", e)
throw e
}
} else {
- Log.w("WebSocketManager", "Cannot send message: no active session")
+ Logger.w("WebSocketManager", "Cannot send message: no active session")
throw IllegalStateException("No active WebSocket session")
}
}
@OptIn(DelicateCoroutinesApi::class)
suspend fun request(message: WebSocketMessage, timeoutMs: Long = 10_000): WebSocketMessage? {
- Log.d("WebSocketManager", "WebSocket request: $message")
+ Logger.d("WebSocketManager", "WebSocket request: $message")
var handler: ((WebSocketMessage) -> Unit)? = null
return try {
// Check if we have a valid session before sending
if (session == null) {
- Log.w("WebSocketManager", "No active WebSocket session")
+ Logger.w("WebSocketManager", "No active WebSocket session")
return null
}
@@ -130,10 +130,10 @@ object WebSocketManager {
}
}
} catch (_: TimeoutCancellationException) {
- Log.w("WebSocketManager", "Request timed out")
+ Logger.w("WebSocketManager", "Request timed out")
null
} catch (e: Exception) {
- Log.e("WebSocketManager", "Request failed", e)
+ Logger.e("WebSocketManager", "Request failed: ${e.message}", e)
null
} finally {
handler?.let { removeGlobalMessageHandler(it) }
diff --git a/composeApp/src/commonMain/kotlin/ru/fromchat/core/Logger.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/core/Logger.kt
new file mode 100644
index 0000000..35a0c07
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/ru/fromchat/core/Logger.kt
@@ -0,0 +1,8 @@
+package ru.fromchat.core
+
+expect object Logger {
+ fun d(tag: String, message: String, throwable: Throwable? = null)
+ fun i(tag: String, message: String, throwable: Throwable? = null)
+ fun w(tag: String, message: String, throwable: Throwable? = null)
+ fun e(tag: String, message: String, throwable: Throwable? = null)
+}
diff --git a/app/src/main/java/ru/fromchat/ui/App.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/App.kt
similarity index 100%
rename from app/src/main/java/ru/fromchat/ui/App.kt
rename to composeApp/src/commonMain/kotlin/ru/fromchat/ui/App.kt
diff --git a/app/src/main/java/ru/fromchat/ui/Components.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/Components.kt
similarity index 100%
rename from app/src/main/java/ru/fromchat/ui/Components.kt
rename to composeApp/src/commonMain/kotlin/ru/fromchat/ui/Components.kt
diff --git a/app/src/main/java/ru/fromchat/ui/Theme.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/Theme.kt
similarity index 85%
rename from app/src/main/java/ru/fromchat/ui/Theme.kt
rename to composeApp/src/commonMain/kotlin/ru/fromchat/ui/Theme.kt
index 940808d..898789b 100644
--- a/app/src/main/java/ru/fromchat/ui/Theme.kt
+++ b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/Theme.kt
@@ -1,15 +1,11 @@
package ru.fromchat.ui
-import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
-import androidx.compose.material3.dynamicDarkColorScheme
-import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFFDBB9F9),
@@ -90,18 +86,10 @@ private val LightColorScheme = lightColorScheme(
@Composable
fun FromChatTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
- dynamicColor: Boolean = true,
+ dynamicColor: Boolean = false, // Disabled for multiplatform compatibility
content: @Composable () -> Unit
) {
- val colorScheme = when {
- dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
- val context = LocalContext.current
- if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
- }
-
- darkTheme -> DarkColorScheme
- else -> LightColorScheme
- }
+ val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
MaterialTheme(
colorScheme = colorScheme,
diff --git a/app/src/main/java/ru/fromchat/ui/auth/LoginScreen.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/auth/LoginScreen.kt
similarity index 96%
rename from app/src/main/java/ru/fromchat/ui/auth/LoginScreen.kt
rename to composeApp/src/commonMain/kotlin/ru/fromchat/ui/auth/LoginScreen.kt
index 639a1dd..b0c1b54 100644
--- a/app/src/main/java/ru/fromchat/ui/auth/LoginScreen.kt
+++ b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/auth/LoginScreen.kt
@@ -33,7 +33,7 @@ import ru.fromchat.api.ApiClient
import ru.fromchat.api.LoginRequest
import ru.fromchat.api.apiRequest
import ru.fromchat.ui.RowHeader
-import ru.fromchat.utils.HashUtils
+import com.pr0gramm3r101.utils.crypto.deriveAuthSecret
@Composable
fun LoginScreen(
@@ -98,7 +98,7 @@ fun LoginScreen(
// Derive auth secret before sending (matches frontend implementation)
scope.launch {
- val derived = HashUtils.deriveAuthSecret(username.trim(), password.trim())
+ val derived = deriveAuthSecret(username.trim(), password.trim())
apiRequest(
onError = { message, _ ->
diff --git a/app/src/main/java/ru/fromchat/ui/auth/RegisterScreen.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/auth/RegisterScreen.kt
similarity index 98%
rename from app/src/main/java/ru/fromchat/ui/auth/RegisterScreen.kt
rename to composeApp/src/commonMain/kotlin/ru/fromchat/ui/auth/RegisterScreen.kt
index 9f2cef4..4d33f9e 100644
--- a/app/src/main/java/ru/fromchat/ui/auth/RegisterScreen.kt
+++ b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/auth/RegisterScreen.kt
@@ -37,7 +37,7 @@ import ru.fromchat.api.RegisterRequest
import ru.fromchat.api.apiRequest
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.RowHeader
-import ru.fromchat.utils.HashUtils
+import com.pr0gramm3r101.utils.crypto.deriveAuthSecret
@Composable
fun RegisterScreen(
@@ -148,7 +148,7 @@ fun RegisterScreen(
// Derive auth secret before sending (matches frontend implementation)
scope.launch {
- val derived = HashUtils.deriveAuthSecret(username.trim(), password)
+ val derived = deriveAuthSecret(username.trim(), password)
apiRequest(
onError = { message, _ ->
diff --git a/app/src/main/java/ru/fromchat/ui/main/ChatsTab.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt
similarity index 100%
rename from app/src/main/java/ru/fromchat/ui/main/ChatsTab.kt
rename to composeApp/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt
diff --git a/app/src/main/java/ru/fromchat/ui/main/MainScreen.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt
similarity index 100%
rename from app/src/main/java/ru/fromchat/ui/main/MainScreen.kt
rename to composeApp/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt
diff --git a/app/src/main/java/ru/fromchat/utils/Generic.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/utils/Generic.kt
similarity index 100%
rename from app/src/main/java/ru/fromchat/utils/Generic.kt
rename to composeApp/src/commonMain/kotlin/ru/fromchat/utils/Generic.kt
diff --git a/app/src/main/java/ru/fromchat/utils/Ktor.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/utils/Ktor.kt
similarity index 91%
rename from app/src/main/java/ru/fromchat/utils/Ktor.kt
rename to composeApp/src/commonMain/kotlin/ru/fromchat/utils/Ktor.kt
index cb2c90c..01fbd5d 100644
--- a/app/src/main/java/ru/fromchat/utils/Ktor.kt
+++ b/composeApp/src/commonMain/kotlin/ru/fromchat/utils/Ktor.kt
@@ -7,11 +7,10 @@ import io.ktor.client.plugins.DefaultRequest
import io.ktor.client.plugins.ResponseException
import io.ktor.client.plugins.ServerResponseException
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
-import io.ktor.client.plugins.logging.DEFAULT
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
-import io.ktor.client.plugins.logging.LoggingConfig
+import io.ktor.client.plugins.logging.SIMPLE
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import io.ktor.serialization.Configuration
@@ -56,10 +55,10 @@ inline fun HttpClientConfig<*>.defaultRequest(
/**
* Installs [Logging] in a Ktor client config.
- * @param settings Lambda to configure the [LoggingConfig].
+ * @param settings Lambda to configure the [Logging.Config].
*/
inline fun HttpClientConfig<*>.logging(
- crossinline settings: LoggingConfig.() -> Unit
+ crossinline settings: Logging.Config.() -> Unit
) = install(Logging) {
settings()
}
@@ -68,7 +67,7 @@ inline fun HttpClientConfig<*>.logging(
* Installs [Logging] in a Ktor client config with default settings (all logs).
*/
inline fun HttpClientConfig<*>.logging() = logging {
- logger = Logger.DEFAULT
+ logger = Logger.SIMPLE
level = LogLevel.ALL
}
diff --git a/composeApp/src/iosMain/kotlin/ru/fromchat/MainViewController.kt b/composeApp/src/iosMain/kotlin/ru/fromchat/MainViewController.kt
new file mode 100644
index 0000000..ee61d3f
--- /dev/null
+++ b/composeApp/src/iosMain/kotlin/ru/fromchat/MainViewController.kt
@@ -0,0 +1,6 @@
+package ru.fromchat
+
+import ru.fromchat.ui.App
+import androidx.compose.ui.window.ComposeUIViewController
+
+fun MainViewController() = ComposeUIViewController { App() }
diff --git a/composeApp/src/iosMain/kotlin/ru/fromchat/api/ApiClient.ios.kt b/composeApp/src/iosMain/kotlin/ru/fromchat/api/ApiClient.ios.kt
new file mode 100644
index 0000000..1b20225
--- /dev/null
+++ b/composeApp/src/iosMain/kotlin/ru/fromchat/api/ApiClient.ios.kt
@@ -0,0 +1,13 @@
+package ru.fromchat.api
+
+import io.ktor.client.HttpClient
+import io.ktor.client.HttpClientConfig
+import io.ktor.client.engine.darwin.Darwin
+import io.ktor.client.engine.darwin.DarwinClientEngineConfig
+
+actual fun createPlatformHttpClient(
+ block: HttpClientConfig<*>.() -> Unit
+): HttpClient {
+ @Suppress("UNCHECKED_CAST")
+ return HttpClient(Darwin, block as HttpClientConfig.() -> Unit)
+}
diff --git a/composeApp/src/iosMain/kotlin/ru/fromchat/core/Logger.ios.kt b/composeApp/src/iosMain/kotlin/ru/fromchat/core/Logger.ios.kt
new file mode 100644
index 0000000..8386ff3
--- /dev/null
+++ b/composeApp/src/iosMain/kotlin/ru/fromchat/core/Logger.ios.kt
@@ -0,0 +1,21 @@
+package ru.fromchat.core
+
+import platform.Foundation.NSLog
+
+actual object Logger {
+ actual fun d(tag: String, message: String, throwable: Throwable?) {
+ NSLog("DEBUG: [%s] %s %s", tag, message, throwable?.message ?: "")
+ }
+
+ actual fun i(tag: String, message: String, throwable: Throwable?) {
+ NSLog("INFO: [%s] %s %s", tag, message, throwable?.message ?: "")
+ }
+
+ actual fun w(tag: String, message: String, throwable: Throwable?) {
+ NSLog("WARN: [%s] %s %s", tag, message, throwable?.message ?: "")
+ }
+
+ actual fun e(tag: String, message: String, throwable: Throwable?) {
+ NSLog("ERROR: [%s] %s %s", tag, message, throwable?.message ?: "")
+ }
+}
diff --git a/app/src/test/java/ru/fromchat/ExampleUnitTest.kt b/composeApp/src/test/java/ru/fromchat/ExampleUnitTest.kt
similarity index 100%
rename from app/src/test/java/ru/fromchat/ExampleUnitTest.kt
rename to composeApp/src/test/java/ru/fromchat/ExampleUnitTest.kt
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index b69757a..4e23a8a 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,43 +1,66 @@
[versions]
agp = "8.13.1"
+androidx-activityCompose = "1.12.0"
+androidx-appcompat = "1.7.1"
+androidx-core-ktx = "1.17.0"
+androidx-lifecycle = "2.9.6"
+compose-multiplatform = "1.9.3"
+constraintlayout = "0.6.1-shaded"
+coreSplashscreen = "1.2.0"
kotlin = "2.2.21"
-coreKtx = "1.17.0"
-kotlinxDatetime = "0.7.1"
-lifecycleRuntimeKtx = "2.10.0"
-activityCompose = "1.12.0"
-composeBom = "2025.11.01"
-materialIconsExtended = "1.7.8"
-navigationCompose = "2.9.6"
-ktor = "3.3.3"
+adaptiveAndroid = "1.2.0"
+kotlinStdlib = "2.2.21"
+biometric = "1.4.0-alpha04"
+gson = "2.13.2"
+kotlinxIoBytestring = "0.8.2"
+kotlinxCoroutinesCore = "1.10.2"
+kotlinxIoCore = "0.8.2"
kotlinxSerializationJson = "1.9.0"
-compose = "1.9.5"
-material3 = "1.5.0-alpha09"
-haze = "1.7.1"
+material = "1.13.0"
+activityKtx = "1.12.0"
+navigationCompose = "2.9.1"
+datastore = "1.2.0"
+ktor = "2.3.12"
+slf4j = "1.7.36"
+kotlinxDatetime = "0.7.1"
[libraries]
-androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
-androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIconsExtended" }
-androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
-androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
-androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
-androidx-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "compose" }
-androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics", version.ref = "compose" }
-androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "compose" }
-androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "compose" }
-androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
-androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
-kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" }
-ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
-ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" }
-ktor-client-websockets = { group = "io.ktor", name = "ktor-client-websockets", version.ref = "ktor" }
-ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
-ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
+androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" }
+androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" }
+androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
+androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
+androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
+constraintlayout = { module = "tech.annexflow.compose:constraintlayout-compose-multiplatform", version.ref = "constraintlayout" }
+jetbrains-kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" }
+jetbrains-kotlinx-io-bytestring = { module = "org.jetbrains.kotlinx:kotlinx-io-bytestring", version.ref = "kotlinxIoBytestring" }
+androidx-adaptive-android = { group = "androidx.compose.material3.adaptive", name = "adaptive-android", version.ref = "adaptiveAndroid" }
+kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlinStdlib" }
+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" }
+material = { group = "com.google.android.material", name = "material", version.ref = "material" }
+androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" }
+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" }
+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" }
-haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
+ktor-client-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
+ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
+ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
+ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" }
+ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
+ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
+slf4j-android = { module = "org.slf4j:slf4j-android", version.ref = "slf4j" }
+kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" }
[plugins]
-android-application = { id = "com.android.application", version.ref = "agp" }
-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
-kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
-kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
\ No newline at end of file
+androidApplication = { id = "com.android.application", version.ref = "agp" }
+androidLibrary = { id = "com.android.library", version.ref = "agp" }
+composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
+composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
+androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
+jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
\ No newline at end of file
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 119a480..6b8db56 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -1,24 +1,34 @@
+@file:Suppress("UnstableApiUsage")
+
+rootProject.name = "FromChat"
+enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
+
pluginManagement {
repositories {
google {
- content {
- includeGroupByRegex("com\\.android.*")
- includeGroupByRegex("com\\.google.*")
- includeGroupByRegex("androidx.*")
+ mavenContent {
+ includeGroupAndSubgroups("androidx")
+ includeGroupAndSubgroups("com.android")
+ includeGroupAndSubgroups("com.google")
}
}
mavenCentral()
gradlePluginPortal()
}
}
+
dependencyResolutionManagement {
- repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
- google()
+ google {
+ mavenContent {
+ includeGroupAndSubgroups("androidx")
+ includeGroupAndSubgroups("com.android")
+ includeGroupAndSubgroups("com.google")
+ }
+ }
mavenCentral()
}
}
-rootProject.name = "FromChat"
-include(":app")
+include(":composeApp", ":utils")
\ No newline at end of file
diff --git a/utils/.gitignore b/utils/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/utils/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/utils/build.gradle.kts b/utils/build.gradle.kts
new file mode 100644
index 0000000..c9cd332
--- /dev/null
+++ b/utils/build.gradle.kts
@@ -0,0 +1,66 @@
+plugins {
+ alias(libs.plugins.kotlinMultiplatform)
+ alias(libs.plugins.androidKotlinMultiplatformLibrary)
+ alias(libs.plugins.composeMultiplatform)
+ alias(libs.plugins.composeCompiler)
+}
+
+kotlin {
+ compilerOptions {
+ freeCompilerArgs.addAll("-Xexpect-actual-classes")
+ }
+
+ androidLibrary {
+ namespace = "com.pr0gramm3r101.utils"
+ compileSdk = 36
+ minSdk = 24
+ }
+
+ listOf(
+ iosX64(),
+ iosArm64(),
+ iosSimulatorArm64()
+ ).forEach {
+ it.binaries.framework {
+ baseName = "utils"
+ }
+ }
+
+ sourceSets {
+ commonMain.dependencies {
+ implementation(libs.kotlin.stdlib)
+ implementation(compose.runtime)
+ implementation(compose.foundation)
+ implementation(compose.material3)
+ implementation(compose.ui)
+ implementation(compose.components.resources)
+ implementation(libs.constraintlayout)
+ implementation(compose.materialIconsExtended)
+ implementation(libs.jetbrains.kotlinx.coroutines.core)
+ }
+
+ androidMain.dependencies {
+ implementation(libs.androidx.adaptive.android)
+ implementation(libs.androidx.activity.ktx)
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.appcompat)
+ implementation(libs.material)
+ implementation(libs.biometric)
+ implementation(libs.gson)
+
+ implementation(libs.androidx.adaptive.android)
+ implementation(libs.datastore.preferences)
+ implementation(libs.datastore.core)
+ }
+
+ iosMain.dependencies {
+ // nothing
+ }
+ }
+}
+
+compose.resources {
+ publicResClass = true
+ packageOfResClass = "com.pr0gramm3r101.utils"
+ generateResClass = auto
+}
\ No newline at end of file
diff --git a/utils/src/androidMain/AndroidManifest.xml b/utils/src/androidMain/AndroidManifest.xml
new file mode 100644
index 0000000..1d26c87
--- /dev/null
+++ b/utils/src/androidMain/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/utils/src/androidMain/kotlin/com/pr0gramm3r101/components/Components.android.kt b/utils/src/androidMain/kotlin/com/pr0gramm3r101/components/Components.android.kt
new file mode 100644
index 0000000..c4c861a
--- /dev/null
+++ b/utils/src/androidMain/kotlin/com/pr0gramm3r101/components/Components.android.kt
@@ -0,0 +1,42 @@
+@file:Suppress("NOTHING_TO_INLINE")
+
+package com.pr0gramm3r101.components
+
+import android.widget.ImageView
+import androidx.annotation.DrawableRes
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.DefaultAlpha
+import androidx.compose.ui.graphics.asAndroidColorFilter
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.viewinterop.AndroidView
+import com.pr0gramm3r101.utils.asAndroidScaleType
+
+@Composable
+inline fun Mipmap(
+ @DrawableRes id: Int,
+ contentDescription: String?,
+ modifier: Modifier = Modifier,
+ contentScale: ContentScale = ContentScale.Fit,
+ alpha: Float = DefaultAlpha,
+ colorFilter: ColorFilter? = null
+) {
+ val update: (ImageView) -> Unit = {
+ it.setImageResource(id)
+ it.contentDescription = contentDescription
+ it.alpha = alpha
+ it.colorFilter = colorFilter?.asAndroidColorFilter()
+ it.scaleType = contentScale.asAndroidScaleType()
+ }
+
+ AndroidView(
+ modifier = modifier,
+ factory = {
+ ImageView(it).apply {
+ update(this)
+ }
+ },
+ update = update
+ )
+}
\ No newline at end of file
diff --git a/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/Adaptive.android.kt b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/Adaptive.android.kt
new file mode 100644
index 0000000..3bce990
--- /dev/null
+++ b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/Adaptive.android.kt
@@ -0,0 +1,9 @@
+@file:Suppress("NOTHING_TO_INLINE")
+
+package com.pr0gramm3r101.utils
+
+import androidx.compose.material3.adaptive.currentWindowSize
+import androidx.compose.runtime.Composable
+
+@Composable
+actual inline fun currentWindowSize() = currentWindowSize()
\ No newline at end of file
diff --git a/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/BetterActivityResult.java b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/BetterActivityResult.java
new file mode 100644
index 0000000..db1aeff
--- /dev/null
+++ b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/BetterActivityResult.java
@@ -0,0 +1,99 @@
+package com.pr0gramm3r101.utils;
+
+import android.content.Intent;
+
+import androidx.activity.result.ActivityResult;
+import androidx.activity.result.ActivityResultCaller;
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContract;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.app.ActivityOptionsCompat;
+
+public final class BetterActivityResult {
+ /**
+ * Register activity result using a {@link ActivityResultContract} and an in-place activity result callback like
+ * the default approach. You can still customise callback using {@link #launch(Object, OnActivityResult)}.
+ */
+ @NonNull
+ public static BetterActivityResult registerForActivityResult(
+ @NonNull ActivityResultCaller caller,
+ @NonNull ActivityResultContract contract,
+ @Nullable OnActivityResult onActivityResult) {
+ return new BetterActivityResult<>(caller, contract, onActivityResult);
+ }
+
+ /**
+ * Same as {@link #registerForActivityResult(ActivityResultCaller, ActivityResultContract, OnActivityResult)} except
+ * the last argument is set to {@code null}.
+ */
+ @NonNull
+ public static BetterActivityResult registerForActivityResult(
+ @NonNull ActivityResultCaller caller,
+ @NonNull ActivityResultContract contract) {
+ return registerForActivityResult(caller, contract, null);
+ }
+
+ /**
+ * Specialised method for launching new activities.
+ */
+ @NonNull
+ public static BetterActivityResult registerActivityForResult(
+ @NonNull ActivityResultCaller caller) {
+ return registerForActivityResult(caller, new ActivityResultContracts.StartActivityForResult());
+ }
+
+ /**
+ * Callback interface
+ */
+ @FunctionalInterface
+ public interface OnActivityResult {
+ /**
+ * Called after receiving a result from the target activity
+ */
+ void onActivityResult(O result);
+ }
+
+ private final ActivityResultLauncher launcher;
+ @Nullable
+ private OnActivityResult onActivityResult;
+
+ private BetterActivityResult(@NonNull ActivityResultCaller caller,
+ @NonNull ActivityResultContract contract,
+ @Nullable OnActivityResult onActivityResult) {
+ this.onActivityResult = onActivityResult;
+ this.launcher = caller.registerForActivityResult(contract, this::callOnActivityResult);
+ }
+
+ /**
+ * Launch activity, same as {@link ActivityResultLauncher#launch(Object)} except that it allows a callback
+ * executed after receiving a result from the target activity.
+ */
+ public void launch(I input, @Nullable OnActivityResult onActivityResult) {
+ this.onActivityResult = onActivityResult;
+ launcher.launch(input);
+ }
+
+ public void launch(I input, ActivityOptionsCompat options, @Nullable OnActivityResult onActivityResult) {
+ this.onActivityResult = onActivityResult;
+ launcher.launch(input, options);
+ }
+
+ /**
+ * Same as {@link #launch(Object, OnActivityResult)} with last parameter set to {@code null}.
+ * @noinspection unused
+ */
+ public void launch(I input) {
+ launch(input, (OnActivityResult) null);
+ }
+
+ /** @noinspection unused*/
+ public void launch(I input, ActivityOptionsCompat options) {
+ launch(input, options, null);
+ }
+
+ private void callOnActivityResult(R result) {
+ if (onActivityResult != null) onActivityResult.onActivityResult(result);
+ }
+}
diff --git a/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/JobIdManager.kt b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/JobIdManager.kt
new file mode 100644
index 0000000..df0e8b7
--- /dev/null
+++ b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/JobIdManager.kt
@@ -0,0 +1,43 @@
+package com.pr0gramm3r101.utils
+
+import androidx.annotation.IntDef
+import org.jetbrains.annotations.Contract
+
+
+object JobIdManager {
+ const val JOB_TYPE_CHANNEL_PROGRAMS: Int = 1
+ const val JOB_TYPE_CHANNEL_METADATA: Int = 2
+ const val JOB_TYPE_CHANNEL_DELETION: Int = 3
+ const val JOB_TYPE_CHANNEL_LOGGER: Int = 4
+
+ const val JOB_TYPE_USER_PREFS: Int = 11
+ const val JOB_TYPE_USER_BEHAVIOR: Int = 21
+
+ //16-1 for short. Adjust per your needs
+ private const val JOB_TYPE_SHIFTS = 15
+
+ @Contract(pure = true)
+ fun getJobId(@JobType jobType: Int, objectId: Int): Int {
+ if (objectId > 0 && objectId < (1 shl JOB_TYPE_SHIFTS)) {
+ return (jobType shl JOB_TYPE_SHIFTS) + objectId
+ } else {
+ throw IllegalArgumentException(String.format(
+ "objectId %s must be between %s and %s",
+ objectId, 0, (1 shl JOB_TYPE_SHIFTS)
+ ))
+ }
+ }
+
+ @IntDef(
+ value = [
+ JOB_TYPE_CHANNEL_PROGRAMS,
+ JOB_TYPE_CHANNEL_METADATA,
+ JOB_TYPE_CHANNEL_DELETION,
+ JOB_TYPE_CHANNEL_LOGGER,
+ JOB_TYPE_USER_PREFS,
+ JOB_TYPE_USER_BEHAVIOR
+ ]
+ )
+ @Retention(AnnotationRetention.SOURCE)
+ private annotation class JobType
+}
\ No newline at end of file
diff --git a/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/NoParallelExecutor.kt b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/NoParallelExecutor.kt
new file mode 100644
index 0000000..6ada166
--- /dev/null
+++ b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/NoParallelExecutor.kt
@@ -0,0 +1,18 @@
+package com.pr0gramm3r101.utils
+
+import java.util.concurrent.Executor
+
+@Suppress("MemberVisibilityCanBePrivate")
+class NoParallelExecutor: Executor {
+ val isRunning get() = thread != null
+ var thread: Thread? = null
+
+ override fun execute(command: Runnable) {
+ if (!isRunning) {
+ thread = async {
+ command.run()
+ thread = null
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/SerializableIntent.kt b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/SerializableIntent.kt
new file mode 100644
index 0000000..1fb0dab
--- /dev/null
+++ b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/SerializableIntent.kt
@@ -0,0 +1,489 @@
+@file:Suppress("MemberVisibilityCanBePrivate")
+
+package com.pr0gramm3r101.utils
+
+import android.content.ClipData
+import android.content.ComponentName
+import android.content.Intent
+import android.graphics.Rect
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.os.Parcelable
+import android.os.PersistableBundle
+import java.io.InputStream
+import java.io.ObjectInputStream
+import java.io.ObjectOutputStream
+import java.io.OutputStream
+import java.io.Serial
+import java.io.Serializable
+
+fun Intent.serialize(outputStream: OutputStream) = SerializableIntent.serialize(this, outputStream)
+fun Intent.toSerializableIntent() = SerializableIntent(this)
+
+class SerializableIntent(): Intent(), Serializable {
+ companion object {
+ @Serial
+ const val serialVersionUID = 1241895592857245245L
+
+ fun deserialize(inputStream: InputStream): Intent {
+ ObjectInputStream(inputStream).use {
+ val obj = it.readObject() as SerializableIntent
+ return obj.toIntent()
+ }
+ }
+
+ fun serialize(intent: Intent, outputStream: OutputStream) {
+ SerializableIntent(intent).serialize(outputStream)
+ }
+ }
+
+ constructor(intent: Intent): this() {
+ _data = intent.data
+ _package = intent.`package`
+ _flags = intent.flags
+ _type = intent.type
+ _action = intent.action
+ _categories.addAll(intent.categories)
+ _component = intent.component
+ _extras = intent.extras
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ _identifier = intent.identifier
+ }
+ _sourceBounds = intent.sourceBounds
+ }
+
+ @Transient
+ var _data: Uri? = null
+ private var __data: String? = null
+ var _package: String? = null
+ var _flags: Int = 0
+ var _type: String? = null
+ val _scheme get() = _data?.scheme
+ var _action: String? = null
+ val _categories = mutableSetOf()
+ @Transient
+ var _component: ComponentName? = null
+ private var __component_packageName: String? = null
+ private var __component_className: String? = null
+ @Transient
+ var _extras: Bundle? = null
+ private var __extras_persistableBundle: PersistableBundle? = null
+ var _identifier: String? = null
+ @Transient
+ var _sourceBounds: Rect? = null
+ private var __sourceBounds_top: Int? = null
+ private var __sourceBounds_bottom: Int? = null
+ private var __sourceBounds_left: Int? = null
+ private var __sourceBounds_right: Int? = null
+
+ fun prepareSerialize() {
+ __data = _data?.toString()
+ __extras_persistableBundle = _extras?.toPersistableBundle()
+ __component_packageName = _component?.packageName
+ __component_className = _component?.className
+ __sourceBounds_top = _sourceBounds?.top
+ __sourceBounds_bottom = _sourceBounds?.bottom
+ __sourceBounds_left = _sourceBounds?.left
+ __sourceBounds_right = _sourceBounds?.right
+ }
+
+ fun serialize(outputStream: OutputStream) {
+ prepareSerialize()
+ ObjectOutputStream(outputStream).use {
+ it.writeObject(this@SerializableIntent)
+ }
+ }
+
+ fun toIntent(): Intent {
+ val intent = Intent()
+ intent.setDataAndType(_data, _type)
+ intent.`package` = _package
+ intent.flags = _flags
+ intent.action = _action
+ _categories.forEach {
+ intent.addCategory(it)
+ }
+ intent.component = _component
+ _extras?.let { intent.putExtras(it) }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ intent.identifier = _identifier
+ }
+ _sourceBounds = Rect(
+ __sourceBounds_left?: 0,
+ __sourceBounds_top?: 0,
+ __sourceBounds_right?: 0,
+ __sourceBounds_bottom?: 0
+ )
+ return intent
+ }
+
+ // OVERRIDES
+ override fun getData() = _data
+ override fun setData(uri: Uri?): Intent {
+ this._data = uri
+ return this
+ }
+
+ override fun getPackage() = _package
+ override fun setPackage(pkg: String?): Intent {
+ this._package = pkg
+ return this
+ }
+ override fun getFlags() = _flags
+ override fun setFlags(flags: Int): Intent {
+ this._flags = flags
+ return this
+ }
+
+ override fun addFlags(flags: Int): Intent {
+ this._flags = this._flags or flags
+ return this
+ }
+
+ override fun getType() = _type
+ override fun setType(type: String?): Intent {
+ this._type = type
+ return this
+ }
+
+ override fun getScheme() = _scheme
+
+ override fun getAction() = _action
+ override fun setAction(action: String?): Intent {
+ this._action = action
+ return this
+ }
+
+ override fun addCategory(category: String): Intent {
+ _categories.add(category)
+ return this
+ }
+ override fun getCategories() = _categories
+ override fun hasCategory(category: String) = _categories.contains(category)
+ override fun removeCategory(category: String) {
+ _categories.remove(category)
+ }
+
+ override fun getClipData() = throw UnsupportedOperationException("Not supported in SerializableIntent")
+ override fun setClipData(clipData: ClipData?) = throw UnsupportedOperationException("Not supported in SerializableIntent")
+
+ override fun getComponent(): ComponentName? = _component
+ override fun setComponent(component: ComponentName?): Intent {
+ this._component = component
+ return this
+ }
+
+ override fun getDataString() = _data?.toString()
+
+ override fun getExtras() = _extras
+ override fun putExtras(extras: Bundle): Intent {
+ if (this._extras == null) {
+ this._extras = Bundle()
+ }
+ this._extras!!.putAll(extras)
+ return this
+ }
+ override fun putExtras(src: Intent): Intent {
+ if (src.extras != null) {
+ putExtras(src.extras!!)
+ }
+ return this
+ }
+ override fun putExtra(name: String, value: Array?): Intent {
+ if (this._extras == null) {
+ this._extras = Bundle()
+ }
+ this._extras!!.putCharSequenceArray(name, value)
+ return this
+ }
+ override fun putExtra(name: String, value: Array?): Intent {
+ return this // Stub!
+ }
+ override fun putExtra(name: String, value: Array?): Intent {
+ if (this._extras == null) {
+ this._extras = Bundle()
+ }
+ this._extras!!.putStringArray(name, value)
+ return this
+ }
+ override fun putExtra(name: String, value: Boolean): Intent {
+ if (this._extras == null) {
+ this._extras = Bundle()
+ }
+ this._extras!!.putBoolean(name, value)
+ return this
+ }
+ override fun putExtra(name: String, value: BooleanArray?): Intent {
+ if (this._extras == null) {
+ this._extras = Bundle()
+ }
+ this._extras!!.putBooleanArray(name, value)
+ return this
+ }
+ override fun putExtra(name: String, value: Bundle?): Intent {
+ return this // Stub!
+ }
+ override fun putExtra(name: String, value: Byte): Intent {
+ if (this._extras == null) {
+ this._extras = Bundle()
+ }
+ this._extras!!.putByte(name, value)
+ return this
+ }
+ override fun putExtra(name: String, value: ByteArray?): Intent {
+ if (this._extras == null) {
+ this._extras = Bundle()
+ }
+ this._extras!!.putByteArray(name, value)
+ return this
+ }
+ override fun putExtra(name: String?, value: Char): Intent {
+ if (this._extras == null) {
+ this._extras = Bundle()
+ }
+ this._extras!!.putChar(name, value)
+ return this
+ }
+ override fun putExtra(name: String, value: CharArray?): Intent {
+ if (this._extras == null) {
+ this._extras = Bundle()
+ }
+ this._extras!!.putCharArray(name, value)
+ return this
+ }
+ override fun putExtra(name: String, value: CharSequence?): Intent {
+ if (this._extras == null) {
+ this._extras = Bundle()
+ }
+ this._extras!!.putCharSequence(name, value)
+ return this
+ }
+ override fun putExtra(name: String, value: Double): Intent {
+ if (this._extras == null) {
+ this._extras = Bundle()
+ }
+ this._extras!!.putDouble(name, value)
+ return this
+ }
+ override fun putExtra(name: String, value: DoubleArray?): Intent {
+ if (this._extras == null) {
+ this._extras = Bundle()
+ }
+ this._extras!!.putDoubleArray(name, value)
+ return this
+ }
+ override fun putExtra(name: String?, value: Float): Intent {
+ if (this._extras == null) {
+ this._extras = Bundle()
+ }
+ this._extras!!.putFloat(name, value)
+ return this
+ }
+ override fun putExtra(name: String, value: FloatArray?): Intent {
+ if (this._extras == null) {
+ this._extras = Bundle()
+ }
+ this._extras!!.putFloatArray(name, value)
+ return this
+ }
+ override fun putExtra(name: String, value: Int): Intent {
+ if (this._extras == null) {
+ this._extras = Bundle()
+ }
+ this._extras!!.putInt(name, value)
+ return this
+ }
+ override fun putExtra(name: String, value: IntArray?): Intent {
+ if (this._extras == null) {
+ this._extras = Bundle()
+ }
+ this._extras!!.putIntArray(name, value)
+ return this
+ }
+ override fun putExtra(name: String, value: Long): Intent {
+ if (this._extras == null) {
+ this._extras = Bundle()
+ }
+ this._extras!!.putLong(name, value)
+ return this
+ }
+ override fun putExtra(name: String, value: LongArray?): Intent {
+ if (this._extras == null) {
+ this._extras = Bundle()
+ }
+ this._extras!!.putLongArray(name, value)
+ return this
+ }
+ override fun putExtra(name: String, value: Parcelable?): Intent {
+ return this // Stub!
+ }
+ override fun putExtra(name: String, value: Short): Intent {
+ if (this._extras == null) {
+ this._extras = Bundle()
+ }
+ this._extras!!.putShort(name, value)
+ return this
+ }
+ override fun putExtra(name: String, value: ShortArray?): Intent {
+ if (this._extras == null) {
+ this._extras = Bundle()
+ }
+ this._extras!!.putShortArray(name, value)
+ return this
+ }
+ override fun putExtra(name: String, value: String?): Intent {
+ if (this._extras == null) {
+ this._extras = Bundle()
+ }
+ this._extras!!.putString(name, value)
+ return this
+ }
+ override fun putExtra(name: String, value: Serializable?): Intent {
+ return this // Stub!
+ }
+ override fun putStringArrayListExtra(name: String, value: ArrayList?): Intent {
+ if (this._extras == null) {
+ this._extras = Bundle()
+ }
+ this._extras!!.putStringArrayList(name, value)
+ return this
+ }
+ override fun putIntegerArrayListExtra(name: String, value: ArrayList?): Intent {
+ if (this._extras == null) {
+ this._extras = Bundle()
+ }
+ this._extras!!.putIntegerArrayList(name, value)
+ return this
+ }
+ override fun putParcelableArrayListExtra(name: String?, value: java.util.ArrayList?): Intent {
+ return this // Stub!
+ }
+ override fun putCharSequenceArrayListExtra(name: String?, value: java.util.ArrayList?): Intent {
+ if (this._extras == null) {
+ this._extras = Bundle()
+ }
+ this._extras!!.putCharSequenceArrayList(name, value)
+ return this
+ }
+ override fun getBooleanExtra(name: String, defaultValue: Boolean): Boolean {
+ return _extras?.getBoolean(name, defaultValue) ?: defaultValue
+ }
+ override fun getBooleanArrayExtra(name: String): BooleanArray? {
+ return _extras?.getBooleanArray(name)
+ }
+ override fun getByteExtra(name: String, defaultValue: Byte): Byte {
+ return _extras?.getByte(name, defaultValue) ?: defaultValue
+ }
+ override fun getByteArrayExtra(name: String?): ByteArray? {
+ return _extras?.getByteArray(name)
+ }
+ override fun getCharExtra(name: String, defaultValue: Char): Char {
+ return _extras?.getChar(name, defaultValue) ?: defaultValue
+ }
+ override fun getCharArrayExtra(name: String?): CharArray? {
+ return _extras?.getCharArray(name)
+ }
+ override fun getDoubleExtra(name: String, defaultValue: Double): Double {
+ return _extras?.getDouble(name, defaultValue) ?: defaultValue
+ }
+ override fun getDoubleArrayExtra(name: String?): DoubleArray? {
+ return _extras?.getDoubleArray(name)
+ }
+ override fun getFloatExtra(name: String, defaultValue: Float): Float {
+ return _extras?.getFloat(name, defaultValue) ?: defaultValue
+ }
+ override fun getFloatArrayExtra(name: String?): FloatArray? {
+ return _extras?.getFloatArray(name)
+ }
+ override fun getIntExtra(name: String, defaultValue: Int): Int {
+ return _extras?.getInt(name, defaultValue) ?: defaultValue
+ }
+ override fun getIntArrayExtra(name: String?): IntArray? {
+ return _extras?.getIntArray(name)
+ }
+ override fun getLongExtra(name: String, defaultValue: Long): Long {
+ return _extras?.getLong(name, defaultValue) ?: defaultValue
+ }
+ override fun getLongArrayExtra(name: String): LongArray? {
+ return _extras?.getLongArray(name)
+ }
+ @Deprecated("Deprecated in Java")
+ override fun getSerializableExtra(name: String): Serializable? {
+ return null // Stub!
+ }
+ override fun getStringArrayListExtra(name: String): ArrayList? {
+ return _extras?.getStringArrayList(name)
+ }
+ override fun getIntegerArrayListExtra(name: String): ArrayList? {
+ return _extras?.getIntegerArrayList(name)
+ }
+ override fun getCharSequenceArrayListExtra(name: String): ArrayList? {
+ return _extras?.getCharSequenceArrayList(name)
+ }
+ override fun getSerializableExtra(name: String?, clazz: Class): T? {
+ return null // Stub!
+ }
+ override fun getBundleExtra(name: String): Bundle? {
+ return null // Stub!
+ }
+ override fun getStringExtra(name: String): String? {
+ return _extras?.getString(name)
+ }
+ override fun getShortExtra(name: String, defaultValue: Short): Short {
+ return _extras?.getShort(name, defaultValue) ?: defaultValue
+ }
+ override fun getShortArrayExtra(name: String): ShortArray? {
+ return _extras?.getShortArray(name)
+ }
+ override fun getStringArrayExtra(name: String): Array? {
+ return _extras?.getStringArray(name)
+ }
+ override fun getCharSequenceExtra(name: String): CharSequence? {
+ return _extras?.getCharSequence(name)
+ }
+ override fun getParcelableExtra(name: String?, clazz: Class): T? {
+ return null // Stub!
+ }
+ @Deprecated("Deprecated in Java")
+ override fun getParcelableExtra(name: String?): T? {
+ return null // Stub!
+ }
+ override fun getCharSequenceArrayExtra(name: String): Array? {
+ return _extras?.getCharSequenceArray(name)
+ }
+ override fun getParcelableArrayExtra(name: String?, clazz: Class): Array? {
+ return null // Stub!
+ }
+ @Deprecated("Deprecated in Java")
+ override fun getParcelableArrayExtra(name: String): Array? {
+ return null // Stub!
+ }
+ override fun getParcelableArrayListExtra(name: String?, clazz: Class): java.util.ArrayList? {
+ return null // Stub!
+ }
+ @Deprecated("Deprecated in Java")
+ override fun getParcelableArrayListExtra(name: String?): java.util.ArrayList? {
+ return null // Stub!
+ }
+
+ override fun getIdentifier() = _identifier
+ override fun setIdentifier(identifier: String?): Intent {
+ this._identifier = identifier
+ return this
+ }
+
+ override fun getSelector(): Intent? {
+ return null
+ }
+ override fun setSelector(selector: Intent?) {
+ // Stub!
+ }
+
+ override fun getSourceBounds() = _sourceBounds
+ override fun setSourceBounds(sourceBounds: Rect?) {
+ this._sourceBounds = sourceBounds
+ }
+
+ override fun isMismatchingFilter() = false
+}
\ No newline at end of file
diff --git a/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/SimpleAsyncTask.kt b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/SimpleAsyncTask.kt
new file mode 100644
index 0000000..09c76b9
--- /dev/null
+++ b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/SimpleAsyncTask.kt
@@ -0,0 +1,13 @@
+@file:Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
+
+package com.pr0gramm3r101.utils
+
+import android.os.AsyncTask
+
+abstract class SimpleAsyncTask: AsyncTask() {
+ final override fun doInBackground(vararg params: Unit) = run()
+ abstract fun run()
+ abstract fun postRun()
+ final override fun onProgressUpdate(vararg values: Unit) {}
+ final override fun onPostExecute(result: Unit?) = postRun()
+}
\ No newline at end of file
diff --git a/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/Utils.android.kt b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/Utils.android.kt
new file mode 100644
index 0000000..8870977
--- /dev/null
+++ b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/Utils.android.kt
@@ -0,0 +1,832 @@
+@file:Suppress("NOTHING_TO_INLINE", "unused")
+
+package com.pr0gramm3r101.utils
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.app.Activity
+import android.app.KeyguardManager
+import android.app.Notification
+import android.content.ComponentName
+import android.content.ContentResolver
+import android.content.Context
+import android.content.Intent
+import android.content.Intent.ACTION_MAIN
+import android.content.Intent.CATEGORY_LAUNCHER
+import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
+import android.content.pm.ActivityInfo
+import android.content.pm.PackageManager
+import android.content.res.Resources
+import android.hardware.Sensor
+import android.hardware.SensorEvent
+import android.hardware.SensorEventListener
+import android.hardware.SensorManager
+import android.hardware.usb.UsbEndpoint
+import android.hardware.usb.UsbInterface
+import android.net.Uri
+import android.os.BaseBundle
+import android.os.Build
+import android.os.Bundle
+import android.os.Parcelable
+import android.os.PersistableBundle
+import android.provider.OpenableColumns
+import android.service.quicksettings.Tile
+import android.util.Base64
+import android.util.Log
+import android.util.TypedValue
+import android.view.View
+import android.view.ViewTreeObserver.OnPreDrawListener
+import android.widget.ImageView.ScaleType
+import androidx.activity.ComponentActivity
+import androidx.activity.OnBackPressedCallback
+import androidx.activity.result.ActivityResult
+import androidx.activity.result.ActivityResultCaller
+import androidx.annotation.AttrRes
+import androidx.annotation.ChecksSdkIntAtLeast
+import androidx.annotation.RequiresPermission
+import androidx.annotation.StringRes
+import androidx.appcompat.app.AlertDialog
+import androidx.biometric.BiometricManager
+import androidx.biometric.BiometricPrompt
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.calculateEndPadding
+import androidx.compose.foundation.layout.calculateStartPadding
+import androidx.compose.foundation.layout.isImeVisible
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.focus.onFocusEvent
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.unit.Dp
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.ContextCompat
+import androidx.core.net.toUri
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentActivity
+import androidx.window.core.layout.WindowSizeClass
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import java.io.IOException
+import java.io.InputStream
+import java.io.Serializable
+import java.lang.ref.WeakReference
+import java.security.SecureRandom
+import java.util.regex.Pattern
+import javax.crypto.Cipher
+import javax.crypto.SecretKeyFactory
+import javax.crypto.spec.IvParameterSpec
+import javax.crypto.spec.PBEKeySpec
+import kotlin.collections.iterator
+import kotlin.random.Random
+import kotlin.random.nextInt
+import kotlin.reflect.KClass
+import kotlin.reflect.KProperty
+
+@OptIn(ExperimentalLayoutApi::class)
+actual fun Modifier.clearFocusOnKeyboardDismiss(): Modifier = composed {
+ var isFocused by remember { mutableStateOf(false) }
+ var keyboardAppearedSinceLastFocused by remember { mutableStateOf(false) }
+ if (isFocused) {
+ val imeIsVisible = WindowInsets.isImeVisible
+ val focusManager = LocalFocusManager.current
+ LaunchedEffect(imeIsVisible) {
+ if (imeIsVisible) {
+ @Suppress("AssignedValueIsNeverRead")
+ keyboardAppearedSinceLastFocused = true
+ } else if (keyboardAppearedSinceLastFocused) {
+ focusManager.clearFocus()
+ }
+ }
+ }
+ onFocusEvent {
+ if (isFocused != it.isFocused) {
+ isFocused = it.isFocused
+ if (isFocused) {
+ keyboardAppearedSinceLastFocused = false
+ }
+ }
+ }
+}
+
+@SuppressLint("ComposableNaming")
+@Composable
+actual fun ToggleNavScrimEffect(enabled: Boolean) {
+ val context = (LocalContext() as Activity)
+ LaunchedEffect(enabled) {
+ runCatching {
+ val window = context.window
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ window.isNavigationBarContrastEnforced = enabled
+ }
+ }
+ }
+}
+
+val screenWidth: Int inline get() = Resources.getSystem().displayMetrics.widthPixels
+@Suppress("unused")
+val screenHeight: Int inline get() = Resources.getSystem().displayMetrics.heightPixels
+
+fun appName(context: Context, packageName: String): String? {
+ try {
+ val packageManager = context.packageManager
+ val applicationInfo = packageManager.getApplicationInfo(
+ packageName, PackageManager.GET_META_DATA
+ )
+ val appName = packageManager.getApplicationLabel(applicationInfo) as String
+ return appName
+ } catch (_: PackageManager.NameNotFoundException) {
+ return null
+ }
+}
+
+fun Context.homeScreen() {
+ val intentHome = Intent(ACTION_MAIN)
+ intentHome.addCategory(Intent.CATEGORY_HOME)
+ intentHome.flags = FLAG_ACTIVITY_NEW_TASK
+ startActivity(intentHome)
+}
+
+
+inline fun async(noinline exec: () -> Unit) = Thread(exec).apply { start() }
+
+inline fun Tile.configure(apply: Tile.() -> Unit) {
+ apply.invoke(this)
+ updateTile()
+}
+
+inline fun View.addOneTimeOnPreDrawListener(crossinline listener: () -> Boolean) {
+ viewTreeObserver.addOnPreDrawListener(object: OnPreDrawListener {
+ override fun onPreDraw(): Boolean {
+ viewTreeObserver.removeOnPreDrawListener(this)
+ return listener()
+ }
+ })
+}
+
+fun Context.launchFiles(): Boolean {
+ val primaryStorageUri = "content://com.android.externalstorage.documents/root/primary".toUri()
+
+ fun launchIntentWithComponent(action: String, componentName: ComponentName? = null): Boolean {
+ val intent = Intent(action, primaryStorageUri)
+ if (componentName != null) {
+ intent.setComponent(componentName)
+ }
+ try {
+ startActivity(intent)
+ return true
+ } catch (_: Throwable) {
+ return false
+ }
+ }
+ fun intent1() = launchIntentWithComponent(
+ Intent.ACTION_VIEW,
+ ComponentName(
+ "com.google.android.documentsui",
+ "com.android.documentsui.files.FilesActivity"
+ )
+ )
+ fun intent2() = launchIntentWithComponent(
+ Intent.ACTION_VIEW,
+ ComponentName(
+ "com.android.documentsui",
+ "com.android.documentsui.files.FilesActivity"
+ )
+ )
+ fun intent3() = launchIntentWithComponent(
+ Intent.ACTION_VIEW,
+ ComponentName(
+ "com.android.documentsui",
+ "com.android.documentsui.FilesActivity"
+ )
+ )
+ fun intent4() = launchIntentWithComponent(Intent.ACTION_VIEW)
+ fun intent5() = launchIntentWithComponent("android.provider.action.BROWSE")
+ fun intent6() = launchIntentWithComponent("android.provider.action.BROWSE_DOCUMENT_ROOT")
+
+ return intent1() || intent2() || intent3() || intent4() || intent5() || intent6()
+}
+
+inline fun Fragment.getSystemService(name: String): Any? = requireActivity().getSystemService(name)
+inline fun Fragment.getSystemService(cls: Class): T? = requireActivity().getSystemService(cls)
+
+inline fun Context.getSystemService(cls: KClass): T? = getSystemService(cls.java)
+inline fun Fragment.getSystemService(cls: KClass): T? = requireActivity().getSystemService(cls)
+
+typealias ActivityLauncher = BetterActivityResult
+
+val ActivityResultCaller.activityResultLauncher get() = BetterActivityResult.registerActivityForResult(this)
+
+@Suppress("unused")
+fun getOpenDocumentIntent(vararg fileTypes: String) =
+ Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
+ addCategory(Intent.CATEGORY_OPENABLE)
+ if (fileTypes.size == 1) {
+ type = fileTypes[0]
+ } else {
+ type = "*/*"
+ putExtra(Intent.EXTRA_MIME_TYPES, fileTypes)
+ }
+ }
+
+fun Context.getFileName(uri: Uri): String {
+ if (uri.scheme == "content") {
+ contentResolver.query(
+ uri,
+ null,
+ null,
+ null,
+ null
+ )!!.use { cursor ->
+ if (cursor.moveToFirst()) {
+ return cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
+ }
+ }
+ } else {
+ throw UnsupportedOperationException("Cannot process non-\"content://\" URIs")
+ }
+ return uri.lastPathSegment.let { name ->
+ name!!
+ val matcher = Pattern.compile("\\w+:(.*/)*(.*)").matcher(name)
+ if (matcher.find()) {
+ matcher.group(2)
+ } else {
+ name
+ }
+ }
+}
+
+@Suppress("unused")
+inline fun Fragment.getFileName(uri: Uri) = requireActivity().getFileName(uri)
+
+@Suppress("unused")
+val UsbInterface.endpointList: List get() {
+ val list = mutableListOf()
+ for (i in 0 until endpointCount) {
+ list.add(getEndpoint(i))
+ }
+ return list.toList()
+}
+
+fun Activity.resolveAttr(@AttrRes id: Int): Int? {
+ val typedValue = TypedValue()
+ if (theme.resolveAttribute(
+ id,
+ typedValue,
+ true
+ )) {
+ val resId = typedValue.resourceId
+ return if (resId != 0) resId else null
+ } else {
+ return null
+ }
+}
+
+@Suppress("UNUSED_PARAMETER", "unused", "MemberVisibilityCanBePrivate")
+class QuickAlertDialogBuilder(context: Context): MaterialAlertDialogBuilder(context) {
+ fun title(title: CharSequence): QuickAlertDialogBuilder {
+ setTitle(title)
+ return this
+ }
+ fun title(@StringRes titleRes: Int): QuickAlertDialogBuilder {
+ setTitle(titleRes)
+ return this
+ }
+
+ fun message(message: CharSequence): QuickAlertDialogBuilder {
+ setMessage(message)
+ return this
+ }
+ fun message(@StringRes messageRes: Int): QuickAlertDialogBuilder {
+ setMessage(messageRes)
+ return this
+ }
+
+ fun body(body: CharSequence) = message(body)
+ fun body(@StringRes bodyRes: Int) = message(bodyRes)
+
+ inline fun positiveButton(
+ text: CharSequence,
+ crossinline listener: () -> Unit
+ ) : QuickAlertDialogBuilder {
+ setPositiveButton(text) { _, _ -> listener() }
+ return this
+ }
+ inline fun positiveButton(@StringRes textRes: Int, crossinline listener: () -> Unit): QuickAlertDialogBuilder {
+ setPositiveButton(textRes) { _, _ -> listener() }
+ return this
+ }
+ inline fun positiveButton(text: CharSequence, listener: Nothing? = null): QuickAlertDialogBuilder {
+ setPositiveButton(text, null)
+ return this
+ }
+ inline fun positiveButton(@StringRes text: Int, listener: Nothing? = null): QuickAlertDialogBuilder {
+ setPositiveButton(text, null)
+ return this
+ }
+
+ inline fun negativeButton(text: CharSequence, crossinline listener: () -> Unit): QuickAlertDialogBuilder {
+ setNegativeButton(text) { _, _ -> listener() }
+ return this
+ }
+ inline fun negativeButton(@StringRes textRes: Int, crossinline listener: () -> Unit): QuickAlertDialogBuilder {
+ setNegativeButton(textRes) { _, _ -> listener() }
+ return this
+ }
+ inline fun negativeButton(text: CharSequence, listener: Nothing? = null): QuickAlertDialogBuilder {
+ setNegativeButton(text, null)
+ return this
+ }
+ inline fun negativeButton(@StringRes text: Int, listener: Nothing? = null): QuickAlertDialogBuilder {
+ setNegativeButton(text, null)
+ return this
+ }
+
+ inline fun neutralButton(text: CharSequence, crossinline listener: () -> Unit): QuickAlertDialogBuilder {
+ setNeutralButton(text) { _, _ -> listener() }
+ return this
+ }
+ inline fun neutralButton(@StringRes textRes: Int, crossinline listener: () -> Unit): QuickAlertDialogBuilder {
+ setNeutralButton(textRes) { _, _ -> listener() }
+ return this
+ }
+ inline fun neutralButton(text: CharSequence, listener: Nothing? = null): QuickAlertDialogBuilder {
+ setNeutralButton(text, null)
+ return this
+ }
+ inline fun neutralButton(@StringRes text: Int, listener: Nothing? = null): QuickAlertDialogBuilder {
+ setNeutralButton(text, null)
+ return this
+ }
+
+ inline fun onCancel(crossinline listener: () -> Unit): QuickAlertDialogBuilder {
+ setOnCancelListener { listener() }
+ return this
+ }
+
+ inline fun cancelable(value: Boolean): QuickAlertDialogBuilder {
+ setCancelable(value)
+ return this
+ }
+}
+
+inline fun Activity.alertDialog(crossinline config: QuickAlertDialogBuilder.() -> Unit): AlertDialog {
+ val builder = QuickAlertDialogBuilder(this)
+ config(builder)
+ return builder.show()
+}
+
+@Suppress("unused")
+inline fun Fragment.alertDialog(crossinline config: QuickAlertDialogBuilder.() -> Unit)
+ = requireActivity().alertDialog(config)
+
+@Suppress("unused")
+class AuthenticationConfig {
+ var success: ((BiometricPrompt.AuthenticationResult) -> Unit)? = null
+ var fail: (() -> Unit)? = null
+ var error: ((Int, String) -> Unit)? = null
+ var always: (() -> Unit)? = null
+
+ fun success(block: (BiometricPrompt.AuthenticationResult) -> Unit) {
+ success = block
+ }
+
+ inline fun ail(noinline block: () -> Unit) {
+ fail = block
+ }
+
+ inline fun error(noinline block: (Int, String) -> Unit) {
+ error = block
+ }
+
+ inline fun always(noinline block: () -> Unit) {
+ always = block
+ }
+
+ lateinit var title: String
+ var subtitle: String? = null
+ lateinit var negativeButtonText: String
+}
+
+inline fun FragmentActivity.requestAuthentication(crossinline callback: AuthenticationConfig.() -> Unit): BiometricPrompt {
+ val config = AuthenticationConfig()
+ callback(config)
+ try {
+ config.title
+ config.negativeButtonText
+ } catch (_: UninitializedPropertyAccessException) {
+ throw IllegalStateException("Required fields haven't been set")
+ }
+ val executor = ContextCompat.getMainExecutor(this)
+ val biometricPrompt = BiometricPrompt(
+ this,
+ executor,
+ object: BiometricPrompt.AuthenticationCallback() {
+ override fun onAuthenticationFailed() {
+ super.onAuthenticationFailed()
+ config.fail?.invoke()
+ }
+
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
+ super.onAuthenticationSucceeded(result)
+ config.success?.invoke(result)
+ config.always?.invoke()
+ }
+
+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
+ super.onAuthenticationError(errorCode, errString)
+ config.error?.invoke(errorCode, "$errString")
+ config.always?.invoke()
+ }
+ }
+ )
+
+ val promptInfo = BiometricPrompt.PromptInfo.Builder().apply {
+ setTitle(config.title)
+ setSubtitle(config.subtitle)
+ setNegativeButtonText(config.negativeButtonText)
+ setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
+ setConfirmationRequired(false)
+ }.build()
+ biometricPrompt.authenticate(promptInfo)
+ return biometricPrompt
+}
+
+fun ContentScale.asAndroidScaleType(): ScaleType? {
+ return when (this) {
+ ContentScale.Fit -> ScaleType.FIT_CENTER
+ ContentScale.Crop -> ScaleType.CENTER_CROP
+ ContentScale.FillWidth,
+ ContentScale.FillHeight,
+ ContentScale.FillBounds -> ScaleType.FIT_XY
+ ContentScale.Inside -> ScaleType.CENTER_INSIDE
+ else -> null
+ }
+}
+
+inline fun Context.openUrl(url: Uri) {
+ startActivity(Intent(Intent.ACTION_VIEW, url))
+}
+
+inline fun Context.openUrl(url: String) {
+ openUrl(
+ url.let {
+ val regex = "^[\\w-_]://".toRegex()
+ var th = it
+ if (!regex.containsMatchIn(th)) {
+ th = "https://$th"
+ }
+ th
+ }.toUri()
+ )
+}
+
+inline fun ComponentActivity.ComposeView(crossinline init: @Composable () -> Unit) =
+ ComposeView(this).apply {
+ setContent {
+ init()
+ }
+ }
+
+inline fun backCallback(enabled: Boolean = true, crossinline callback: OnBackPressedCallback.() -> Unit)
+ = object: OnBackPressedCallback(enabled) {
+ override fun handleOnBackPressed() {
+ callback(this)
+ }
+}
+
+fun String.encrypt(password: String): String {
+ val salt = ByteArray(16).also { SecureRandom().nextBytes(it) }
+ val spec = PBEKeySpec(password.toCharArray(), salt, 65536, 256)
+ val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
+ val secretKey = factory.generateSecret(spec)
+
+ val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
+ val iv = ByteArray(cipher.blockSize).also { SecureRandom().nextBytes(it) }
+ val params = IvParameterSpec(iv)
+ cipher.init(Cipher.ENCRYPT_MODE, secretKey, params)
+
+ val encryptedBytes = cipher.doFinal(this.toByteArray())
+ return "${Base64.encodeToString(salt, Base64.DEFAULT)}:${Base64.encodeToString(iv, Base64.DEFAULT)}:${Base64.encodeToString(encryptedBytes, Base64.DEFAULT)}"
+}
+
+fun String.decrypt(password: String): String {
+ val parts = this.split(":")
+ val salt = Base64.decode(parts[0], Base64.DEFAULT)
+ val iv = Base64.decode(parts[1], Base64.DEFAULT)
+ val encryptedBytes = Base64.decode(parts[2], Base64.DEFAULT)
+
+ val spec = PBEKeySpec(password.toCharArray(), salt, 65536, 256)
+ val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
+ val secretKey = factory.generateSecret(spec)
+
+ val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
+ val params = IvParameterSpec(iv)
+ cipher.init(Cipher.DECRYPT_MODE, secretKey, params)
+
+ val decryptedBytes = cipher.doFinal(encryptedBytes)
+ return String(decryptedBytes)
+}
+
+@Suppress("unused")
+inline fun waitUntil(timeout: Long = 0, condition: () -> Boolean): Boolean {
+ val time = System.currentTimeMillis()
+ while (!condition()) {
+ if (timeout > 0 && System.currentTimeMillis() - time > timeout) {
+ return false
+ }
+ }
+ return true
+}
+
+inline fun waitWhile(timeout: Long = 0, condition: () -> Boolean): Boolean {
+ val time = System.currentTimeMillis()
+ while (condition()) {
+ if (timeout > 0 && System.currentTimeMillis() - time > timeout) {
+ return false
+ }
+ }
+ return true
+}
+
+inline fun orientationSensorEventListener(
+ crossinline callback: (Float, Float, Float) -> Unit
+) = object: SensorEventListener {
+ private var accelerometerData: FloatArray? = null
+ private var geomagneticData: FloatArray? = null
+
+ override fun onSensorChanged(event: SensorEvent) {
+ if (event.sensor.type == Sensor.TYPE_ACCELEROMETER) accelerometerData = event.values
+ if (event.sensor.type == Sensor.TYPE_MAGNETIC_FIELD) geomagneticData = event.values
+ if (accelerometerData != null && geomagneticData != null) {
+ val R = FloatArray(9)
+ val success = SensorManager.getRotationMatrix(
+ R,
+ FloatArray(9),
+ accelerometerData,
+ geomagneticData
+ )
+ if (success) {
+ val orientationData = FloatArray(3)
+ SensorManager.getOrientation(R, orientationData)
+ callback(
+ orientationData[0],
+ orientationData[1],
+ orientationData[2]
+ )
+ }
+ }
+ }
+
+ override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {}
+}
+
+inline fun SensorEventListener(crossinline callback: (SensorEvent) -> Unit) = object: SensorEventListener {
+ override fun onSensorChanged(event: SensorEvent) {
+ callback(event)
+ }
+ override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {}
+}
+
+inline val Context.isScreenLocked get() = (getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager).isKeyguardLocked
+
+@Suppress("MemberVisibilityCanBePrivate", "unused")
+class NotificationIdManager(vararg reservedIds: Int) {
+ private val reserved = reservedIds.toMutableList()
+
+ fun reserve(id: Int) {
+ reserved.add(id)
+ }
+
+ fun release(id: Int) {
+ reserved.remove(id)
+ }
+
+ fun get(): Int {
+ while (true) {
+ val random = Random.nextInt(0..Int.MAX_VALUE)
+ if (!reserved.contains(random)) {
+ return random
+ }
+ }
+ }
+
+ fun getAndReserve(): Int {
+ val id = get()
+ reserve(id)
+ return id
+ }
+}
+
+@Composable
+fun PaddingValues.copy(
+ start: Dp? = null,
+ end: Dp? = null,
+ top: Dp? = null,
+ bottom: Dp? = null
+) = PaddingValues(
+ start = start ?: calculateStartPadding(LocalLayoutDirection.current),
+ end = end ?: calculateEndPadding(LocalLayoutDirection.current),
+ top = top ?: calculateTopPadding(),
+ bottom = bottom ?: calculateBottomPadding()
+)
+
+@Suppress("unused")
+@Composable
+fun PaddingValues.copy(
+ horizontal: Dp? = null,
+ vertical: Dp? = null
+) = copy(
+ start = horizontal,
+ end = horizontal,
+ top = vertical,
+ bottom = vertical
+)
+
+inline fun InputStream.test() =
+ try {
+ read()
+ close()
+ true
+ } catch (_: IOException) {
+ false
+ }
+
+inline fun ContentResolver.test(item: Uri) =
+ try {
+ openInputStream(item)!!.test()
+ } catch (_: Exception) {
+ false
+ }
+
+inline fun Intent.getParcelableExtraAs(key: String): T {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ getParcelableExtra(key, T::class.java)
+ } else {
+ @Suppress("DEPRECATION")
+ getParcelableExtra(key)
+ }!!
+}
+
+inline fun Intent.getSerializableExtraAs(key: String): T {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ getSerializableExtra(key, T::class.java)
+ } else {
+ @Suppress("DEPRECATION")
+ getSerializableExtra(key) as T
+ }!!
+}
+
+fun ActivityInfo.contentEquals(other: ActivityInfo): Boolean {
+ return (
+ (
+ if (Build.VERSION.SDK_INT >= 26)
+ colorMode == other.colorMode
+ else true
+ ) &&
+ (
+ if (Build.VERSION.SDK_INT >= 34)
+ requiredDisplayCategory == other.requiredDisplayCategory
+ else true
+ ) &&
+ theme == other.theme &&
+ launchMode == other.launchMode &&
+ documentLaunchMode == other.documentLaunchMode &&
+ permission == other.permission &&
+ taskAffinity == other.taskAffinity &&
+ targetActivity == other.targetActivity &&
+ flags == other.flags &&
+ screenOrientation == other.screenOrientation &&
+ configChanges == other.configChanges &&
+ softInputMode == other.softInputMode &&
+ uiOptions == other.uiOptions &&
+ parentActivityName == other.parentActivityName &&
+ maxRecents == other.maxRecents &&
+ windowLayout == other.windowLayout
+ )
+}
+
+val ActivityInfo.launchIntent get() = Intent().apply {
+ component = ComponentName(packageName!!, name)
+ flags = FLAG_ACTIVITY_NEW_TASK
+}
+
+@RequiresPermission(Manifest.permission.QUERY_ALL_PACKAGES)
+fun ActivityInfo.isLauncher(context: Context): Boolean {
+ with (context) {
+ @Suppress("ExplicitThis")
+ val intent = launchIntent.apply {
+ action = ACTION_MAIN
+ addCategory(CATEGORY_LAUNCHER)
+ component = ComponentName(this@isLauncher.packageName!!, name)
+ flags = FLAG_ACTIVITY_NEW_TASK
+ }
+ val infos = packageManager.queryIntentActivities(intent, PackageManager.GET_RESOLVED_FILTER)
+ for (info in infos) {
+ val filter = info.filter
+ if (
+ info.activityInfo.contentEquals(this@isLauncher) &&
+ filter != null &&
+ filter.hasAction(ACTION_MAIN) &&
+ filter.hasCategory(CATEGORY_LAUNCHER)
+ ) {
+ return true
+ }
+ }
+ return false
+ }
+}
+
+
+
+@Suppress("unused", "RedundantSuppression")
+operator fun WeakReference.getValue(thisRef: Any?, property: KProperty<*>) = get()!!
+
+@Suppress("unused")
+inline fun runOrLog(
+ tag: String,
+ message: String = "An error occurred:",
+ crossinline block: () -> Unit
+) {
+ try {
+ block()
+ } catch (e: Exception) {
+ Log.e(tag, message, e)
+ }
+}
+
+@Suppress("unused")
+typealias WidthSizeClass = WindowWidthSizeClass
+@Suppress("unused")
+typealias HeightSizeClass = WindowHeightSizeClass
+@Suppress("unused")
+typealias SizeClass = WindowSizeClass
+
+@RequiresPermission(Manifest.permission.POST_NOTIFICATIONS)
+inline fun Context.notifyIfAllowed(
+ id: Int,
+ notification: Notification
+) {
+ with (NotificationManagerCompat.from(this)) {
+ if (
+ checkSelfPermission(
+ Manifest.permission.POST_NOTIFICATIONS
+ ) != PackageManager.PERMISSION_GRANTED
+ ) {
+ return@with
+ }
+ notify(id, notification)
+ }
+}
+
+@Suppress("DEPRECATION")
+fun BaseBundle.toMap(): Map {
+ val map = mutableMapOf()
+ for (key in keySet()) {
+ map[key] = get(key)!!
+ }
+ return map
+}
+
+fun Bundle.toPersistableBundle(): PersistableBundle {
+ val map = toMap()
+ val bundle = PersistableBundle()
+ for ((key, value) in map) {
+ when (value) {
+ is Int -> bundle.putInt(key, value)
+ is Long -> bundle.putLong(key, value)
+ is Double -> bundle.putDouble(key, value)
+ is String -> bundle.putString(key, value)
+ is IntArray -> bundle.putIntArray(key, value)
+ is LongArray -> bundle.putLongArray(key, value)
+ is DoubleArray -> bundle.putDoubleArray(key, value)
+ is PersistableBundle -> bundle.putPersistableBundle(key, value)
+ is Boolean -> bundle.putBoolean(key, value)
+ is BooleanArray -> bundle.putBooleanArray(key, value)
+ is Array<*> -> {
+ if (value.isArrayOf()) {
+ @Suppress("UNCHECKED_CAST")
+ bundle.putStringArray(key, value as Array)
+ }
+ }
+ }
+ }
+ return bundle
+}
+
+inline fun String.decodeWindows1251() = String(
+ toByteArray(Charsets.ISO_8859_1),
+ Charsets.UTF_8
+)
+
+@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S)
+actual val materialYouAvailable get() = Build.VERSION.SDK_INT >= 31
\ No newline at end of file
diff --git a/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/UtilsLibrary.kt b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/UtilsLibrary.kt
new file mode 100644
index 0000000..c896098
--- /dev/null
+++ b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/UtilsLibrary.kt
@@ -0,0 +1,14 @@
+package com.pr0gramm3r101.utils
+
+import android.content.Context
+import java.lang.ref.WeakReference
+
+actual object UtilsLibrary {
+ private var init = false
+ private lateinit var _context: WeakReference
+ internal val context get() = _context.get()!!
+
+ actual fun init(vararg args: Any) {
+ _context = WeakReference(args[0] as Context)
+ }
+}
\ No newline at end of file
diff --git a/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/crypto/Base64.android.kt b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/crypto/Base64.android.kt
new file mode 100644
index 0000000..61f495d
--- /dev/null
+++ b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/crypto/Base64.android.kt
@@ -0,0 +1,13 @@
+package com.pr0gramm3r101.utils.crypto
+
+import android.util.Base64 as AndroidBase64
+
+actual object Base64 {
+ actual fun encode(bytes: ByteArray): String {
+ return AndroidBase64.encodeToString(bytes, AndroidBase64.NO_WRAP)
+ }
+
+ actual fun decode(base64: String): ByteArray {
+ return AndroidBase64.decode(base64, AndroidBase64.NO_WRAP)
+ }
+}
diff --git a/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/crypto/Hmac.android.kt b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/crypto/Hmac.android.kt
new file mode 100644
index 0000000..4ae6c08
--- /dev/null
+++ b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/crypto/Hmac.android.kt
@@ -0,0 +1,15 @@
+package com.pr0gramm3r101.utils.crypto
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import javax.crypto.Mac
+import javax.crypto.spec.SecretKeySpec
+
+actual object Hmac {
+ actual suspend fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray = withContext(Dispatchers.Default) {
+ val mac = Mac.getInstance("HmacSHA256")
+ val secretKey = SecretKeySpec(key, "HmacSHA256")
+ mac.init(secretKey)
+ mac.doFinal(data)
+ }
+}
diff --git a/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/AndroidSettings.kt b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/AndroidSettings.kt
new file mode 100644
index 0000000..0221f8d
--- /dev/null
+++ b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/AndroidSettings.kt
@@ -0,0 +1,115 @@
+package com.pr0gramm3r101.utils.settings
+
+import android.content.Context
+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
+import kotlinx.coroutines.runBlocking
+import com.pr0gramm3r101.utils.settings.DataStoreSingleton
+
+class AndroidSettings(private val context: Context): Settings {
+ private val dataStore: DataStore
+ get() = DataStoreSingleton.dataStore
+ override fun putString(key: String, value: String) {
+ runBlocking {
+ dataStore.edit { preferences ->
+ preferences[stringPreferencesKey(key)] = value
+ }
+ }
+ }
+
+ override fun getString(key: String, default: String) = runBlocking {
+ dataStore.data.map { preferences ->
+ preferences[stringPreferencesKey(key)] ?: default
+ }.first()
+ }
+
+ override fun putInt(key: String, value: Int) = runBlocking {
+ dataStore.edit { preferences ->
+ preferences[intPreferencesKey(key)] = value
+ }
+ Unit
+ }
+
+ override fun getInt(key: String, default: Int) = runBlocking {
+ dataStore.data.map { preferences ->
+ preferences[intPreferencesKey(key)] ?: default
+ }.first()
+ }
+
+ override fun putLong(key: String, value: Long) = runBlocking {
+ dataStore.edit { preferences ->
+ preferences[longPreferencesKey(key)] = value
+ }
+ Unit
+ }
+
+ override fun getLong(key: String, default: Long) = runBlocking {
+ dataStore.data.map { preferences ->
+ preferences[longPreferencesKey(key)] ?: default
+ }.first()
+ }
+
+ override fun putFloat(key: String, value: Float) = runBlocking {
+ dataStore.edit { preferences ->
+ preferences[floatPreferencesKey(key)] = value
+ }
+ Unit
+ }
+
+ override fun getFloat(key: String, default: Float) = runBlocking {
+ dataStore.data.map { preferences ->
+ preferences[floatPreferencesKey(key)] ?: default
+ }.first()
+ }
+
+ override fun putBoolean(key: String, value: Boolean) = runBlocking {
+ dataStore.edit { preferences ->
+ preferences[booleanPreferencesKey(key)] = value
+ }
+ Unit
+ }
+
+ override fun getBoolean(key: String, default: Boolean) = runBlocking {
+ dataStore.data.map { preferences ->
+ preferences[booleanPreferencesKey(key)] ?: default
+ }.first()
+ }
+
+ override fun putStringSet(key: String, value: Set) = runBlocking {
+ dataStore.edit { preferences ->
+ preferences[stringSetPreferencesKey(key)] = value
+ }
+ Unit
+ }
+
+ override fun getStringSet(key: String, default: Set) = runBlocking {
+ dataStore.data.map { preferences ->
+ preferences[stringSetPreferencesKey(key)] ?: default
+ }.first()
+ }
+
+ override fun putStringList(key: String, value: List) = putStringSet(key, value.toSet())
+
+ override fun getStringList(key: String, default: List) = getStringSet(key, default.toSet()).toList()
+
+ override fun remove(key: String) = runBlocking {
+ 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))
+ }
+ Unit
+ }
+}
\ No newline at end of file
diff --git a/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/AsyncSettings.android.kt b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/AsyncSettings.android.kt
new file mode 100644
index 0000000..749b4a9
--- /dev/null
+++ b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/AsyncSettings.android.kt
@@ -0,0 +1,129 @@
+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 com.pr0gramm3r101.utils.settings.DataStoreSingleton
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+
+class AndroidAsyncSettings : AsyncSettings {
+ private val dataStore: DataStore
+ 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) {
+ dataStore.edit { preferences ->
+ preferences[stringSetPreferencesKey(key)] = value
+ }
+ }
+
+ override suspend fun getStringSet(key: String, default: Set): Set {
+ return dataStore.data.map { preferences ->
+ preferences[stringSetPreferencesKey(key)] ?: default
+ }.first()
+ }
+
+ override suspend fun putStringList(key: String, value: List) {
+ putStringSet(key, value.toSet())
+ }
+
+ override suspend fun getStringList(key: String, default: List): List {
+ 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()
diff --git a/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/DataStoreSingleton.kt b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/DataStoreSingleton.kt
new file mode 100644
index 0000000..d07fb95
--- /dev/null
+++ b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/DataStoreSingleton.kt
@@ -0,0 +1,17 @@
+package com.pr0gramm3r101.utils.settings
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.preferencesDataStore
+import com.pr0gramm3r101.utils.UtilsLibrary
+
+/**
+ * Singleton DataStore instance to avoid multiple DataStore instances for the same file
+ */
+object DataStoreSingleton {
+ private val Context.dataStore: DataStore by preferencesDataStore(name = "settings")
+
+ val dataStore: DataStore
+ get() = UtilsLibrary.context.dataStore
+}
diff --git a/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/SettingsFactory.android.kt b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/SettingsFactory.android.kt
new file mode 100644
index 0000000..fc36384
--- /dev/null
+++ b/utils/src/androidMain/kotlin/com/pr0gramm3r101/utils/settings/SettingsFactory.android.kt
@@ -0,0 +1,5 @@
+package com.pr0gramm3r101.utils.settings
+
+import com.pr0gramm3r101.utils.UtilsLibrary
+
+actual val settings: Settings get() = AndroidSettings(UtilsLibrary.context)
\ No newline at end of file
diff --git a/utils/src/androidMain/res/values-ru/strings.xml b/utils/src/androidMain/res/values-ru/strings.xml
new file mode 100644
index 0000000..2aaf96d
--- /dev/null
+++ b/utils/src/androidMain/res/values-ru/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Показать пароль
+ Скрыть пароль
+ Что-то пошло не так
+
\ No newline at end of file
diff --git a/utils/src/androidMain/res/values/strings.xml b/utils/src/androidMain/res/values/strings.xml
new file mode 100644
index 0000000..ba4ee06
--- /dev/null
+++ b/utils/src/androidMain/res/values/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Show password
+ Hide password
+ Something went wrong
+
\ No newline at end of file
diff --git a/utils/src/androidMain/res/values/themes.xml b/utils/src/androidMain/res/values/themes.xml
new file mode 100644
index 0000000..5bc96a1
--- /dev/null
+++ b/utils/src/androidMain/res/values/themes.xml
@@ -0,0 +1,14 @@
+
+
+
+
\ No newline at end of file
diff --git a/utils/src/commonMain/composeResources/drawable/license.xml b/utils/src/commonMain/composeResources/drawable/license.xml
new file mode 100644
index 0000000..23fb3cf
--- /dev/null
+++ b/utils/src/commonMain/composeResources/drawable/license.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/utils/src/commonMain/composeResources/drawable/siren_filled.xml b/utils/src/commonMain/composeResources/drawable/siren_filled.xml
new file mode 100644
index 0000000..3dffcb1
--- /dev/null
+++ b/utils/src/commonMain/composeResources/drawable/siren_filled.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/utils/src/commonMain/composeResources/drawable/siren_outlined.xml b/utils/src/commonMain/composeResources/drawable/siren_outlined.xml
new file mode 100644
index 0000000..70acd19
--- /dev/null
+++ b/utils/src/commonMain/composeResources/drawable/siren_outlined.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/utils/src/commonMain/kotlin/com/pr0gramm3r101/components/Components.kt b/utils/src/commonMain/kotlin/com/pr0gramm3r101/components/Components.kt
new file mode 100644
index 0000000..0be4a01
--- /dev/null
+++ b/utils/src/commonMain/kotlin/com/pr0gramm3r101/components/Components.kt
@@ -0,0 +1,1086 @@
+@file:Suppress("unused", "MemberVisibilityCanBePrivate", "UnusedReceiverParameter", "NOTHING_TO_INLINE")
+
+package com.pr0gramm3r101.components
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.LocalIndication
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.foundation.selection.selectableGroup
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Archive
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonColors
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ButtonElevation
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults.cardColors
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.CheckboxColors
+import androidx.compose.material3.CheckboxDefaults
+import androidx.compose.material3.DividerDefaults
+import androidx.compose.material3.ElevatedButton
+import androidx.compose.material3.FilledIconButton
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.FilledTonalIconButton
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButtonColors
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ProvideTextStyle
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.SwipeToDismissBoxState
+import androidx.compose.material3.SwipeToDismissBoxValue
+import androidx.compose.material3.Switch
+import androidx.compose.material3.SwitchColors
+import androidx.compose.material3.SwitchDefaults
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.VerticalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.pr0gramm3r101.utils.invoke
+import com.pr0gramm3r101.utils.left
+import com.pr0gramm3r101.utils.link
+import com.pr0gramm3r101.utils.plus
+import com.pr0gramm3r101.utils.right
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import tech.annexflow.constraintlayout.compose.ConstraintLayout
+import tech.annexflow.constraintlayout.compose.ConstraintLayoutScope
+import tech.annexflow.constraintlayout.compose.Dimension
+import kotlin.random.Random
+
+@Composable
+fun ListItem(
+ modifier: Modifier = Modifier,
+ bodyModifier: Modifier = Modifier,
+ headline: String,
+ supportingText: String? = null,
+ leadingContent: (@Composable ConstraintLayoutScope.() -> Unit)? = null,
+ trailingContent: (@Composable ConstraintLayoutScope.() -> Unit)? = null,
+ enabled: Boolean = true,
+ divider: Boolean = false,
+ dividerColor: Color = DividerDefaults.color,
+ dividerThickness: Dp = DividerDefaults.Thickness,
+ dividerAnimated: Boolean = false,
+ onClick: (() -> Unit)? = null,
+ bodyOnClick: (() -> Unit)? = null,
+ leadingAndBodyShared: Boolean = false,
+ bottomContent: (@Composable ConstraintLayoutScope.() -> Unit)? = null
+) {
+ Column {
+ @Composable
+ fun ProvideStyle(
+ content: @Composable BoxScope?.() -> Unit
+ ) {
+ if (!enabled) {
+ CompositionLocalProvider(
+ value = LocalContentColor provides MaterialTheme.colorScheme.onSurface,
+ ) {
+ Box(
+ modifier = Modifier.alpha(0.38f),
+ content = content
+ )
+ }
+ } else {
+ content(null)
+ }
+ }
+
+ ProvideStyle {
+ ConstraintLayout(
+ modifier = Modifier.let {
+ var mod = it.fillMaxWidth()
+ if (onClick != null) {
+ mod += Modifier.clickable(onClick = onClick)
+ }
+ mod + modifier
+ }
+ ) {
+ val (leading, listItem, trailing, btm) = createRefs()
+
+ @Composable
+ fun leadingBody() {
+ if (leadingContent != null) {
+ ConstraintLayout(
+ modifier = Modifier
+ .constrainAs(leading) {
+ top link parent.top
+ bottom link
+ if (bottomContent != null) btm.top
+ else parent.bottom
+ left link parent.left
+ right link listItem.left
+ }
+ .padding(
+ start = 16.dp,
+ top = 8.dp,
+ bottom = 8.dp
+ ),
+ content = leadingContent
+ )
+ }
+ ListItem(
+ headlineContent = { Text(headline) },
+ supportingContent = { if (supportingText != null) Text(supportingText) },
+ modifier = Modifier
+ .let {
+ var mod = it.constrainAs(listItem) {
+ top link parent.top
+ bottom link
+ if (bottomContent != null) btm.top
+ else parent.bottom
+
+ left link
+ if (leadingContent != null) leading.right
+ else parent.left
+ right link
+ if (trailingContent != null) trailing.left
+ else parent.right
+ width = Dimension.fillToConstraints
+ }
+ if (bodyOnClick != null && !leadingAndBodyShared) {
+ mod += Modifier.clickable(onClick = bodyOnClick)
+ }
+ mod + bodyModifier
+ },
+ colors = ListItemDefaults.colors(
+ containerColor = Color.Transparent
+ )
+ )
+ }
+
+ if (leadingAndBodyShared) {
+ Row(
+ modifier = Modifier
+ .constrainAs(listItem) {
+ top link parent.top
+ bottom link
+ if (bottomContent != null) btm.top
+ else parent.bottom
+ left link parent.left
+ right link
+ if (trailingContent != null) trailing.left
+ else parent.right
+ width = Dimension.fillToConstraints
+ }
+ +
+ if (bodyOnClick != null)
+ Modifier.clickable(onClick = bodyOnClick)
+ else
+ Modifier,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ leadingBody()
+ }
+ } else {
+ leadingBody()
+ }
+
+ if (trailingContent != null) {
+ ConstraintLayout(
+ modifier = Modifier
+ .constrainAs(trailing) {
+ top link parent.top
+ bottom link
+ if (bottomContent != null) btm.top
+ else parent.bottom
+ right link parent.right
+ left link listItem.right
+ }
+ .padding(
+ end = 16.dp,
+ top = 8.dp,
+ bottom = 8.dp
+ ),
+ content = trailingContent
+ )
+ }
+ if (bottomContent != null) {
+ ConstraintLayout(
+ modifier = Modifier
+ .constrainAs(btm) {
+ bottom link parent.bottom
+ left link parent.left
+ right link parent.right
+ width = Dimension.fillToConstraints
+ }
+ .padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
+ content = bottomContent
+ )
+ }
+ }
+ }
+ if (dividerAnimated) {
+ AnimatedVisibility(
+ visible = divider,
+ enter = expandVertically(),
+ exit = shrinkVertically()
+ ) {
+ HorizontalDivider(
+ color = dividerColor,
+ thickness = dividerThickness
+ )
+ }
+ } else if (divider) {
+ HorizontalDivider(
+ color = dividerColor,
+ thickness = dividerThickness
+ )
+ }
+ }
+}
+
+@Composable
+inline fun SwitchListItem(
+ modifier: Modifier = Modifier,
+ headline: String,
+ supportingText: String? = null,
+ noinline leadingContent: (@Composable ConstraintLayoutScope.() -> Unit)? = null,
+ checked: Boolean,
+ noinline onCheckedChange: (Boolean) -> Unit,
+ crossinline listItemOnClick: () -> Unit = { onCheckedChange(!checked) },
+ divider: Boolean = false,
+ dividerColor: Color = DividerDefaults.color,
+ dividerThickness: Dp = DividerDefaults.Thickness,
+ dividerAnimated: Boolean = false,
+ enabled: Boolean = true
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ ListItem(
+ modifier = if (enabled) Modifier.clickable(
+ interactionSource = interactionSource,
+ indication = LocalIndication(),
+ onClick = { listItemOnClick() }
+ ) + modifier else modifier,
+ headline = headline,
+ supportingText = supportingText,
+ leadingContent = leadingContent,
+ trailingContent = {
+ Switch(
+ checked = checked,
+ onCheckedChange = onCheckedChange,
+ interactionSource = interactionSource,
+ enabled = enabled
+ )
+ },
+ divider = divider,
+ dividerColor = dividerColor,
+ dividerThickness = dividerThickness,
+ dividerAnimated = dividerAnimated,
+ enabled = enabled
+ )
+}
+
+@Composable
+inline fun SeparatedSwitchListItem(
+ modifier: Modifier = Modifier,
+ bodyModifier: Modifier = Modifier,
+ headline: String,
+ supportingText: String? = null,
+ noinline leadingContent: (@Composable ConstraintLayoutScope.() -> Unit)? = null,
+ checked: Boolean,
+ noinline onCheckedChange: (Boolean) -> Unit,
+ noinline bodyOnClick: () -> Unit,
+ divider: Boolean = false,
+ dividerColor: Color = DividerDefaults.color,
+ dividerThickness: Dp = DividerDefaults.Thickness,
+ dividerAnimated: Boolean = false,
+ enabled: Boolean = true
+) {
+ ListItem(
+ modifier = modifier,
+ bodyModifier = bodyModifier,
+ bodyOnClick = bodyOnClick,
+ headline = headline,
+ supportingText = supportingText,
+ leadingContent = leadingContent,
+ trailingContent = {
+ val (div, switch) = createRefs()
+ VerticalDivider(
+ modifier = Modifier
+ .padding()
+ .padding(end = 16.dp)
+ .constrainAs(div) {
+ top link parent.top
+ bottom link parent.bottom
+ left link parent.left
+ right link switch.left
+ height = Dimension.fillToConstraints
+ }
+ )
+ Switch(
+ checked = checked,
+ onCheckedChange = onCheckedChange,
+ modifier = Modifier.constrainAs(switch) {
+ top link parent.top
+ bottom link parent.bottom
+ right link parent.right
+ left link div.right
+ },
+ enabled = enabled
+ )
+ },
+ divider = divider,
+ dividerColor = dividerColor,
+ dividerThickness = dividerThickness,
+ dividerAnimated = dividerAnimated,
+ leadingAndBodyShared = true,
+ enabled = enabled
+ )
+}
+
+@Composable
+fun SwitchCard(
+ modifier: Modifier = Modifier,
+ text: String,
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+ cardOnClick: () -> Unit = { onCheckedChange(!checked) }
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ Card(
+ modifier = Modifier
+ .padding(horizontal = 16.dp, vertical = 10.dp)
+ .fillMaxWidth()
+ + modifier,
+ colors = cardColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer
+ ),
+ shape = RoundedCornerShape(25.dp)
+ ) {
+ Box(
+ Modifier.clickable(
+ interactionSource = interactionSource,
+ indication = LocalIndication(),
+ onClick = cardOnClick
+ )
+ ) {
+ ConstraintLayout(
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp, vertical = 10.dp)
+ ) {
+ val (txt, switch) = createRefs()
+ Box(
+ modifier = Modifier.constrainAs(txt) {
+ top link parent.top
+ bottom link parent.bottom
+ left link parent.left
+ right link switch.left
+ width = Dimension.fillToConstraints
+ height = Dimension.fillToConstraints
+ },
+ contentAlignment = Alignment.CenterStart
+ ) {
+ Text(
+ text = text,
+ fontSize = 20.sp
+ )
+ }
+ Switch(
+ checked = checked,
+ onCheckedChange = onCheckedChange,
+ modifier = Modifier.constrainAs(switch) {
+ top link parent.top
+ bottom link parent.bottom
+ right link parent.right
+ },
+ interactionSource = interactionSource
+ )
+ }
+ }
+ }
+}
+
+
+// TODO fix
+/*@Composable
+inline fun SecureTextField(
+ value: String,
+ noinline onValueChange: (String) -> Unit,
+ passwordHidden: Boolean,
+ noinline visibilityOnClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ textStyle: TextStyle = LocalTextStyle(),
+ noinline label: @Composable (() -> Unit)? = null,
+ noinline leadingIcon: @Composable (() -> Unit)? = null,
+ isError: Boolean = false,
+ keyboardActions: KeyboardActions = KeyboardActions.Default,
+ interactionSource: MutableInteractionSource? = null,
+ shape: Shape = TextFieldDefaults.shape,
+ colors: TextFieldColors = TextFieldDefaults.colors()
+) {
+ TextField(
+ value = value,
+ onValueChange = onValueChange,
+ visualTransformation =
+ if (!passwordHidden)
+ VisualTransformation.None
+ else
+ PasswordVisualTransformation(),
+ label = label,
+ trailingIcon = {
+ IconButton(
+ onClick = visibilityOnClick
+ ) {
+ val visibilityIcon =
+ if (passwordHidden) Icons.Filled.Visibility else
+ Icons.Filled.VisibilityOff
+ // Provide localized description for accessibility services
+ val description =
+ if (passwordHidden)
+ stringResource(R.string.show_pw)
+ else
+ stringResource(R.string.hide_pw)
+ Icon(imageVector = visibilityIcon, contentDescription = description)
+ }
+ },
+ modifier = modifier,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Password,
+ autoCorrectEnabled = false
+ ),
+ maxLines = 1,
+ singleLine = true,
+ enabled = enabled,
+ textStyle = textStyle,
+ leadingIcon = leadingIcon,
+ isError = isError,
+ keyboardActions = keyboardActions,
+ interactionSource = interactionSource,
+ shape = shape,
+ colors = colors
+ )
+}*/
+
+@Composable
+inline fun ToggleIconButton(
+ checked: Boolean,
+ crossinline onCheckedChange: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ shape: Shape = IconButtonDefaults.filledShape,
+ checkedColors: IconButtonColors = IconButtonDefaults.filledIconButtonColors(),
+ uncheckedColors: IconButtonColors = IconButtonDefaults.filledTonalIconButtonColors(),
+ interactionSource: MutableInteractionSource? = null,
+ noinline content: @Composable () -> Unit
+) {
+ if (checked) {
+ FilledIconButton(
+ onClick = { onCheckedChange(false) },
+ modifier = Modifier.size(60.dp, 60.dp) + modifier,
+ enabled = enabled,
+ shape = shape,
+ colors = checkedColors,
+ interactionSource = interactionSource,
+ content = content
+ )
+ } else {
+ FilledTonalIconButton(
+ onClick = { onCheckedChange(true) },
+ modifier = Modifier.size(60.dp, 60.dp) + modifier,
+ enabled = enabled,
+ shape = shape,
+ colors = uncheckedColors,
+ interactionSource = interactionSource,
+ content = content
+ )
+ }
+}
+
+@Composable
+inline fun ElevatedButton(
+ noinline onClick: () -> Unit,
+ crossinline icon: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ shape: Shape = ButtonDefaults.elevatedShape,
+ colors: ButtonColors = ButtonDefaults.elevatedButtonColors(),
+ elevation: ButtonElevation? = ButtonDefaults.elevatedButtonElevation(),
+ border: BorderStroke? = null,
+ contentPadding: PaddingValues = ButtonDefaults.ButtonWithIconContentPadding,
+ interactionSource: MutableInteractionSource? = null,
+ crossinline content: @Composable RowScope.() -> Unit
+) {
+ ElevatedButton(
+ onClick = onClick,
+ modifier = modifier,
+ enabled = enabled,
+ shape = shape,
+ colors = colors,
+ elevation = elevation,
+ border = border,
+ contentPadding = contentPadding,
+ interactionSource = interactionSource
+ ) {
+ Box(
+ modifier = Modifier.size(18.dp)
+ ) {
+ icon()
+ }
+ Spacer(Modifier.width(8.dp))
+ content()
+ }
+}
+
+@Composable
+inline fun Button(
+ noinline onClick: () -> Unit,
+ crossinline icon: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ shape: Shape = ButtonDefaults.shape,
+ colors: ButtonColors = ButtonDefaults.buttonColors(),
+ elevation: ButtonElevation? = ButtonDefaults.buttonElevation(),
+ border: BorderStroke? = null,
+ contentPadding: PaddingValues = ButtonDefaults.ButtonWithIconContentPadding,
+ interactionSource: MutableInteractionSource? = null,
+ crossinline content: @Composable RowScope.() -> Unit
+) {
+ Button(
+ onClick = onClick,
+ modifier = modifier,
+ enabled = enabled,
+ shape = shape,
+ colors = colors,
+ elevation = elevation,
+ border = border,
+ contentPadding = contentPadding,
+ interactionSource = interactionSource
+ ) {
+ Box(
+ modifier = Modifier.size(18.dp)
+ ) {
+ icon()
+ }
+ Spacer(Modifier.width(8.dp))
+ content()
+ }
+}
+
+@Composable
+inline fun FilledTonalButton(
+ noinline onClick: () -> Unit,
+ crossinline icon: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ shape: Shape = ButtonDefaults.filledTonalShape,
+ colors: ButtonColors = ButtonDefaults.filledTonalButtonColors(),
+ elevation: ButtonElevation? = ButtonDefaults.filledTonalButtonElevation(),
+ border: BorderStroke? = null,
+ contentPadding: PaddingValues = ButtonDefaults.ButtonWithIconContentPadding,
+ interactionSource: MutableInteractionSource? = null,
+ crossinline content: @Composable RowScope.() -> Unit
+) {
+ FilledTonalButton(
+ onClick = onClick,
+ modifier = modifier,
+ enabled = enabled,
+ shape = shape,
+ colors = colors,
+ elevation = elevation,
+ border = border,
+ contentPadding = contentPadding,
+ interactionSource = interactionSource
+ ) {
+ Box(
+ modifier = Modifier.size(18.dp)
+ ) {
+ icon()
+ }
+ Spacer(Modifier.width(8.dp))
+ content()
+ }
+}
+
+@Composable
+inline fun TextButton(
+ noinline onClick: () -> Unit,
+ crossinline icon: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ shape: Shape = ButtonDefaults.textShape,
+ colors: ButtonColors = ButtonDefaults.textButtonColors(),
+ elevation: ButtonElevation? = null,
+ border: BorderStroke? = null,
+ contentPadding: PaddingValues = ButtonDefaults.TextButtonWithIconContentPadding,
+ interactionSource: MutableInteractionSource? = null,
+ crossinline content: @Composable RowScope.() -> Unit
+) {
+ TextButton(
+ onClick = onClick,
+ modifier = modifier,
+ enabled = enabled,
+ shape = shape,
+ colors = colors,
+ elevation = elevation,
+ border = border,
+ contentPadding = contentPadding,
+ interactionSource = interactionSource
+ ) {
+ Box(
+ modifier = Modifier.size(18.dp)
+ ) {
+ icon()
+ }
+ Spacer(Modifier.width(8.dp))
+ content()
+ }
+}
+
+class RadioButtonControllerScope @PublishedApi internal constructor() {
+ private val radioButtons = mutableMapOf>()
+
+ val checkedItemAsState: MutableState = mutableStateOf(null)
+
+ private fun processCheckedChange(id: T) {
+ val toModify = mutableListOf()
+
+ radioButtons.forEach { (i) ->
+ if (i != id) {
+ toModify.add(i)
+ }
+ }
+
+ toModify.forEach { i ->
+ radioButtons[i]!!.value = false
+ }
+ }
+
+ fun addRadioButton(id: T, checked: MutableState, coroutineScope: CoroutineScope) {
+ radioButtons[id] = checked
+ coroutineScope.launch {
+ snapshotFlow { checked.value }.collect {
+ if (it) {
+ processCheckedChange(id)
+ checkedItemAsState.value = checkedItem
+ }
+ }
+ }
+ }
+
+ fun addRadioButtons(
+ vararg radioButtons: Pair>,
+ coroutineScope: CoroutineScope
+ ) =
+ radioButtons.forEach { (id, checked) ->
+ addRadioButton(id, checked, coroutineScope)
+ }
+
+ fun removeRadioButton(id: T) = radioButtons.remove(id)
+
+ fun isChecked(id: T) = radioButtons[id]!!.value
+ val checkedItem: T?
+ get() = radioButtons.firstNotNullOfOrNull { if (it.value.value) it.key else null }
+
+ fun clearCheckedItems() = radioButtons.values.forEach { it.value = false }
+
+ interface Id {
+ val id: T
+ }
+
+ class RandomIntId internal constructor(): Id {
+ override val id = Random.nextInt()
+ }
+
+ class OrderedIntId internal constructor(override val id: Int): Id
+
+ abstract class Ids, T> {
+ abstract operator fun component1(): IdT
+ abstract operator fun component2(): IdT
+ abstract operator fun component3(): IdT
+ abstract operator fun component4(): IdT
+ abstract operator fun component5(): IdT
+ abstract operator fun component6(): IdT
+ abstract operator fun component7(): IdT
+ abstract operator fun component8(): IdT
+ abstract operator fun component9(): IdT
+ abstract operator fun component10(): IdT
+ abstract operator fun component11(): IdT
+ abstract operator fun component12(): IdT
+ abstract operator fun component13(): IdT
+ abstract operator fun component14(): IdT
+ abstract operator fun component15(): IdT
+ abstract operator fun component16(): IdT
+ }
+
+ class OrderedIntIds internal constructor(): Ids() {
+ override fun component1() = OrderedIntId(0)
+ override fun component2() = OrderedIntId(1)
+ override fun component3() = OrderedIntId(2)
+ override fun component4() = OrderedIntId(3)
+ override fun component5() = OrderedIntId(4)
+ override fun component6() = OrderedIntId(5)
+ override fun component7() = OrderedIntId(6)
+ override fun component8() = OrderedIntId(7)
+ override fun component9() = OrderedIntId(8)
+ override fun component10() = OrderedIntId(9)
+ override fun component11() = OrderedIntId(10)
+ override fun component12() = OrderedIntId(11)
+ override fun component13() = OrderedIntId(12)
+ override fun component14() = OrderedIntId(13)
+ override fun component15() = OrderedIntId(14)
+ override fun component16() = OrderedIntId(15)
+ }
+
+ @Suppress("SameReturnValue")
+ class Ints internal constructor() {
+ operator fun component1() = 0
+ operator fun component2() = 1
+ operator fun component3() = 2
+ operator fun component4() = 3
+ operator fun component5() = 4
+ operator fun component6() = 5
+ operator fun component7() = 6
+ operator fun component8() = 7
+ operator fun component9() = 8
+ operator fun component10() = 9
+ operator fun component11() = 10
+ operator fun component12() = 11
+ operator fun component13() = 12
+ operator fun component14() = 13
+ operator fun component15() = 14
+ operator fun component16() = 15
+ }
+
+ fun createRandomId() = RandomIntId()
+ fun createRandomIds() = object: Ids, Int>() {
+ override operator fun component1() = RandomIntId()
+ override operator fun component2() = RandomIntId()
+ override operator fun component3() = RandomIntId()
+ override operator fun component4() = RandomIntId()
+ override operator fun component5() = RandomIntId()
+ override operator fun component6() = RandomIntId()
+ override operator fun component7() = RandomIntId()
+ override operator fun component8() = RandomIntId()
+ override operator fun component9() = RandomIntId()
+ override operator fun component10() = RandomIntId()
+ override operator fun component11() = RandomIntId()
+ override operator fun component12() = RandomIntId()
+ override operator fun component13() = RandomIntId()
+ override operator fun component14() = RandomIntId()
+ override operator fun component15() = RandomIntId()
+ override operator fun component16() = RandomIntId()
+ }
+
+ fun createOrderedId(index: Int) = OrderedIntId(index)
+ fun createOrderedIds() = OrderedIntIds()
+
+ fun createIntIds() = Ints()
+}
+
+@Composable
+inline fun rememberRadioButtonController() = rememberSaveable { RadioButtonControllerScope() }
+
+@Composable
+inline fun RadioButtonController(
+ content: @Composable RadioButtonControllerScope.() -> Unit
+) {
+ content(RadioButtonControllerScope())
+}
+
+@Composable
+inline fun CheckboxWithText(
+ checked: Boolean,
+ crossinline onCheckedChange: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ colors: CheckboxColors = CheckboxDefaults.colors(),
+ contentPadding: PaddingValues = PaddingValues(horizontal = 16.dp),
+ content: @Composable () -> Unit
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp)
+ .padding(contentPadding)
+ +
+ if (enabled)
+ Modifier.toggleable(
+ value = checked,
+ onValueChange = { onCheckedChange(!checked) },
+ role = Role.Checkbox
+ )
+ else Modifier
+ + modifier,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Checkbox(
+ checked = checked,
+ onCheckedChange = null,
+ enabled = enabled,
+ colors = colors,
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ content()
+ }
+}
+
+@Composable
+inline fun SwitchWithText(
+ checked: Boolean,
+ crossinline onCheckedChange: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ colors: SwitchColors = SwitchDefaults.colors(),
+ contentPadding: PaddingValues = PaddingValues(horizontal = 16.dp),
+ content: @Composable () -> Unit
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp)
+ .padding(contentPadding)
+ +
+ if (enabled)
+ Modifier.toggleable(
+ value = checked,
+ onValueChange = { onCheckedChange(!checked) },
+ role = Role.Switch
+ )
+ else Modifier
+ + modifier,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ content()
+ Spacer(modifier = Modifier.weight(1f))
+ Switch(
+ checked = checked,
+ onCheckedChange = null,
+ enabled = enabled,
+ colors = colors,
+ )
+ }
+}
+
+
+@Composable
+inline fun RadioButtonWithText(
+ modifier: Modifier = Modifier,
+ selected: Boolean,
+ crossinline onSelectedChange: () -> Unit,
+ enabled: Boolean = true,
+ crossinline content: @Composable () -> Unit
+) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .height(50.dp)
+ .selectable(
+ selected = selected,
+ onClick = {
+ if (enabled) onSelectedChange()
+ },
+ role = Role.RadioButton
+ )
+ .padding(horizontal = 16.dp)
+ + modifier,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ RadioButton(
+ selected = selected,
+ onClick = null
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ ProvideTextStyle(value = MaterialTheme.typography.bodyLarge) {
+ content()
+ }
+ }
+}
+
+@Composable
+inline fun SimpleAlertDialog(
+ open: Boolean,
+ crossinline onDismissRequest: () -> Unit,
+ crossinline onPositiveButtonClick: (() -> Unit) = {},
+ crossinline onNegativeButtonClick: (() -> Unit) = {},
+ positiveButtonText: String? = null,
+ negativeButtonText: String? = null,
+ title: String,
+ body: String? = null,
+ dsaString: String? = null,
+ crossinline onDsa: (() -> Unit) = {},
+ noinline icon: @Composable (() -> Unit) = {},
+) {
+ if (open) {
+ var dsaChecked by remember { mutableStateOf(false) }
+
+ AlertDialog(
+ icon = icon,
+ title = {
+ Text(text = title)
+ },
+ text =
+ if (body != null || dsaString != null) {
+ {
+ if (body != null) {
+ Text(text = body)
+ }
+ if (dsaString != null) {
+ CheckboxWithText(
+ checked = dsaChecked,
+ onCheckedChange = { dsaChecked = it },
+ contentPadding = PaddingValues(top = 10.dp)
+ ) {
+ Text(text = dsaString)
+ }
+ }
+ }
+ } else null,
+ onDismissRequest = { onDismissRequest() },
+ confirmButton = {
+ if (!positiveButtonText.isNullOrBlank()) {
+ TextButton(
+ onClick = {
+ onPositiveButtonClick()
+ if (dsaChecked) onDsa()
+ onDismissRequest()
+ }
+ ) {
+ Text(positiveButtonText)
+ }
+ }
+ },
+ dismissButton = if (!negativeButtonText.isNullOrBlank()) {
+ {
+ TextButton(
+ onClick = {
+ onNegativeButtonClick()
+ if (dsaChecked) onDsa()
+ onDismissRequest()
+ }
+ ) {
+ Text(negativeButtonText)
+ }
+ }
+ } else null
+ )
+ }
+}
+
+@Composable
+inline fun RadioGroup(
+ modifier: Modifier = Modifier,
+ content: @Composable ColumnScope.() -> Unit
+) {
+ Column(Modifier.selectableGroup() + modifier) {
+ content()
+ }
+}
+
+@Composable
+inline fun SwipeToDismissBackground(
+ dismissState: SwipeToDismissBoxState,
+ startToEndColor: Color = Color(0xFFFF1744),
+ startToEndIcon: @Composable () -> Unit = {
+ Icon(
+ Icons.Default.Delete,
+ contentDescription = "Delete"
+ )
+ },
+ endToStartColor: Color = Color(0xFF1DE9B6),
+ endToStartIcon: @Composable () -> Unit = {
+ Icon(
+ Icons.Filled.Archive,
+ contentDescription = "Archive"
+ )
+ }
+) {
+ val color = when (dismissState.dismissDirection) {
+ SwipeToDismissBoxValue.StartToEnd -> startToEndColor
+ SwipeToDismissBoxValue.EndToStart -> endToStartColor
+ SwipeToDismissBoxValue.Settled -> Color.Transparent
+ }
+
+ Row(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(color)
+ .padding(12.dp, 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ startToEndIcon()
+ Spacer(modifier = Modifier)
+ endToStartIcon()
+ }
+}
+
+object CategoryDefaults {
+ val margin = PaddingValues(start = 16.dp, end = 16.dp, bottom = 20.dp)
+ val dividerColor @Composable get() = MaterialTheme.colorScheme.surface
+ val dividerThickness: Dp = 2.dp
+}
+
+@Composable
+inline fun ColumnScope.Category(
+ modifier: Modifier = Modifier,
+ title: String? = null,
+ margin: PaddingValues = CategoryDefaults.margin,
+ containerColor: Color = MaterialTheme.colorScheme.surfaceContainer,
+ noinline content: @Composable ColumnScope.() -> Unit
+) {
+ if (title != null) {
+ Text(
+ text = title,
+ color = MaterialTheme.colorScheme.primary,
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(bottom = 10.dp, start = 32.dp, end = 32.dp)
+ )
+ }
+ Card(
+ modifier = modifier
+ .padding(margin)
+ .fillMaxWidth(),
+ shape = MaterialTheme.shapes.extraLarge,
+ colors = cardColors(containerColor = containerColor),
+ content = content
+ )
+}
\ No newline at end of file
diff --git a/utils/src/commonMain/kotlin/com/pr0gramm3r101/ui/Icons.kt b/utils/src/commonMain/kotlin/com/pr0gramm3r101/ui/Icons.kt
new file mode 100644
index 0000000..96765ac
--- /dev/null
+++ b/utils/src/commonMain/kotlin/com/pr0gramm3r101/ui/Icons.kt
@@ -0,0 +1,29 @@
+@file:Suppress("UnusedReceiverParameter", "unused")
+
+package com.pr0gramm3r101.ui
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Language
+import androidx.compose.material.icons.outlined.Language
+import androidx.compose.runtime.Composable
+import org.jetbrains.compose.resources.vectorResource
+import com.pr0gramm3r101.utils.Res
+import com.pr0gramm3r101.utils.license
+import com.pr0gramm3r101.utils.siren_filled
+import com.pr0gramm3r101.utils.siren_outlined
+
+inline val Icons.Outlined.Internet get() = Icons.Outlined.Language
+inline val Icons.Filled.Internet get() = Icons.Filled.Language
+
+inline val Icons.Outlined.Website get() = Icons.Outlined.Language
+inline val Icons.Filled.Website get() = Icons.Filled.Language
+
+inline val Icons.Outlined.License
+ @Composable get() = vectorResource(Res.drawable.license)
+inline val Icons.Filled.License
+ @Composable get() = Icons.Outlined.License
+
+inline val Icons.Outlined.Siren
+ @Composable get() = vectorResource(Res.drawable.siren_outlined)
+inline val Icons.Filled.Siren
+ @Composable get() = vectorResource(Res.drawable.siren_filled)
\ No newline at end of file
diff --git a/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/Adaptive.kt b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/Adaptive.kt
new file mode 100644
index 0000000..101ff7e
--- /dev/null
+++ b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/Adaptive.kt
@@ -0,0 +1,290 @@
+@file:JvmName("Adaptive")
+
+package com.pr0gramm3r101.utils
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.toSize
+import kotlin.jvm.JvmField
+import kotlin.jvm.JvmName
+import kotlin.jvm.JvmStatic
+
+@Composable expect inline fun currentWindowSize(): IntSize
+
+/**
+ * Calculates and returns [WindowAdaptiveInfo] of the provided context. It's a convenient function
+ * that uses the default [WindowSizeClass] constructor to retrieve [WindowSizeClass].
+ *
+ * @return [WindowAdaptiveInfo] of the provided context
+ */
+@Composable
+fun currentWindowAdaptiveInfo(): WindowAdaptiveInfo {
+ val windowSize = with(LocalDensity.current) { currentWindowSize().toSize().toDpSize() }
+ return WindowAdaptiveInfo(
+ WindowSizeClass.compute(windowSize.width.value, windowSize.height.value)
+ )
+}
+
+/**
+ * This class collects window info that affects adaptation decisions. An adaptive layout is supposed
+ * to use the info from this class to decide how the layout is supposed to be adapted.
+ *
+ * @param windowSizeClass [WindowSizeClass] of the current window.
+ * @constructor create an instance of [WindowAdaptiveInfo]
+ */
+@Immutable
+class WindowAdaptiveInfo(val windowSizeClass: WindowSizeClass) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is WindowAdaptiveInfo) return false
+ if (windowSizeClass != other.windowSizeClass) return false
+ return true
+ }
+
+ override fun hashCode() = windowSizeClass.hashCode()
+
+ override fun toString(): String {
+ return "WindowAdaptiveInfo(windowSizeClass=$windowSizeClass)"
+ }
+}
+
+/**
+ * [WindowSizeClass] represents breakpoints for a viewport. The recommended width and height break
+ * points are presented through [windowWidthSizeClass] and [windowHeightSizeClass]. Designers
+ * should design around the different combinations of width and height buckets. Developers should
+ * use the different buckets to specify the layouts. Ideally apps will work well in each bucket and
+ * by extension work well across multiple devices. If two devices are in similar buckets they
+ * should behave similarly.
+ *
+ * This class is meant to be a common definition that can be shared across different device types.
+ * Application developers can use WindowSizeClass to have standard window buckets and design the UI
+ * around those buckets. Library developers can use these buckets to create different UI with
+ * respect to each bucket. This will help with consistency across multiple device types.
+ *
+ * A library developer use-case can be creating some navigation UI library. For a size
+ * class with the [WindowWidthSizeClass.EXPANDED] width it might be more reasonable to have a side
+ * navigation. For a [WindowWidthSizeClass.COMPACT] width, a bottom navigation might be a better
+ * fit.
+ *
+ * An application use-case can be applied for apps that use a list-detail pattern. The app can use
+ * the [WindowWidthSizeClass.MEDIUM] to determine if there is enough space to show the list and the
+ * detail side by side. If all apps follow this guidance then it will present a very consistent user
+ * experience.
+ *
+ * In some cases developers or UI systems may decide to create their own break points. A developer
+ * might optimize for a window that is smaller than the supported break points or larger. A UI
+ * system might find that some break points are better suited than the recommended break points.
+ * In these cases developers may wish to specify their own custom break points and match using
+ * a `when` statement.
+ *
+ * @see WindowWidthSizeClass
+ * @see WindowHeightSizeClass
+ */
+class WindowSizeClass private constructor(
+ /**
+ * Returns the [WindowWidthSizeClass] that corresponds to the widthDp of the window.
+ */
+ val windowWidthSizeClass: WindowWidthSizeClass,
+ /**
+ * Returns the [WindowHeightSizeClass] that corresponds to the heightDp of the window.
+ */
+ val windowHeightSizeClass: WindowHeightSizeClass
+) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other == null || this::class != other::class) return false
+
+ other as WindowSizeClass
+
+ if (windowWidthSizeClass != other.windowWidthSizeClass) return false
+ if (windowHeightSizeClass != other.windowHeightSizeClass) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = windowWidthSizeClass.hashCode()
+ result = 31 * result + windowHeightSizeClass.hashCode()
+ return result
+ }
+
+ override fun toString(): String {
+ return "WindowSizeClass {" +
+ "windowWidthSizeClass=$windowWidthSizeClass, " +
+ "windowHeightSizeClass=$windowHeightSizeClass }"
+ }
+
+ companion object {
+ /**
+ * Computes the recommended [WindowSizeClass] for the given width and height in DP.
+ * @param dpWidth width of a window in DP.
+ * @param dpHeight height of a window in DP.
+ * @return [WindowSizeClass] that is recommended for the given dimensions.
+ * @throws IllegalArgumentException if [dpWidth] or [dpHeight] is
+ * negative.
+ */
+ @JvmStatic
+ fun compute(dpWidth: Float, dpHeight: Float): WindowSizeClass {
+ return WindowSizeClass(
+ WindowWidthSizeClass.compute(dpWidth),
+ WindowHeightSizeClass.compute(dpHeight)
+ )
+ }
+
+ /**
+ * Computes the [WindowSizeClass] for the given width and height in pixels with density.
+ * @param widthPx width of a window in PX.
+ * @param heightPx height of a window in PX.
+ * @param density density of the display where the window is shown.
+ * @return [WindowSizeClass] that is recommended for the given dimensions.
+ * @throws IllegalArgumentException if [widthPx], [heightPx], or [density] is
+ * negative.
+ */
+ @JvmStatic
+ fun compute(widthPx: Int, heightPx: Int, density: Float): WindowSizeClass {
+ val widthDp = widthPx / density
+ val heightDp = heightPx / density
+ return compute(widthDp, heightDp)
+ }
+ }
+}
+
+/**
+ * A class to represent the width size buckets for a viewport. The possible values are [COMPACT],
+ * [MEDIUM], and [EXPANDED]. [WindowWidthSizeClass] should not be used as a proxy for the device
+ * type. It is possible to have resizeable windows in different device types.
+ * The viewport might change from a [COMPACT] all the way to an [EXPANDED] size class.
+ */
+class WindowWidthSizeClass private constructor(
+ private val rawValue: Int
+) {
+ override fun toString(): String {
+ val name = when (this) {
+ COMPACT -> "COMPACT"
+ MEDIUM -> "MEDIUM"
+ EXPANDED -> "EXPANDED"
+ else -> "UNKNOWN"
+ }
+ return "WindowWidthSizeClass: $name"
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other == null) return false
+ if (this::class != other::class) return false
+
+ val that = other as WindowWidthSizeClass
+
+ return rawValue == that.rawValue
+ }
+
+ override fun hashCode(): Int {
+ return rawValue
+ }
+
+ companion object {
+ /**
+ * A bucket to represent a compact width window, typical for a phone in portrait.
+ */
+ @JvmField
+ val COMPACT: WindowWidthSizeClass = WindowWidthSizeClass(0)
+
+ /**
+ * A bucket to represent a medium width window, typical for a phone in landscape or
+ * a tablet.
+ */
+ @JvmField
+ val MEDIUM: WindowWidthSizeClass = WindowWidthSizeClass(1)
+
+ /**
+ * A bucket to represent an expanded width window, typical for a large tablet or desktop
+ * form-factor.
+ */
+ @JvmField
+ val EXPANDED: WindowWidthSizeClass = WindowWidthSizeClass(2)
+
+ /**
+ * Returns a recommended [WindowWidthSizeClass] for the width of a window given the width
+ * in DP.
+ * @param dpWidth the width of the window in DP
+ * @return A recommended size class for the width
+ * @throws IllegalArgumentException if the width is negative
+ */
+ internal fun compute(dpWidth: Float): WindowWidthSizeClass {
+ require(dpWidth >= 0) { "Width must be positive, received $dpWidth" }
+ return when {
+ dpWidth < 600 -> COMPACT
+ dpWidth < 840 -> MEDIUM
+ else -> EXPANDED
+ }
+ }
+ }
+}
+
+class WindowHeightSizeClass private constructor(
+ private val rawValue: Int
+) {
+
+ override fun toString(): String {
+ val name = when (this) {
+ COMPACT -> "COMPACT"
+ MEDIUM -> "MEDIUM"
+ EXPANDED -> "EXPANDED"
+ else -> "UNKNOWN"
+ }
+ return "WindowHeightSizeClass: $name"
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other == null) return false
+ if (this::class != other::class) return false
+
+ val that = other as WindowHeightSizeClass
+
+ return rawValue == that.rawValue
+ }
+
+ override fun hashCode(): Int {
+ return rawValue
+ }
+
+ companion object {
+ /**
+ * A bucket to represent a compact height, typical for a phone that is in landscape.
+ */
+ @JvmField
+ val COMPACT: WindowHeightSizeClass = WindowHeightSizeClass(0)
+
+ /**
+ * A bucket to represent a medium height, typical for a phone in portrait or a tablet.
+ */
+ @JvmField
+ val MEDIUM: WindowHeightSizeClass = WindowHeightSizeClass(1)
+
+ /**
+ * A bucket to represent an expanded height window, typical for a large tablet or a
+ * desktop form-factor.
+ */
+ @JvmField
+ val EXPANDED: WindowHeightSizeClass = WindowHeightSizeClass(2)
+
+ /**
+ * Returns a recommended [WindowHeightSizeClass] for the height of a window given the
+ * height in DP.
+ * @param dpHeight the height of the window in DP
+ * @return A recommended size class for the height
+ * @throws IllegalArgumentException if the height is negative
+ */
+ internal fun compute(dpHeight: Float): WindowHeightSizeClass {
+ require(dpHeight >= 0) { "Height must be positive, received $dpHeight" }
+ return when {
+ dpHeight < 480 -> COMPACT
+ dpHeight < 900 -> MEDIUM
+ else -> EXPANDED
+ }
+ }
+ }
+}
diff --git a/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/Components.kt b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/Components.kt
new file mode 100644
index 0000000..bc7bab0
--- /dev/null
+++ b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/Components.kt
@@ -0,0 +1,76 @@
+package com.pr0gramm3r101.utils
+
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.NavigationRail
+import androidx.compose.material3.NavigationRailItem
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+
+data class NavigationItem(
+ val name: String,
+ val selectedIcon: ImageVector,
+ val unselectedIcon: ImageVector = selectedIcon,
+ val onClick: () -> Unit
+)
+
+@Composable
+fun SimpleNavigationBar(
+ selectedItem: Int,
+ vararg items: NavigationItem,
+ modifier: Modifier = Modifier,
+) {
+ NavigationBar(modifier) {
+ items.forEachIndexed { index, item ->
+ NavigationBarItem(
+ icon = {
+ Icon(
+ imageVector =
+ if (selectedItem == index)
+ items[index].selectedIcon
+ else
+ items[index].unselectedIcon,
+ contentDescription = item.name
+ )
+ },
+ label = { Text(item.name) },
+ selected = selectedItem == index,
+ onClick = items[index].onClick
+ )
+ }
+ }
+}
+
+@Composable
+fun SimpleNavigationRail(
+ selectedItem: Int,
+ vararg items: NavigationItem,
+ modifier: Modifier = Modifier,
+) {
+ NavigationRail(
+ modifier,
+ containerColor = MaterialTheme.colorScheme.surfaceContainer
+ ) {
+ items.forEachIndexed { index, item ->
+ NavigationRailItem(
+ icon = {
+ Icon(
+ imageVector =
+ if (selectedItem == index)
+ items[index].selectedIcon
+ else
+ items[index].unselectedIcon,
+ contentDescription = item.name
+ )
+ },
+ label = { Text(item.name) },
+ selected = selectedItem == index,
+ onClick = items[index].onClick
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/Utils.kt b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/Utils.kt
new file mode 100644
index 0000000..e1b8ec7
--- /dev/null
+++ b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/Utils.kt
@@ -0,0 +1,183 @@
+@file:Suppress("UnusedReceiverParameter", "unused", "NOTHING_TO_INLINE", "KotlinRedundantDiagnosticSuppress")
+
+package com.pr0gramm3r101.utils
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Typography
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocal
+import androidx.compose.runtime.ProvidableCompositionLocal
+import androidx.compose.runtime.compositionLocalOf
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusManager
+import androidx.compose.ui.platform.ClipboardManager
+import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.platform.SoftwareKeyboardController
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.unit.LayoutDirection
+import org.jetbrains.compose.resources.Font
+import org.jetbrains.compose.resources.FontResource
+import tech.annexflow.constraintlayout.compose.ConstrainScope
+import tech.annexflow.constraintlayout.compose.ConstrainedLayoutReference
+import tech.annexflow.constraintlayout.compose.ConstraintLayoutBaseScope
+import tech.annexflow.constraintlayout.compose.HorizontalAnchorable
+import tech.annexflow.constraintlayout.compose.VerticalAnchorable
+
+@Composable
+fun Typography(fontRes: FontResource) = Typography().let {
+ val font = FontFamily(Font(fontRes))
+ it.copy(
+ displayLarge = it.displayLarge.copy(fontFamily = font),
+ displayMedium = it.displayMedium.copy(fontFamily = font),
+ displaySmall = it.displaySmall.copy(fontFamily = font),
+ headlineLarge = it.headlineLarge.copy(fontFamily = font),
+ headlineMedium = it.headlineMedium.copy(fontFamily = font),
+ headlineSmall = it.headlineSmall.copy(fontFamily = font),
+ titleLarge = it.titleLarge.copy(fontFamily = font),
+ titleMedium = it.titleMedium.copy(fontFamily = font),
+ titleSmall = it.titleSmall.copy(fontFamily = font),
+ bodyLarge = it.bodyLarge.copy(fontFamily = font),
+ bodyMedium = it.bodyMedium.copy(fontFamily = font),
+ bodySmall = it.bodySmall.copy(fontFamily = font),
+ labelLarge = it.labelLarge.copy(fontFamily = font),
+ labelMedium = it.labelMedium.copy(fontFamily = font),
+ labelSmall = it.labelSmall.copy(fontFamily = font)
+ )
+}
+
+val ConstrainScope.left get() = absoluteLeft
+val ConstrainScope.right get() = absoluteRight
+
+val ConstrainedLayoutReference.left get() = absoluteLeft
+val ConstrainedLayoutReference.right get() = absoluteRight
+
+inline infix fun HorizontalAnchorable.link(
+ anchor: ConstraintLayoutBaseScope.HorizontalAnchor
+) = linkTo(anchor)
+
+inline infix fun VerticalAnchorable.link(
+ anchor: ConstraintLayoutBaseScope.VerticalAnchor
+) = linkTo(anchor)
+
+
+expect fun Modifier.clearFocusOnKeyboardDismiss(): Modifier
+
+operator fun Modifier.plus(other: Modifier) = then(other)
+
+var ClipboardManager.text
+ get() = getText()
+ set(value) = setText(value!!)
+
+@Composable
+expect fun ToggleNavScrimEffect(enabled: Boolean = false)
+
+interface SupportClipboardManager {
+ suspend fun setText(string: String)
+ suspend fun getText(): String?
+ suspend fun hasText(): Boolean = getText()?.isNotEmpty() == true
+
+ fun setTextListener(listener: (String) -> Unit)
+}
+
+val LocalSupportClipboardManager: ProvidableCompositionLocal = compositionLocalOf {
+ throw IllegalStateException("This CompositionLocal hasn't been provided.")
+}
+
+val supportClipboardManagerImpl
+ @Composable get() = LocalClipboardManager().toSupport()
+
+inline fun ClipboardManager.toSupport() = object : SupportClipboardManager {
+ private var listener: ((String) -> Unit)? = null
+
+ override suspend fun setText(string: String) {
+ setText(string.toAnnotatedString())
+ listener?.invoke(string)
+ }
+ override suspend fun getText() = this@toSupport.getText()?.toString() ?: ""
+
+ override fun setTextListener(listener: (String) -> Unit) {
+ this.listener = listener
+ }
+}
+
+fun String.toAnnotatedString() = AnnotatedString(this)
+
+@Composable
+inline operator fun CompositionLocal.invoke() = current
+
+val LocalSnackbar: ProvidableCompositionLocal = compositionLocalOf { throw NullPointerException() }
+
+inline fun resetFocus(
+ keyboardController: SoftwareKeyboardController?,
+ focusManager: FocusManager
+) {
+ keyboardController?.hide()
+ focusManager.clearFocus(true)
+}
+
+val unsupported: Nothing
+ get() = throw UnsupportedOperationException()
+
+val WindowWidthSizeClass.rawValue: Int get() =
+ when (this) {
+ WindowWidthSizeClass.COMPACT -> 0
+ WindowWidthSizeClass.MEDIUM -> 1
+ WindowWidthSizeClass.EXPANDED -> 2
+ else -> throw IllegalArgumentException("Unsupported WindowWidthSizeClass: $this")
+ }
+
+val WindowHeightSizeClass.rawValue: Int get() =
+ when (this) {
+ WindowHeightSizeClass.COMPACT -> 0
+ WindowHeightSizeClass.MEDIUM -> 1
+ WindowHeightSizeClass.EXPANDED -> 2
+ else -> throw IllegalArgumentException("Unsupported WindowHeightSizeClass: $this")
+ }
+
+operator fun WindowWidthSizeClass.compareTo(other: WindowWidthSizeClass) = this.rawValue.compareTo(other.rawValue)
+operator fun WindowHeightSizeClass.compareTo(other: WindowHeightSizeClass) = this.rawValue.compareTo(other.rawValue)
+
+val WindowSizeClass.width get() = windowWidthSizeClass
+val WindowSizeClass.height get() = windowHeightSizeClass
+
+val WindowAdaptiveInfo.widthSizeClass get() = windowSizeClass.width
+@Suppress("unused")
+val WindowAdaptiveInfo.heightSizeClass get() = windowSizeClass.height
+
+fun String.encodeJSON(): String {
+ val out = StringBuilder()
+ for (i in indices) {
+ when (val c: Char = get(i)) {
+ '"', '\\', '/' -> out.append('\\').append(c)
+ '\t' -> out.append("\\t")
+ '\b' -> out.append("\\b")
+ '\n' -> out.append("\\n")
+ '\r' -> out.append("\\r")
+ else -> if (c.code <= 0x1F) {
+ out.append("\\u${c.code.toString(16).padStart(4, '0')}")
+ } else {
+ out.append(c)
+ }
+ }
+ }
+ return "$out"
+}
+
+@Composable
+operator fun PaddingValues.plus(other: PaddingValues) = PaddingValues(
+ top = calculateTopPadding() + other.calculateTopPadding(),
+ bottom = calculateBottomPadding() + other.calculateTopPadding(),
+ start = when (LocalLayoutDirection()) {
+ LayoutDirection.Ltr -> calculateLeftPadding(LocalLayoutDirection())
+ LayoutDirection.Rtl -> calculateRightPadding(LocalLayoutDirection())
+ },
+ end = when (LocalLayoutDirection()) {
+ LayoutDirection.Ltr -> calculateRightPadding(LocalLayoutDirection())
+ LayoutDirection.Rtl -> calculateLeftPadding(LocalLayoutDirection())
+ }
+)
+
+expect val materialYouAvailable: Boolean
\ No newline at end of file
diff --git a/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/UtilsLibrary.kt b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/UtilsLibrary.kt
new file mode 100644
index 0000000..beda22b
--- /dev/null
+++ b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/UtilsLibrary.kt
@@ -0,0 +1,5 @@
+package com.pr0gramm3r101.utils
+
+expect object UtilsLibrary {
+ fun init(vararg args: Any)
+}
\ No newline at end of file
diff --git a/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/crypto/Base64.kt b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/crypto/Base64.kt
new file mode 100644
index 0000000..5586057
--- /dev/null
+++ b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/crypto/Base64.kt
@@ -0,0 +1,19 @@
+package com.pr0gramm3r101.utils.crypto
+
+/**
+ * Platform-specific base64 encoding/decoding using native implementations
+ */
+expect object Base64 {
+ fun encode(bytes: ByteArray): String
+ fun decode(base64: String): ByteArray
+}
+
+/**
+ * Extension function to encode ByteArray to base64 string
+ */
+fun ByteArray.toBase64(): String = Base64.encode(this)
+
+/**
+ * Extension function to decode base64 string to ByteArray
+ */
+fun String.fromBase64(): ByteArray = Base64.decode(this)
\ No newline at end of file
diff --git a/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/crypto/Hmac.kt b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/crypto/Hmac.kt
new file mode 100644
index 0000000..6fa549c
--- /dev/null
+++ b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/crypto/Hmac.kt
@@ -0,0 +1,8 @@
+package com.pr0gramm3r101.utils.crypto
+
+/**
+ * Platform-specific HMAC-SHA256 implementation
+ */
+expect object Hmac {
+ suspend fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray
+}
diff --git a/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/crypto/PasswordHash.kt b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/crypto/PasswordHash.kt
new file mode 100644
index 0000000..97bbc71
--- /dev/null
+++ b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/crypto/PasswordHash.kt
@@ -0,0 +1,59 @@
+package com.pr0gramm3r101.utils.crypto
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+/**
+ * Derives authentication secret from password using HKDF
+ * Matches web client implementation:
+ * - Salt: fromchat.user:${username} encoded as bytes
+ * - Info: "auth-secret" encoded as bytes
+ * - Output: 32 bytes, then base64 encoded
+ */
+object PasswordHash {
+ /**
+ * Derives a 32-byte key using HKDF-SHA256
+ * Implementation of RFC 5869 HKDF
+ */
+ suspend fun hkdfExtractAndExpand(
+ inputKeyMaterial: ByteArray,
+ salt: ByteArray,
+ info: ByteArray,
+ length: Int = 32
+ ): ByteArray = withContext(Dispatchers.Default) {
+ // HKDF-Extract: PRK = HMAC-Hash(salt, IKM)
+ val prk = Hmac.hmacSha256(salt, inputKeyMaterial)
+
+ // HKDF-Expand: OKM = HKDF-Expand(PRK, info, L)
+ val hashLen = 32 // SHA-256 output length
+ val n = (length + hashLen - 1) / hashLen // number of blocks
+
+ val okm = ByteArray(length)
+ var offset = 0
+
+ var t = ByteArray(0)
+ for (i in 0 until n) {
+ val tInput = t + info + byteArrayOf((i + 1).toByte())
+ t = Hmac.hmacSha256(prk, tInput)
+
+ val copyLen = minOf(t.size, length - offset)
+ t.copyInto(okm, offset, 0, copyLen)
+ offset += copyLen
+ }
+
+ okm
+ }
+}
+
+/**
+ * Derives authentication secret so the raw password never leaves the client
+ */
+suspend fun deriveAuthSecret(username: String, password: String): String {
+ val salt = "fromchat.user:$username".encodeToByteArray()
+ val info = "auth-secret".encodeToByteArray()
+ val passwordBytes = password.encodeToByteArray()
+
+ val derived = PasswordHash.hkdfExtractAndExpand(passwordBytes, salt, info, 32)
+
+ return derived.toBase64()
+}
diff --git a/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/settings/AsyncSettings.kt b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/settings/AsyncSettings.kt
new file mode 100644
index 0000000..e0d8e27
--- /dev/null
+++ b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/settings/AsyncSettings.kt
@@ -0,0 +1,37 @@
+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)
+ suspend fun getStringSet(key: String, default: Set = emptySet()): Set
+
+ suspend fun putStringList(key: String, value: List)
+ suspend fun getStringList(key: String, default: List = emptyList()): List
+
+ suspend fun remove(key: String)
+
+ suspend fun contains(key: String): Boolean
+ suspend fun clear()
+}
+
+/**
+ * Global async settings instance
+ */
+expect val asyncSettings: AsyncSettings
diff --git a/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/settings/Settings.kt b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/settings/Settings.kt
new file mode 100644
index 0000000..343c5cc
--- /dev/null
+++ b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/settings/Settings.kt
@@ -0,0 +1,33 @@
+@file:Suppress("NOTHING_TO_INLINE")
+
+package com.pr0gramm3r101.utils.settings
+
+interface Settings {
+ companion object {
+ inline fun get() = settings
+ inline operator fun invoke() = settings
+ }
+
+ fun putString(key: String, value: String)
+ fun getString(key: String, default: String = ""): String
+
+ fun putInt(key: String, value: Int)
+ fun getInt(key: String, default: Int = 0): Int
+
+ fun putLong(key: String, value: Long)
+ fun getLong(key: String, default: Long = 0L): Long
+
+ fun putFloat(key: String, value: Float)
+ fun getFloat(key: String, default: Float = 0f): Float
+
+ fun putBoolean(key: String, value: Boolean)
+ fun getBoolean(key: String, default: Boolean = false): Boolean
+
+ fun putStringSet(key: String, value: Set)
+ fun getStringSet(key: String, default: Set = emptySet()): Set
+
+ fun putStringList(key: String, value: List)
+ fun getStringList(key: String, default: List = emptyList()): List
+
+ fun remove(key: String)
+}
\ No newline at end of file
diff --git a/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/settings/SettingsFactory.kt b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/settings/SettingsFactory.kt
new file mode 100644
index 0000000..7dd5e78
--- /dev/null
+++ b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/settings/SettingsFactory.kt
@@ -0,0 +1,3 @@
+package com.pr0gramm3r101.utils.settings
+
+expect val settings: Settings
\ No newline at end of file
diff --git a/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/storage/AuthStorage.kt b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/storage/AuthStorage.kt
new file mode 100644
index 0000000..0e67139
--- /dev/null
+++ b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/storage/AuthStorage.kt
@@ -0,0 +1,29 @@
+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)
+}
diff --git a/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/storage/ServerConfigStorage.kt b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/storage/ServerConfigStorage.kt
new file mode 100644
index 0000000..bcc6fa9
--- /dev/null
+++ b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/storage/ServerConfigStorage.kt
@@ -0,0 +1,55 @@
+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 if (url.isEmpty()) null else url
+ }
+
+ 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() ?: "fromchat.ru"
+ val https = getHttpsEnabled() ?: true
+ return ServerConfigData(url, https)
+ }
+
+ suspend fun saveConfig(config: ServerConfigData) {
+ setServerUrl(config.serverUrl)
+ setHttpsEnabled(config.httpsEnabled)
+ }
+}
diff --git a/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/storage/Storage.kt b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/storage/Storage.kt
new file mode 100644
index 0000000..d729012
--- /dev/null
+++ b/utils/src/commonMain/kotlin/com/pr0gramm3r101/utils/storage/Storage.kt
@@ -0,0 +1,59 @@
+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) = settings.putStringSet(key, value)
+ suspend fun getStringSet(key: String, default: Set = emptySet()): Set = settings.getStringSet(key, default)
+
+ suspend fun putStringList(key: String, value: List) = settings.putStringList(key, value)
+ suspend fun getStringList(key: String, default: List = emptyList()): List = 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))
+}
diff --git a/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/Adaptive.ios.kt b/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/Adaptive.ios.kt
new file mode 100644
index 0000000..25196eb
--- /dev/null
+++ b/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/Adaptive.ios.kt
@@ -0,0 +1,9 @@
+package com.pr0gramm3r101.utils
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.platform.LocalWindowInfo
+
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+actual inline fun currentWindowSize() = LocalWindowInfo.current.containerSize
\ No newline at end of file
diff --git a/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/Utils.ios.kt b/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/Utils.ios.kt
new file mode 100644
index 0000000..746dbf3
--- /dev/null
+++ b/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/Utils.ios.kt
@@ -0,0 +1,25 @@
+@file:OptIn(ExperimentalNativeApi::class)
+@file:Suppress("NOTHING_TO_INLINE")
+
+package com.pr0gramm3r101.utils
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import platform.posix._OSSwapInt16
+import platform.posix._OSSwapInt32
+import platform.posix._OSSwapInt64
+import kotlin.experimental.ExperimentalNativeApi
+import kotlin.native.Platform.isLittleEndian
+
+actual fun Modifier.clearFocusOnKeyboardDismiss() = this
+
+@Composable
+actual fun ToggleNavScrimEffect(enabled: Boolean) {}
+actual val materialYouAvailable get() = false
+
+inline fun htons(short: UShort) = if (isLittleEndian) _OSSwapInt16(short) else short
+inline fun htonl(data: UInt) = if (isLittleEndian) _OSSwapInt32(data) else data
+inline fun htonll(data: ULong) = if (isLittleEndian) _OSSwapInt64(data) else data
+inline fun ntohs(data: UShort) = if (isLittleEndian) _OSSwapInt16(data) else data
+inline fun ntohl(data: UInt) = if (isLittleEndian) _OSSwapInt32(data) else data
+inline fun ntohll(data: ULong) = if (isLittleEndian) _OSSwapInt64(data) else data
\ No newline at end of file
diff --git a/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/UtilsLibrary.kt b/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/UtilsLibrary.kt
new file mode 100644
index 0000000..9005bfd
--- /dev/null
+++ b/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/UtilsLibrary.kt
@@ -0,0 +1,5 @@
+package com.pr0gramm3r101.utils
+
+actual object UtilsLibrary {
+ actual fun init(vararg args: Any) {}
+}
\ No newline at end of file
diff --git a/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/crypto/Base64.ios.kt b/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/crypto/Base64.ios.kt
new file mode 100644
index 0000000..4ed01bd
--- /dev/null
+++ b/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/crypto/Base64.ios.kt
@@ -0,0 +1,23 @@
+package com.pr0gramm3r101.utils.crypto
+
+import kotlinx.cinterop.ByteVar
+import kotlinx.cinterop.usePinned
+import platform.Foundation.NSData
+
+actual object Base64 {
+ actual fun encode(bytes: ByteArray): String {
+ return bytes.usePinned { pinned ->
+ val nsData = NSData.create(bytes = pinned.addressOf(0), length = bytes.size.toULong())
+ nsData.base64EncodedStringWithOptions(0u)
+ }
+ }
+
+ actual fun decode(base64: String): ByteArray {
+ val nsData = NSData.create(base64EncodedString = base64, options = 0u) ?: return ByteArray(0)
+ val length = nsData.length.toInt()
+ val bytes = nsData.bytes ?: return ByteArray(0)
+ return ByteArray(length) { index ->
+ bytes.reinterpret()[index]
+ }
+ }
+}
diff --git a/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/crypto/Hmac.ios.kt b/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/crypto/Hmac.ios.kt
new file mode 100644
index 0000000..0b9d178
--- /dev/null
+++ b/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/crypto/Hmac.ios.kt
@@ -0,0 +1,29 @@
+package com.pr0gramm3r101.utils.crypto
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import platform.CommonCrypto.CCHmac
+import platform.CommonCrypto.kCCHmacAlgSHA256
+
+actual object Hmac {
+ actual suspend fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray = withContext(Dispatchers.Default) {
+ val result = ByteArray(32)
+
+ key.usePinned { keyPinned ->
+ data.usePinned { dataPinned ->
+ result.usePinned { resultPinned ->
+ CCHmac(
+ algorithm = kCCHmacAlgSHA256,
+ key = keyPinned.addressOf(0),
+ keyLength = key.size.toULong(),
+ data = dataPinned.addressOf(0),
+ dataLength = data.size.toULong(),
+ macOut = resultPinned.addressOf(0)
+ )
+ }
+ }
+ }
+
+ result
+ }
+}
diff --git a/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/settings/AsyncSettings.ios.kt b/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/settings/AsyncSettings.ios.kt
new file mode 100644
index 0000000..c1fd475
--- /dev/null
+++ b/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/settings/AsyncSettings.ios.kt
@@ -0,0 +1,99 @@
+package com.pr0gramm3r101.utils.settings
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import platform.Foundation.NSUserDefaults
+
+class IosAsyncSettings : AsyncSettings {
+ private val defaults: NSUserDefaults = NSUserDefaults.standardUserDefaults
+
+ override suspend fun putString(key: String, value: String) = 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) = withContext(Dispatchers.Default) {
+ defaults.setInteger(value.toLong(), key)
+ defaults.synchronize()
+ }
+
+ override suspend fun getInt(key: String, default: Int): Int = withContext(Dispatchers.Default) {
+ val value = defaults.integerForKey(key)
+ if (value != null) value.toInt() else default
+ }
+
+ override suspend fun putLong(key: String, value: Long) = 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) = withContext(Dispatchers.Default) {
+ defaults.setFloat(value, key)
+ defaults.synchronize()
+ }
+
+ override suspend fun getFloat(key: String, default: Float): Float = withContext(Dispatchers.Default) {
+ val value = defaults.floatForKey(key)
+ if (value != null) value.toFloat() else default
+ }
+
+ override suspend fun putBoolean(key: String, value: Boolean) = 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) = withContext(Dispatchers.Default) {
+ defaults.setObject(value.toList(), key)
+ defaults.synchronize()
+ }
+
+ override suspend fun getStringSet(key: String, default: Set): Set = withContext(Dispatchers.Default) {
+ val list = defaults.objectForKey(key) as? List<*> ?: return@withContext default
+ list.filterIsInstance().toSet()
+ }
+
+ override suspend fun putStringList(key: String, value: List) = withContext(Dispatchers.Default) {
+ defaults.setObject(value, key)
+ defaults.synchronize()
+ }
+
+ override suspend fun getStringList(key: String, default: List): List = withContext(Dispatchers.Default) {
+ val list = defaults.objectForKey(key) as? List<*> ?: return@withContext default
+ list.filterIsInstance()
+ }
+
+ override suspend fun remove(key: String) = 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() = withContext(Dispatchers.Default) {
+ val domain = defaults.persistentDomainForName(defaults.bundleIdentifier() ?: "")
+ domain?.keys?.forEach { key ->
+ defaults.removeObjectForKey(key as String)
+ }
+ defaults.synchronize()
+ }
+}
+
+actual val asyncSettings: AsyncSettings = IosAsyncSettings()
diff --git a/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/settings/IosSettings.kt b/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/settings/IosSettings.kt
new file mode 100644
index 0000000..4d82e55
--- /dev/null
+++ b/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/settings/IosSettings.kt
@@ -0,0 +1,70 @@
+package com.pr0gramm3r101.utils.settings
+
+import platform.Foundation.NSUserDefaults
+
+// TODO fix
+class IosSettings : Settings {
+ private val defaults: NSUserDefaults = NSUserDefaults.standardUserDefaults
+
+ override fun putString(key: String, value: String) {
+ defaults.setObject(value, key)
+ }
+
+ override fun getString(key: String, default: String): String {
+ return defaults.stringForKey(key) ?: default
+ }
+
+ override fun putInt(key: String, value: Int) {
+ defaults.setInteger(value.toLong(), key)
+ }
+
+ override fun getInt(key: String, default: Int): Int {
+ return defaults.integerForKey(key).toInt()
+ }
+
+ override fun putLong(key: String, value: Long) {
+ defaults.setObject(value, key)
+ }
+
+ override fun getLong(key: String, default: Long): Long {
+ return defaults.objectForKey(key) as? Long ?: default
+ }
+
+ override fun putFloat(key: String, value: Float) {
+ defaults.setFloat(value, key)
+ }
+
+ override fun getFloat(key: String, default: Float): Float {
+ return defaults.floatForKey(key)
+ }
+
+ override fun putBoolean(key: String, value: Boolean) {
+ defaults.setBool(value, key)
+ }
+
+ override fun getBoolean(key: String, default: Boolean): Boolean {
+ return defaults.boolForKey(key)
+ }
+
+ override fun putStringSet(key: String, value: Set) {
+ defaults.setObject(value.toList(), key)
+ }
+
+ override fun getStringSet(key: String, default: Set): Set {
+ val list = defaults.objectForKey(key) as? List<*> ?: return default
+ return list.filterIsInstance().toSet()
+ }
+
+ override fun putStringList(key: String, value: List) {
+ defaults.setObject(value, key)
+ }
+
+ override fun getStringList(key: String, default: List): List {
+ val list = defaults.objectForKey(key) as? List<*> ?: return default
+ return list.filterIsInstance()
+ }
+
+ override fun remove(key: String) {
+ defaults.removeObjectForKey(key)
+ }
+}
\ No newline at end of file
diff --git a/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/settings/SettingsFactory.ios.kt b/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/settings/SettingsFactory.ios.kt
new file mode 100644
index 0000000..0d678c9
--- /dev/null
+++ b/utils/src/iosMain/kotlin/com/pr0gramm3r101/utils/settings/SettingsFactory.ios.kt
@@ -0,0 +1,3 @@
+package com.pr0gramm3r101.utils.settings
+
+actual val settings: Settings get() = IosSettings()
\ No newline at end of file