Initial commit

This commit is contained in:
tanexc
2026-06-12 23:49:11 +03:00
commit f69ffd79c2
222 changed files with 7094 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
*.iml
.kotlin
.gradle
**/build/
xcuserdata
!src/**/build/
local.properties
.idea
.DS_Store
captures
.externalNativeBuild
.cxx
*.xcodeproj/*
!*.xcodeproj/project.pbxproj
!*.xcodeproj/xcshareddata/
!*.xcodeproj/project.xcworkspace/
!*.xcworkspace/contents.xcworkspacedata
**/xcshareddata/WorkspaceSettings.xcsettings
node_modules/
Pods/
**/Pods/
+1
View File
@@ -0,0 +1 @@
/build
+62
View File
@@ -0,0 +1,62 @@
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.composeCompiler)
}
android {
namespace = "ru.tanexc.hemoanalysis"
compileSdk {
version = release(36)
}
defaultConfig {
applicationId = "ru.tanexc.hemoanalysis"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "1.0"
}
buildTypes {
debug {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
splits {
abi {
isEnable = true
reset()
include("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
isUniversalApk = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
implementation(projects.shared)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.koin.core)
implementation(libs.koin.android)
implementation(libs.filekit.core)
implementation(libs.filekit.dialogs.compose)
}
+26
View File
@@ -0,0 +1,26 @@
-keepattributes RuntimeVisibleAnnotations,RuntimeInvisibleAnnotations,Signature,InnerClasses,EnclosingMethod
-keep class **$$serializer { *; }
-keepclassmembers class ** {
public static ** serializer(...);
}
-keepclassmembers class **$Companion {
public final kotlinx.serialization.KSerializer serializer(...);
}
-keep @kotlinx.serialization.Serializable class ru.tanexc.hemoanalysis.** { *; }
-keep class io.ktor.** { *; }
-keep class kotlinx.coroutines.** { *; }
-keep class kotlinx.serialization.** { *; }
-keepnames class io.ktor.** { *; }
-keep class ai.onnxruntime.** { *; }
-dontwarn okhttp3.**
-dontwarn okio.**
-dontwarn javax.annotation.**
-dontwarn org.conscrypt.**
-dontwarn org.bouncycastle.**
-dontwarn org.openjsse.**
-dontwarn java.lang.management.**
Binary file not shown.
Binary file not shown.
Binary file not shown.
+87
View File
@@ -0,0 +1,87 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "ru.tanexc.hemoanalysis",
"variantName": "release",
"elements": [
{
"type": "ONE_OF_MANY",
"filters": [
{
"filterType": "ABI",
"value": "armeabi-v7a"
}
],
"attributes": [],
"versionCode": 1,
"versionName": "1.0",
"outputFile": "androidApp-armeabi-v7a-release.apk"
},
{
"type": "ONE_OF_MANY",
"filters": [
{
"filterType": "ABI",
"value": "x86_64"
}
],
"attributes": [],
"versionCode": 1,
"versionName": "1.0",
"outputFile": "androidApp-x86_64-release.apk"
},
{
"type": "ONE_OF_MANY",
"filters": [
{
"filterType": "ABI",
"value": "arm64-v8a"
}
],
"attributes": [],
"versionCode": 1,
"versionName": "1.0",
"outputFile": "androidApp-arm64-v8a-release.apk"
},
{
"type": "ONE_OF_MANY",
"filters": [
{
"filterType": "ABI",
"value": "x86"
}
],
"attributes": [],
"versionCode": 1,
"versionName": "1.0",
"outputFile": "androidApp-x86-release.apk"
}
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/androidApp-armeabi-v7a-release.dm",
"baselineProfiles/1/androidApp-x86_64-release.dm",
"baselineProfiles/1/androidApp-arm64-v8a-release.dm",
"baselineProfiles/1/androidApp-x86-release.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/androidApp-armeabi-v7a-release.dm",
"baselineProfiles/0/androidApp-x86_64-release.dm",
"baselineProfiles/0/androidApp-arm64-v8a-release.dm",
"baselineProfiles/0/androidApp-x86-release.dm"
]
}
],
"minSdkVersionForDexing": 26
}
+35
View File
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission
android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"
android:minSdkVersion="34" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<application
android:name=".HemoAnalysisApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_policy"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.HemoAnalysis">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.HemoAnalysis">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,14 @@
package ru.tanexc.hemoanalysis
import android.app.Application
import ru.tanexc.hemoanalysis.di.koinInit
import org.koin.android.ext.koin.androidContext
class HemoAnalysisApplication: Application() {
override fun onCreate() {
super.onCreate()
koinInit {
androidContext(this@HemoAnalysisApplication)
}
}
}
@@ -0,0 +1,21 @@
package ru.tanexc.hemoanalysis
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import io.github.vinceglb.filekit.FileKit
import io.github.vinceglb.filekit.dialogs.init
import ru.tanexc.hemoanalysis.navigation.provideRootComponent
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val root = provideRootComponent()
FileKit.init(this)
setContent {
App(root)
}
}
}
@@ -0,0 +1,17 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="80"
android:viewportHeight="80">
<group android:scaleX="0.5"
android:scaleY="0.5"
android:translateX="20"
android:translateY="20">
<path
android:pathData="M37.691,23.516C42.2,23.22 46.21,24.387 49.722,27.017C53.235,29.648 55.483,33.168 56.467,37.578C57.451,41.988 56.465,46.167 53.508,50.116C50.55,54.065 46.817,56.188 42.308,56.484C37.8,56.78 33.789,55.613 30.277,52.983C26.765,50.352 24.517,46.832 23.532,42.422C22.548,38.012 23.534,33.833 26.492,29.884C29.449,25.935 33.182,23.812 37.691,23.516ZM28.951,41.098C29.834,43.954 31.492,46.293 33.923,48.114C36.354,49.935 39.065,50.867 42.054,50.911C45.044,50.954 47.511,49.678 49.455,47.081C51.4,44.485 51.931,41.758 51.048,38.902C50.165,36.046 48.508,33.707 46.076,31.886C43.645,30.065 40.934,29.133 37.945,29.089C34.956,29.046 32.488,30.322 30.544,32.919C28.599,35.515 28.069,38.242 28.951,41.098ZM33.314,38.792C33.309,39.549 33.59,40.16 34.155,40.625C34.72,41.091 35.367,41.259 36.096,41.13C36.569,41.062 37.045,41.059 37.525,41.123C38.004,41.186 38.424,41.353 38.784,41.623C39.145,41.893 39.423,42.249 39.618,42.691C39.814,43.133 39.946,43.591 40.014,44.063C40.095,44.8 40.417,45.379 40.98,45.8C41.543,46.222 42.209,46.341 42.979,46.157C43.704,45.94 44.309,45.507 44.795,44.858C45.281,44.209 45.504,43.489 45.464,42.699C45.4,41.469 45.104,40.34 44.575,39.31C44.046,38.281 43.331,37.429 42.43,36.754C41.53,36.08 40.521,35.62 39.405,35.375C38.288,35.13 37.131,35.15 35.933,35.435C35.164,35.618 34.536,36.035 34.049,36.684C33.563,37.333 33.318,38.036 33.314,38.792Z"
android:fillColor="#BCF0B4"/>
<path
android:pathData="M17.012,69.981C15.179,69.981 13.61,69.329 12.304,68.023C10.998,66.718 10.346,65.148 10.346,63.315V53.315C10.346,52.37 10.665,51.579 11.304,50.94C11.943,50.301 12.735,49.981 13.679,49.981C14.623,49.981 15.415,50.301 16.054,50.94C16.693,51.579 17.012,52.37 17.012,53.315V63.315H27.012C27.957,63.315 28.749,63.634 29.387,64.273C30.026,64.912 30.346,65.704 30.346,66.648C30.346,67.593 30.026,68.384 29.387,69.023C28.749,69.662 27.957,69.981 27.012,69.981H17.012ZM63.679,69.981H53.679C52.735,69.981 51.943,69.662 51.304,69.023C50.665,68.384 50.346,67.593 50.346,66.648C50.346,65.704 50.665,64.912 51.304,64.273C51.943,63.634 52.735,63.315 53.679,63.315H63.679V53.315C63.679,52.37 63.999,51.579 64.637,50.94C65.276,50.301 66.068,49.981 67.012,49.981C67.957,49.981 68.748,50.301 69.387,50.94C70.026,51.579 70.346,52.37 70.346,53.315V63.315C70.346,65.148 69.693,66.718 68.387,68.023C67.082,69.329 65.512,69.981 63.679,69.981ZM10.346,26.648V16.648C10.346,14.815 10.998,13.245 12.304,11.94C13.61,10.634 15.179,9.981 17.012,9.981H27.012C27.957,9.981 28.749,10.301 29.387,10.94C30.026,11.579 30.346,12.37 30.346,13.315C30.346,14.259 30.026,15.051 29.387,15.69C28.749,16.329 27.957,16.648 27.012,16.648H17.012V26.648C17.012,27.593 16.693,28.384 16.054,29.023C15.415,29.662 14.623,29.981 13.679,29.981C12.735,29.981 11.943,29.662 11.304,29.023C10.665,28.384 10.346,27.593 10.346,26.648ZM63.679,26.648V16.648H53.679C52.735,16.648 51.943,16.329 51.304,15.69C50.665,15.051 50.346,14.259 50.346,13.315C50.346,12.37 50.665,11.579 51.304,10.94C51.943,10.301 52.735,9.981 53.679,9.981H63.679C65.512,9.981 67.082,10.634 68.387,11.94C69.693,13.245 70.346,14.815 70.346,16.648V26.648C70.346,27.593 70.026,28.384 69.387,29.023C68.748,29.662 67.957,29.981 67.012,29.981C66.068,29.981 65.276,29.662 64.637,29.023C63.999,28.384 63.679,27.593 63.679,26.648Z"
android:fillColor="#BCF0B4"/>
</group>
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 886 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#235024</color>
</resources>
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">HemoAnalysis</string>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.HemoAnalysis" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">111.88.154.194</domain>
</domain-config>
</network-security-config>
+10
View File
@@ -0,0 +1,10 @@
plugins {
alias(libs.plugins.androidMultiplatformLibrary) apply false
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.kotlinAndroid) apply false
alias(libs.plugins.androidLint) apply false
}
+1
View File
@@ -0,0 +1 @@
/build
+63
View File
@@ -0,0 +1,63 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidMultiplatformLibrary)
alias(libs.plugins.kotlinxSerialization)
}
kotlin {
android {
namespace = "ru.tanexc.hemoanalysis"
compileSdk {
version = release(36)
}
minSdk = 26
}
listOf(
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "datakit"
isStatic = true
}
}
sourceSets {
commonMain {
dependencies {
api(projects.domain)
api(projects.core.database)
api(projects.core.network)
// settings store
implementation(libs.multiplatform.settings)
implementation(libs.multiplatform.settings.serialization)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)
// koin
implementation(libs.koin.core)
implementation(libs.kotlinx.datetime)
}
}
androidMain {
dependencies {
// datastore
implementation(libs.androidx.datastore)
implementation(libs.androidx.datastore.preferences)
}
}
iosMain {
dependencies {
// datastore
implementation(libs.androidx.datastore)
implementation(libs.androidx.datastore.preferences)
}
}
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,33 @@
package ru.tanexc.hemoanalysis.usecase
import android.content.Context
import android.util.Log
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import ru.tanexc.hemoanalysis.Constants.INTERNAL_MODELS_DIR
import ru.tanexc.hemoanalysis.domain.repository.ModelRepository
import ru.tanexc.hemoanalysis.domain.usecase.GetLatestModelFileUseCase
import java.io.File
internal actual class GetLatestModelFileUseCaseImpl : GetLatestModelFileUseCase, KoinComponent {
private val context: Context by inject()
private val modelRepository: ModelRepository by inject()
actual override operator fun invoke(): ByteArray? {
val filename = modelRepository.getCurrentModelInfo()?.filename?: return null
val modelsDir = File(context.filesDir, INTERNAL_MODELS_DIR)
if (!modelsDir.exists()) return null
val modelFile = File(modelsDir, filename)
if (!modelFile.exists()) return null
Log.i("GetLatestModelFileUseCase", "get model bytearray ${modelFile.absolutePath}")
return modelFile.inputStream().buffered().use { inputStream ->
inputStream.readBytes()
}
}
}
@@ -0,0 +1,15 @@
package ru.tanexc.hemoanalysis.usecase
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import ru.tanexc.hemoanalysis.domain.repository.ModelRepository
import ru.tanexc.hemoanalysis.domain.usecase.GetModelPathUseCase
import kotlin.getValue
actual class GetModelPathUseCaseImpl actual constructor() : GetModelPathUseCase, KoinComponent {
private val modelRepository: ModelRepository by inject()
actual override fun invoke(): String? {
return modelRepository.getCurrentModelInfo()?.filename
}
}
@@ -0,0 +1,42 @@
package ru.tanexc.hemoanalysis.usecase
import android.content.Context
import android.util.Log
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import ru.tanexc.hemoanalysis.Constants.INTERNAL_MODELS_DIR
import ru.tanexc.hemoanalysis.domain.model.Size
import ru.tanexc.hemoanalysis.domain.usecase.BytesReader
import ru.tanexc.hemoanalysis.domain.usecase.SaveModelFileUseCase
import java.io.File
internal actual class SaveModelFileUseCaseImpl : SaveModelFileUseCase, KoinComponent {
val context: Context by inject()
actual override suspend operator fun invoke(
filename: String,
bytesReader: BytesReader
): Size {
val modelsDir = File(context.filesDir, INTERNAL_MODELS_DIR)
modelsDir.mkdirs()
Log.i("SaveModelFileUseCase", "saving model into ${modelsDir.absolutePath}")
val modelFile = File(modelsDir, filename)
modelFile.outputStream().buffered().use { output ->
var bytes = bytesReader.readNext(BUFFER_SIZE)
while (bytes.isNotEmpty()) {
output.write(bytes)
bytes = bytesReader.readNext(BUFFER_SIZE)
}
}
Log.i("SaveModelFileUseCase", "model saved into file $modelFile ${modelFile.length()}")
return Size(modelFile.length().toInt())
}
private val BUFFER_SIZE = 1024 * 1024
}
@@ -0,0 +1,6 @@
package ru.tanexc.hemoanalysis
internal object Constants {
const val INTERNAL_MODELS_DIR = "models"
const val DEFAULT_MODEL_NAME = "model.ort"
}
@@ -0,0 +1,41 @@
package ru.tanexc.hemoanalysis.di
import org.koin.core.context.loadKoinModules
import org.koin.core.module.dsl.singleOf
import org.koin.core.qualifier.named
import org.koin.dsl.bind
import org.koin.dsl.module
import ru.tanexc.hemoanalysis.repository.AnalysisParamsRepositoryImpl
import ru.tanexc.hemoanalysis.repository.ModelRepositoryImpl
import ru.tanexc.hemoanalysis.core.database.databaseModule
import ru.tanexc.hemoanalysis.core.network.di.networkModule
import ru.tanexc.hemoanalysis.domain.repository.AnalysisParamsRepository
import ru.tanexc.hemoanalysis.domain.repository.ModelRepository
import ru.tanexc.hemoanalysis.domain.usecase.GetLatestModelFileUseCase
import ru.tanexc.hemoanalysis.domain.usecase.GetModelPathUseCase
import ru.tanexc.hemoanalysis.domain.usecase.SaveModelFileUseCase
import ru.tanexc.hemoanalysis.usecase.GetLatestModelFileUseCaseImpl
import ru.tanexc.hemoanalysis.usecase.GetModelPathUseCaseImpl
import ru.tanexc.hemoanalysis.usecase.SaveModelFileUseCaseImpl
val dataModule = module {
loadKoinModules(listOf(databaseModule, networkModule))
singleOf(::GetModelPathUseCaseImpl) bind GetModelPathUseCase::class
singleOf(::SaveModelFileUseCaseImpl) bind SaveModelFileUseCase::class
singleOf(::GetLatestModelFileUseCaseImpl) bind GetLatestModelFileUseCase::class
single<AnalysisParamsRepository> {
AnalysisParamsRepositoryImpl(
settings = get(named("AnalysisParamsSettings"))
)
}
single<ModelRepository> {
ModelRepositoryImpl(
settings = get(named("ModelInfoSettings")),
api = get(),
saveModelFileUseCase = get()
)
}
}
@@ -0,0 +1,15 @@
package ru.tanexc.hemoanalysis.repository
import com.russhwolf.settings.Settings
import com.russhwolf.settings.float
import com.russhwolf.settings.int
import ru.tanexc.hemoanalysis.domain.repository.AnalysisParamsRepository
internal class AnalysisParamsRepositoryImpl(
settings: Settings
): AnalysisParamsRepository {
override var confThreshold by settings.float("conf_threshold", 0.5f)
override var iouThreshold by settings.float("iou_threshold", 0.5f)
override var preprocessVariant by settings.int("preprocess_variant", 0)
override var roiVariant by settings.int("roi_variant", 0)
}
@@ -0,0 +1,183 @@
package ru.tanexc.hemoanalysis.repository
import com.russhwolf.settings.Settings
import com.russhwolf.settings.string
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.format
import kotlinx.datetime.format.DateTimeFormat
import kotlinx.datetime.format.char
import kotlinx.datetime.toLocalDateTime
import kotlinx.serialization.json.Json
import org.koin.core.logger.Logger
import ru.tanexc.hemoanalysis.core.network.ModelApi
import ru.tanexc.hemoanalysis.core.network.RequestState
import ru.tanexc.hemoanalysis.domain.State
import ru.tanexc.hemoanalysis.domain.model.ModelInfo
import ru.tanexc.hemoanalysis.domain.model.Size
import ru.tanexc.hemoanalysis.domain.repository.ModelRepository
import ru.tanexc.hemoanalysis.domain.usecase.SaveModelFileUseCase
import kotlin.time.Clock
import kotlin.time.Instant
internal class ModelRepositoryImpl(
settings: Settings,
private val api: ModelApi,
private val saveModelFileUseCase: SaveModelFileUseCase,
) : ModelRepository {
private var currentModelInfo: String by settings.string("current_model_info", "")
private var latestModelInfo: String by settings.string("latest_model_info", "")
private var apiKey: String by settings.string("api_key", "")
private val json = Json {
ignoreUnknownKeys = true
}
override fun getLatestModelInfo(): ModelInfo? = runCatching {
json.decodeFromString<ModelInfo>(latestModelInfo)
}.getOrNull()
override fun getCurrentModelInfo(): ModelInfo? = runCatching {
json.decodeFromString<ModelInfo>(currentModelInfo)
}.getOrNull()
override fun updateModelInfo(): Flow<State<ModelInfo?>> = flow {
emit(State.Loading)
when (val state = api.getLatestModelInfo(apiKey)) {
is RequestState.Error -> emit(State.Error)
is RequestState.Success -> {
val timeNow = nowFormatted()
val meta = state.data
val currentInfo = getCurrentModelInfo()?.copy(
lastUpdateTime = timeNow
)
currentModelInfo = currentInfo.asString()
if (meta.version.isBiggerVersion(currentInfo?.version ?: "")) {
val modelInfo = ModelInfo(
version = meta.version,
lastUpdateTime = timeNow,
createdAt = meta.createdAt.toDisplayDateTime(),
filename = meta.filename,
isLatest = true,
size = Size.ZERO
)
currentModelInfo = currentInfo?.copy(
isLatest = false
).asString()
latestModelInfo = modelInfo.asString()
emit(State.Success(modelInfo))
} else {
emit(State.Success(currentInfo))
}
}
}
}
override fun loadLatestModel(): Flow<State<ModelInfo?>> = flow {
emit(State.Loading)
val latestInfo = getLatestModelInfo()
if (latestInfo == null) {
emit(State.Error)
return@flow
}
val state = api.downloadLatestModel(
version = latestInfo.version,
filename = latestInfo.filename,
apiKey = apiKey
)
when (state) {
is RequestState.Error -> emit(State.Error)
is RequestState.Success -> {
val savedSize = saveModelFileUseCase(latestInfo.filename, state.data.bytesReader)
val currentInfo = latestInfo.copy(
lastUpdateTime = nowFormatted(),
isLatest = true,
size = savedSize
)
currentModelInfo = currentInfo.asString()
emit(State.Success(currentInfo))
}
}
}
override fun getKeyApi(): String = apiKey
override fun updateApiKey(apiKey: String) {
this.apiKey = apiKey
}
private fun String.toVersionParts(): List<Int> {
return split(".").map { part ->
part.toIntOrNull() ?: 0
}
}
private fun String.isBiggerVersion(
second: String
): Boolean {
val firstParts = this.toVersionParts()
val secondParts = second.toVersionParts()
val maxSize = maxOf(firstParts.size, secondParts.size)
for (index in 0 until maxSize) {
val firstValue = firstParts.getOrElse(index) { 0 }
val secondValue = secondParts.getOrElse(index) { 0 }
if (firstValue > secondValue) {
return true
}
}
return false
}
private fun ModelInfo?.asString() = this?.let(json::encodeToString) ?: ""
private fun nowFormatted(): String {
return Clock.System.now().toDisplayDateTime()
}
private fun String.toDisplayDateTime(): String {
if (runCatching { displayDateTimeFormat.parse(this) }.isSuccess) return this
return runCatching {
Instant.parse(this).toDisplayDateTime()
}.getOrElse {
runCatching {
LocalDateTime.parse(this).toDisplayDateTime()
}.getOrDefault(this)
}
}
private fun Instant.toDisplayDateTime(): String {
return toLocalDateTime(TimeZone.currentSystemDefault()).toDisplayDateTime()
}
private fun LocalDateTime.toDisplayDateTime(): String {
return format(displayDateTimeFormat)
}
companion object {
private val displayDateTimeFormat: DateTimeFormat<LocalDateTime> = LocalDateTime.Format {
day()
char('.')
monthNumber()
char('.')
year()
char(' ')
hour()
char(':')
minute()
}
}
}
@@ -0,0 +1,7 @@
package ru.tanexc.hemoanalysis.usecase
import ru.tanexc.hemoanalysis.domain.usecase.GetLatestModelFileUseCase
internal expect class GetLatestModelFileUseCaseImpl(): GetLatestModelFileUseCase {
override operator fun invoke(): ByteArray?
}
@@ -0,0 +1,7 @@
package ru.tanexc.hemoanalysis.usecase
import ru.tanexc.hemoanalysis.domain.usecase.GetModelPathUseCase
expect class GetModelPathUseCaseImpl() : GetModelPathUseCase {
override fun invoke(): String?
}
@@ -0,0 +1,9 @@
package ru.tanexc.hemoanalysis.usecase
import ru.tanexc.hemoanalysis.domain.model.Size
import ru.tanexc.hemoanalysis.domain.usecase.BytesReader
import ru.tanexc.hemoanalysis.domain.usecase.SaveModelFileUseCase
internal expect class SaveModelFileUseCaseImpl(): SaveModelFileUseCase {
override suspend fun invoke(filename: String, bytesReader: BytesReader): Size
}
@@ -0,0 +1,44 @@
package ru.tanexc.hemoanalysis.usecase
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.readBytes
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import platform.Foundation.NSApplicationSupportDirectory
import platform.Foundation.NSData
import platform.Foundation.NSFileManager
import platform.Foundation.NSSearchPathForDirectoriesInDomains
import platform.Foundation.NSUserDomainMask
import platform.Foundation.dataWithContentsOfFile
import ru.tanexc.hemoanalysis.Constants.INTERNAL_MODELS_DIR
import ru.tanexc.hemoanalysis.domain.repository.ModelRepository
import ru.tanexc.hemoanalysis.domain.usecase.GetLatestModelFileUseCase
import kotlin.getValue
internal actual class GetLatestModelFileUseCaseImpl : GetLatestModelFileUseCase, KoinComponent {
private val modelRepository: ModelRepository by inject()
@OptIn(ExperimentalForeignApi::class)
actual override operator fun invoke(): ByteArray? {
val filename = modelRepository.getCurrentModelInfo()?.filename?: return null
val appSupportDir = getApplicationSupportDir()
val modelFilePath = "$appSupportDir/$INTERNAL_MODELS_DIR/$filename"
if (!NSFileManager.defaultManager.fileExistsAtPath(modelFilePath)) return null
val data = NSData.dataWithContentsOfFile(modelFilePath) ?: return null
return data.bytes?.readBytes(data.length.toInt())
}
private fun getApplicationSupportDir(): String {
val paths = NSSearchPathForDirectoriesInDomains(
directory = NSApplicationSupportDirectory,
domainMask = NSUserDomainMask,
expandTilde = true
)
return paths.first() as? String ?: error("Application Support directory not found")
}
}
@@ -0,0 +1,32 @@
package ru.tanexc.hemoanalysis.usecase
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import platform.Foundation.NSApplicationSupportDirectory
import platform.Foundation.NSFileManager
import platform.Foundation.NSSearchPathForDirectoriesInDomains
import platform.Foundation.NSUserDomainMask
import ru.tanexc.hemoanalysis.Constants.INTERNAL_MODELS_DIR
import ru.tanexc.hemoanalysis.domain.repository.ModelRepository
import ru.tanexc.hemoanalysis.domain.usecase.GetModelPathUseCase
import kotlin.getValue
actual class GetModelPathUseCaseImpl actual constructor() : GetModelPathUseCase, KoinComponent {
private val modelRepository: ModelRepository by inject()
actual override fun invoke(): String? {
val filename = modelRepository.getCurrentModelInfo()?.filename ?: return null
val modelPath = "${getApplicationSupportDir()}/$INTERNAL_MODELS_DIR/$filename"
return modelPath.takeIf { NSFileManager.defaultManager.fileExistsAtPath(it) }
}
private fun getApplicationSupportDir(): String {
val paths = NSSearchPathForDirectoriesInDomains(
directory = NSApplicationSupportDirectory,
domainMask = NSUserDomainMask,
expandTilde = true
)
return paths.first() as? String ?: error("Application Support directory not found")
}
}
@@ -0,0 +1,89 @@
package ru.tanexc.hemoanalysis.usecase
import kotlinx.cinterop.BetaInteropApi
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.allocArrayOf
import kotlinx.cinterop.memScoped
import platform.Foundation.NSApplicationSupportDirectory
import platform.Foundation.NSData
import platform.Foundation.NSFileHandle
import platform.Foundation.NSFileManager
import platform.Foundation.NSSearchPathForDirectoriesInDomains
import platform.Foundation.NSUserDomainMask
import platform.Foundation.closeFile
import platform.Foundation.create
import platform.Foundation.fileHandleForWritingAtPath
import platform.Foundation.seekToEndOfFile
import platform.Foundation.writeData
import ru.tanexc.hemoanalysis.Constants.INTERNAL_MODELS_DIR
import ru.tanexc.hemoanalysis.domain.model.Size
import ru.tanexc.hemoanalysis.domain.usecase.BytesReader
import ru.tanexc.hemoanalysis.domain.usecase.SaveModelFileUseCase
internal actual class SaveModelFileUseCaseImpl actual constructor() :
SaveModelFileUseCase {
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
actual override suspend operator fun invoke(
filename: String,
bytesReader: BytesReader
): Size {
val appSupportDir = getApplicationSupportDir()
val modelDir = "$appSupportDir/$INTERNAL_MODELS_DIR"
NSFileManager.defaultManager.createDirectoryAtPath(
path = modelDir,
withIntermediateDirectories = true,
attributes = null,
error = null
)
val filePath = "$modelDir/$filename"
if (NSFileManager.defaultManager.fileExistsAtPath(filePath)) {
NSFileManager.defaultManager.removeItemAtPath(filePath, error = null)
}
check(
NSFileManager.defaultManager.createFileAtPath(
path = filePath,
contents = null,
attributes = null
)
) { "Cannot create file at $filePath" }
val handle = NSFileHandle.fileHandleForWritingAtPath(filePath)
?: error("Cannot open file for writing at $filePath")
var totalBytes = 0
try {
handle.seekToEndOfFile()
var bytes = bytesReader.readNext(BUFFER_SIZE)
while (bytes.isNotEmpty()) {
handle.writeData(bytes.toNSData())
totalBytes += bytes.size
bytes = bytesReader.readNext(BUFFER_SIZE)
}
} finally {
handle.closeFile()
}
return Size(totalBytes)
}
private fun getApplicationSupportDir(): String {
val paths = NSSearchPathForDirectoriesInDomains(
directory = NSApplicationSupportDirectory,
domainMask = NSUserDomainMask,
expandTilde = true
)
return paths.first() as? String ?: error("Application Support directory not found")
}
private val BUFFER_SIZE = 1024 * 1024
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
private fun ByteArray.toNSData(): NSData = memScoped {
NSData.create(
bytes = allocArrayOf(this@toNSData),
length = this@toNSData.size.toULong()
)
}
}
+1
View File
@@ -0,0 +1 @@
/build
+56
View File
@@ -0,0 +1,56 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidMultiplatformLibrary)
}
kotlin {
android {
namespace = "ru.tanexc.hemoanalysis.core.database"
compileSdk {
version = release(36)
}
minSdk = 26
}
listOf(
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "databasekit"
isStatic = true
}
}
sourceSets {
commonMain {
dependencies {
// settings store
implementation(libs.multiplatform.settings)
implementation(libs.multiplatform.settings.serialization)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)
// koin
implementation(libs.koin.core)
}
}
androidMain {
dependencies {
// datastore
implementation(libs.androidx.datastore)
implementation(libs.androidx.datastore.preferences)
}
}
iosMain {
dependencies {
// datastore
implementation(libs.androidx.datastore)
implementation(libs.androidx.datastore.preferences)
}
}
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,12 @@
package ru.tanexc.hemoanalysis.core.database
import android.content.Context
import com.russhwolf.settings.Settings
import com.russhwolf.settings.SharedPreferencesSettings
import org.koin.core.scope.Scope
internal actual fun Scope.createAnalysisParamsSettings(): Settings {
return SharedPreferencesSettings(
delegate = get<Context>().getSharedPreferences("analysis_params_settings", Context.MODE_PRIVATE)
)
}
@@ -0,0 +1,12 @@
package ru.tanexc.hemoanalysis.core.database
import android.content.Context
import com.russhwolf.settings.Settings
import com.russhwolf.settings.SharedPreferencesSettings
import org.koin.core.scope.Scope
internal actual fun Scope.createModelInfoSettings(): Settings {
return SharedPreferencesSettings(
delegate = get<Context>().getSharedPreferences("model_info_settings", Context.MODE_PRIVATE)
)
}
@@ -0,0 +1,6 @@
package ru.tanexc.hemoanalysis.core.database
import com.russhwolf.settings.Settings
import org.koin.core.scope.Scope
internal expect fun Scope.createAnalysisParamsSettings(): Settings
@@ -0,0 +1,10 @@
package ru.tanexc.hemoanalysis.core.database
import com.russhwolf.settings.Settings
import org.koin.core.qualifier.StringQualifier
import org.koin.dsl.module
val databaseModule = module {
single<Settings>(qualifier = StringQualifier("AnalysisParamsSettings")) { createAnalysisParamsSettings() }
single<Settings>(qualifier = StringQualifier("ModelInfoSettings")) { createModelInfoSettings() }
}
@@ -0,0 +1,6 @@
package ru.tanexc.hemoanalysis.core.database
import com.russhwolf.settings.Settings
import org.koin.core.scope.Scope
internal expect fun Scope.createModelInfoSettings(): Settings
@@ -0,0 +1,12 @@
package ru.tanexc.hemoanalysis.core.database
import com.russhwolf.settings.NSUserDefaultsSettings
import com.russhwolf.settings.Settings
import org.koin.core.scope.Scope
import platform.Foundation.NSUserDefaults
internal actual fun Scope.createAnalysisParamsSettings(): Settings {
return NSUserDefaultsSettings(
delegate = NSUserDefaults.standardUserDefaults
)
}
@@ -0,0 +1,12 @@
package ru.tanexc.hemoanalysis.core.database
import com.russhwolf.settings.NSUserDefaultsSettings
import com.russhwolf.settings.Settings
import org.koin.core.scope.Scope
import platform.Foundation.NSUserDefaults
internal actual fun Scope.createModelInfoSettings(): Settings {
return NSUserDefaultsSettings(
delegate = NSUserDefaults.standardUserDefaults
)
}
+1
View File
@@ -0,0 +1 @@
/build
+58
View File
@@ -0,0 +1,58 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidMultiplatformLibrary)
alias(libs.plugins.kotlinxSerialization)
}
kotlin {
android {
namespace = "ru.tanexc.hemoanalysis.navigation"
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
compileSdk {
version = release(36)
}
minSdk = 26
}
listOf(
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "navigationkit"
isStatic = true
}
}
sourceSets {
commonMain {
dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.compose.foundation)
// decompose
implementation(libs.decompose)
implementation(libs.decompose.extensions.compose)
implementation(libs.decompose.extensions.compose.experimental)
implementation(libs.essenty.lifecycle)
// koin
implementation(libs.koin.core)
}
}
androidMain {
dependencies {
implementation(libs.androidx.activity.ktx)
}
}
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,16 @@
package ru.tanexc.hemoanalysis.navigation
import com.arkivanov.decompose.ComponentContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.SupervisorJob
abstract class BaseComponent(
val context: ComponentContext,
val onNavigate: (Config) -> Unit,
val onBack: () -> Unit
): ComponentContext by context {
val componentScope: CoroutineScope
get() = CoroutineScope(SupervisorJob() + Dispatchers.IO).withLifecycle(lifecycle)
}
@@ -0,0 +1,26 @@
package ru.tanexc.hemoanalysis.navigation
import kotlinx.serialization.Serializable
@Serializable
sealed class Config(
val navLabelResId: String,
val navIconResId: String
) {
@Serializable
data object Analysis: Config(
navLabelResId = "analysis_label",
navIconResId = "analysis",
)
@Serializable
data object Settings: Config(
navLabelResId = "settings_label",
navIconResId = "settings",
)
companion object {
val navEntries: List<Config> = listOf(Analysis, Settings)
}
}
@@ -0,0 +1,17 @@
package ru.tanexc.hemoanalysis.navigation
import com.arkivanov.essenty.lifecycle.Lifecycle
import com.arkivanov.essenty.lifecycle.doOnDestroy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import org.koin.core.parameter.parametersOf
import org.koin.mp.KoinPlatform.getKoin
fun CoroutineScope.withLifecycle(lifecycle: Lifecycle): CoroutineScope {
lifecycle.doOnDestroy(::cancel)
return this
}
inline fun <reified T : BaseComponent> getComponent(vararg parameters: Any?) = getKoin().get<T> {
parametersOf(*parameters)
}
+1
View File
@@ -0,0 +1 @@
/build
+52
View File
@@ -0,0 +1,52 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidMultiplatformLibrary)
alias(libs.plugins.kotlinxSerialization)
}
kotlin {
android {
namespace = "ru.tanexc.hemoanalysis.core.network"
compileSdk {
version = release(36)
}
minSdk = 26
}
listOf(
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "nwtworkKit"
isStatic = true
}
}
sourceSets {
commonMain {
dependencies {
api(projects.domain)
implementation(libs.kotlinx.coroutines.core)
// koin
implementation(libs.koin.core)
// ktor
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.logging)
implementation(libs.ktor.serialization.kotlinx.json)
}
}
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,10 @@
package ru.tanexc.hemoanalysis.core.network.util
import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig
import io.ktor.client.engine.okhttp.OkHttp
internal actual fun PlatformHttpClient(declaration: HttpClientConfig<*>.() -> Unit): HttpClient = HttpClient(
engineFactory = OkHttp,
block = declaration,
)
@@ -0,0 +1,3 @@
package ru.tanexc.hemoanalysis.core.network
internal const val DEFAULT_BASE_URL = "http://111.88.154.194:8000"
@@ -0,0 +1,67 @@
package ru.tanexc.hemoanalysis.core.network
import io.ktor.client.HttpClient
import io.ktor.client.request.header
import io.ktor.client.request.parameter
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import io.ktor.util.logging.KtorSimpleLogger
import io.ktor.utils.io.availableForRead
import io.ktor.utils.io.readBuffer
import kotlinx.io.readByteArray
import ru.tanexc.hemoanalysis.core.network.response.ModelDownloadResponse
import ru.tanexc.hemoanalysis.core.network.response.ModelMetaResponse
import ru.tanexc.hemoanalysis.core.network.util.get
import ru.tanexc.hemoanalysis.domain.usecase.BytesReader
import kotlin.math.log
import io.ktor.client.request.get as ktorGet
class ModelApi(
private val httpClient: HttpClient,
) {
suspend fun getLatestModelInfo(apiKey: String): RequestState<ModelMetaResponse> =
httpClient.get(url = "$DEFAULT_BASE_URL/models/latest") {
header("X-API-KEY", apiKey)
}
suspend fun downloadLatestModel(
apiKey: String,
version: String,
filename: String
): RequestState<ModelDownloadResponse> = runCatching {
val response: HttpResponse =
httpClient.ktorGet("$DEFAULT_BASE_URL/models/latest/download") {
header("X-API-KEY", apiKey)
parameter("version", version)
parameter("filename", filename)
contentType(ContentType.Application.OctetStream)
}
when {
response.status.isSuccess() -> {
val buffer = response.bodyAsChannel()
val bytesReader = object : BytesReader {
override suspend fun readNext(size: Int): ByteArray {
return buffer.readBuffer(size).readByteArray()
}
}
RequestState.Success(
ModelDownloadResponse(
bytesReader = bytesReader,
totalBytes = buffer.availableForRead
)
)
}
response.status == HttpStatusCode.NotFound -> RequestState.Error("Model not found")
else -> RequestState.Error(response.status.description)
}
}.getOrElse {
RequestState.Error(
message = it.message ?: "Request error: $DEFAULT_BASE_URL/models/latest/download"
)
}
}
@@ -0,0 +1,7 @@
package ru.tanexc.hemoanalysis.core.network
sealed interface RequestState<out T> {
data class Success<T>(val data: T): RequestState<T>
data class Error(val message: String): RequestState<Nothing>
}
@@ -0,0 +1,31 @@
package ru.tanexc.hemoanalysis.core.network.di
import io.ktor.client.HttpClient
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.SIMPLE
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
import ru.tanexc.hemoanalysis.core.network.ModelApi
import ru.tanexc.hemoanalysis.core.network.util.PlatformHttpClient
val networkModule = module {
single<HttpClient> {
PlatformHttpClient {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(Logging) {
level = LogLevel.HEADERS
}
}
}
singleOf(::ModelApi)
}
@@ -0,0 +1,8 @@
package ru.tanexc.hemoanalysis.core.network.response
import ru.tanexc.hemoanalysis.domain.usecase.BytesReader
data class ModelDownloadResponse(
val bytesReader: BytesReader,
val totalBytes: Int
)
@@ -0,0 +1,12 @@
package ru.tanexc.hemoanalysis.core.network.response
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ModelMetaResponse(
val version: String,
@SerialName("created_at")
val createdAt: String,
val filename: String
)
@@ -0,0 +1,7 @@
package ru.tanexc.hemoanalysis.core.network.util
import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig
import org.koin.core.scope.Scope
internal expect fun PlatformHttpClient(declaration: HttpClientConfig<*>.() -> Unit): HttpClient
@@ -0,0 +1,28 @@
package ru.tanexc.hemoanalysis.core.network.util
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import ru.tanexc.hemoanalysis.core.network.RequestState
internal suspend inline fun <reified T> HttpClient.get(
url: String,
block: HttpRequestBuilder.() -> Unit = {}
): RequestState<T> = runCatching {
get(
urlString = url,
block = block
).handleResponse<T>()
}.getOrElse { RequestState.Error(message = it.message ?: "Request error: $url") }
internal suspend inline fun <reified T> HttpResponse.handleResponse(): RequestState<T> = when {
status.isSuccess() -> RequestState.Success(body())
else -> RequestState.Error(status.description)
}
@@ -0,0 +1,10 @@
package ru.tanexc.hemoanalysis.core.network.util
import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig
import io.ktor.client.engine.darwin.Darwin
internal actual fun PlatformHttpClient(declaration: HttpClientConfig<*>.() -> Unit): HttpClient = HttpClient(
engineFactory = Darwin,
block = declaration
)
+129
View File
@@ -0,0 +1,129 @@
@startuml
left to right direction
actor "Работник фабрики" as FactoryWorker
actor "Работник склада" as WarehouseWorker
actor "Работник магазина" as ShopWorker
actor "Администратор" as Admin
note left of FactoryWorker
Отвечает за регистрацию
произведенных товаров
и организацию поставок
end note
note left of WarehouseWorker
Управляет складскими операциями:
приемка, хранение, отгрузка
end note
note left of ShopWorker
Формирует заявки на товары
и отслеживает доступность
end note
note left of Admin
Управляет пользователями,
ролями и привязкой
сотрудников к складам
end note
rectangle "Система управления складом" {
usecase "Управление товарами" as UC_ManageProducts
usecase "Управление категориями" as UC_ManageCategories
usecase "Регистрация поставки" as UC_RegisterDelivery
usecase "Добавить товар" as UC_AddProduct
usecase "Управление ассортиментом" as UC_Assortment
usecase "Редактировать товар" as UC_EditProduct
usecase "Указать фабрику" as UC_SetFactory
usecase "Указать дату поставки" as UC_SetDeliveryDate
usecase "Указать товары" as UC_SetDeliveryProducts
usecase "Указать склад" as UC_SetWarehouse
usecase "Подтверждение приемки" as UC_AcceptDelivery
usecase "Подтвердить количество" as UC_ConfirmQuantity
usecase "Проверить товар" as UC_CheckProduct
usecase "Обновить остатки" as UC_UpdateStock
usecase "Обработка отгрузки" as UC_ProcessShipment
usecase "Зафиксировать дату отгрузки" as UC_SetShipmentDate
usecase "Подтвердить отгрузку" as UC_ConfirmShipment
usecase "Просмотр остатков" as UC_ViewStock
usecase "Просмотр по категориям" as UC_ViewByCategories
usecase "Просмотр по складам" as UC_ViewByWarehouses
usecase "Запрос отгрузки" as UC_RequestShipment
usecase "Указать магазин" as UC_SetShop
usecase "Указать количество" as UC_SetQuantity
usecase "Выбрать товары" as UC_SelectProducts
usecase "Управление пользователями" as UC_ManageUsers
usecase "Создать пользователя" as UC_CreateUser
usecase "Редактировать пользователя" as UC_EditUser
usecase "Логически удалить пользователя" as UC_DeleteUser
usecase "Управление ролями" as UC_ManageRoles
usecase "Назначить роль" as UC_AssignRole
usecase "Управление привязкой к складам" as UC_ManageWarehouseLinks
usecase "Привязать пользователя к складу" as UC_LinkUserWarehouse
usecase "Отвязать пользователя от склада" as UC_UnlinkUserWarehouse
usecase "Назначить владельца склада" as UC_AssignWarehouseOwner
}
FactoryWorker --> UC_ManageProducts
FactoryWorker --> UC_ManageCategories
FactoryWorker --> UC_RegisterDelivery
WarehouseWorker --> UC_AcceptDelivery
WarehouseWorker --> UC_ProcessShipment
WarehouseWorker --> UC_ViewStock
ShopWorker --> UC_ViewStock
ShopWorker --> UC_RequestShipment
Admin --> UC_ManageUsers
Admin --> UC_ManageRoles
Admin --> UC_ManageWarehouseLinks
UC_ManageProducts ..> UC_AddProduct : include
UC_ManageProducts ..> UC_Assortment : include
UC_ManageProducts ..> UC_EditProduct : include
UC_RegisterDelivery ..> UC_SetFactory : include
UC_RegisterDelivery ..> UC_SetDeliveryDate : include
UC_RegisterDelivery ..> UC_SetDeliveryProducts : include
UC_RegisterDelivery ..> UC_SetWarehouse : include
UC_AcceptDelivery ..> UC_ConfirmQuantity : include
UC_AcceptDelivery ..> UC_CheckProduct : include
UC_AcceptDelivery ..> UC_SetWarehouse : include
UC_AcceptDelivery ..> UC_UpdateStock : include
UC_ProcessShipment ..> UC_UpdateStock : include
UC_ProcessShipment ..> UC_SetShipmentDate : include
UC_ProcessShipment ..> UC_ConfirmShipment : include
UC_ViewStock ..> UC_ViewByCategories : include
UC_ViewStock ..> UC_ViewByWarehouses : include
UC_RequestShipment ..> UC_SetShop : include
UC_RequestShipment ..> UC_SetQuantity : include
UC_RequestShipment ..> UC_SelectProducts : include
UC_RequestShipment ..> UC_SetWarehouse : include
UC_ManageUsers ..> UC_CreateUser : include
UC_ManageUsers ..> UC_EditUser : include
UC_ManageUsers ..> UC_DeleteUser : include
UC_ManageRoles ..> UC_AssignRole : include
UC_ManageWarehouseLinks ..> UC_LinkUserWarehouse : include
UC_ManageWarehouseLinks ..> UC_UnlinkUserWarehouse : include
UC_ManageWarehouseLinks ..> UC_AssignWarehouseOwner : include
@enduml
+1
View File
@@ -0,0 +1 @@
/build
+35
View File
@@ -0,0 +1,35 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidMultiplatformLibrary)
alias(libs.plugins.kotlinxSerialization)
}
kotlin {
androidLibrary {
namespace = "ru.tanexc.hemoanalysis.domain"
compileSdk {
version = release(36)
}
minSdk = 26
}
listOf(
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "domainkit"
isStatic = true
}
}
sourceSets {
commonMain {
dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)
}
}
}
}
@@ -0,0 +1,9 @@
package ru.tanexc.hemoanalysis.domain
sealed interface State<out T> {
data object Loading: State<Nothing>
data class Success<T>(val data: T): State<T>
data object Error: State<Nothing>
}
@@ -0,0 +1,13 @@
package ru.tanexc.hemoanalysis.domain.model
import kotlinx.serialization.Serializable
@Serializable
data class ModelInfo(
val version: String,
val lastUpdateTime: String,
val createdAt: String,
val filename: String,
val isLatest: Boolean,
val size: Size
)
@@ -0,0 +1,18 @@
package ru.tanexc.hemoanalysis.domain.model
import kotlinx.serialization.Serializable
import kotlin.jvm.JvmInline
@Serializable
@JvmInline
value class Size(
val value: Int
) {
companion object {
val ZERO get() = Size(0)
}
}
val Size.Kb: Float get() = value / 1024f
val Size.Mb: Float get() = Kb / 1024f
@@ -0,0 +1,8 @@
package ru.tanexc.hemoanalysis.domain.repository
interface AnalysisParamsRepository {
var confThreshold: Float
var iouThreshold: Float
var preprocessVariant: Int
var roiVariant: Int
}
@@ -0,0 +1,19 @@
package ru.tanexc.hemoanalysis.domain.repository
import kotlinx.coroutines.flow.Flow
import ru.tanexc.hemoanalysis.domain.State
import ru.tanexc.hemoanalysis.domain.model.ModelInfo
interface ModelRepository {
fun getLatestModelInfo(): ModelInfo?
fun getCurrentModelInfo(): ModelInfo?
fun updateModelInfo(): Flow<State<ModelInfo?>>
fun loadLatestModel(): Flow<State<ModelInfo?>>
fun getKeyApi(): String
fun updateApiKey(apiKey: String)
}
@@ -0,0 +1,5 @@
package ru.tanexc.hemoanalysis.domain.usecase
interface BytesReader {
suspend fun readNext(size: Int): ByteArray
}
@@ -0,0 +1,5 @@
package ru.tanexc.hemoanalysis.domain.usecase
interface GetLatestModelFileUseCase {
operator fun invoke(): ByteArray?
}
@@ -0,0 +1,5 @@
package ru.tanexc.hemoanalysis.domain.usecase
interface GetModelPathUseCase {
operator fun invoke(): String?
}
@@ -0,0 +1,7 @@
package ru.tanexc.hemoanalysis.domain.usecase
import ru.tanexc.hemoanalysis.domain.model.Size
interface SaveModelFileUseCase {
suspend operator fun invoke(filename: String, bytesReader: BytesReader): Size
}
+1
View File
@@ -0,0 +1 @@
/build
+72
View File
@@ -0,0 +1,72 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidMultiplatformLibrary)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
}
kotlin {
android {
namespace = "ru.tanexc.hemoanalysis.analysis"
minSdk = 26
compileSdk {
version = release(36)
}
androidResources {
enable = true
}
}
listOf(
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "analysiskit"
isStatic = true
}
}
sourceSets {
commonMain {
dependencies {
api(projects.domain)
api(projects.core.navigation)
api(projects.tool.analysis)
// kotlin
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)
// compose
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
implementation(libs.compose.ui)
implementation(libs.compose.components.resources)
implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.lifecycle.viewmodelCompose)
implementation(libs.androidx.lifecycle.runtimeCompose)
implementation(libs.androidx.graphics.shapes)
// decompose
implementation(libs.decompose)
implementation(libs.decompose.extensions.compose)
implementation(libs.decompose.extensions.compose.experimental)
implementation(libs.essenty.lifecycle)
// file kit
implementation(libs.filekit.core)
implementation(libs.filekit.dialogs)
implementation(libs.filekit.dialogs.compose)
implementation(libs.filekit.coil)
// koin
implementation(libs.koin.core)
}
}
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

Some files were not shown because too many files have changed in this diff Show More