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
+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>
@@ -0,0 +1,9 @@
package ru.tanexc.hemoanalysis.analysis.util
import android.graphics.Bitmap
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.ImageBuffer
internal actual val ImageBuffer.imageBitmap: ImageBitmap
get() = Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888).asImageBitmap()
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:pathData="M400,760v-80q-83,0 -141.5,-58.5T200,480q0,-61 33.5,-111t90.5,-73q8,-34 35.5,-55t62.5,-21l-9,-25q-5,-16 1.5,-30.5T437,144h1q-6,-15 1,-29.5t24,-20.5q15,-5 29.5,1.5T512,118q16,-6 31,1t21,23l82,225q6,16 -0.5,30.5T623,418h-1q6,16 -1,31t-24,21q-15,5 -29.5,-1.5T548,446q-16,6 -31,-1t-21,-23l-10,-28q-15,14 -34.5,21t-39.5,5q-22,-2 -41,-13.5T338,378q-27,16 -42.5,43T280,480q0,50 35,85t85,35h280q17,0 28.5,11.5T720,640q0,17 -11.5,28.5T680,680L520,680v80h200q17,0 28.5,11.5T760,800q0,17 -11.5,28.5T720,840L240,840q-17,0 -28.5,-11.5T200,800q0,-17 11.5,-28.5T240,760h160ZM546,382 L582,368 514,180 476,194 546,382ZM448.5,348.5Q460,337 460,320t-11.5,-28.5Q437,280 420,280t-28.5,11.5Q380,303 380,320t11.5,28.5Q403,360 420,360t28.5,-11.5ZM546,382ZM420,320ZM420,320Z"
android:fillColor="#e3e3e3"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:pathData="m336,680 l144,-144 144,144 56,-56 -144,-144 144,-144 -56,-56 -144,144 -144,-144 -56,56 144,144 -144,144 56,56ZM480,880q-83,0 -156,-31.5T197,763q-54,-54 -85.5,-127T80,480q0,-83 31.5,-156T197,197q54,-54 127,-85.5T480,80q83,0 156,31.5T763,197q54,54 85.5,127T880,480q0,83 -31.5,156T763,763q-54,54 -127,85.5T480,880ZM480,800q134,0 227,-93t93,-227q0,-134 -93,-227t-227,-93q-134,0 -227,93t-93,227q0,134 93,227t227,93ZM480,480Z"
android:fillColor="#e3e3e3"/>
</vector>
@@ -0,0 +1,44 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="450dp"
android:height="450dp"
android:viewportWidth="64"
android:viewportHeight="64">
<path
android:pathData="M56.25,18V46L32,60 7.75,46V18L32,4Z"
android:fillColor="#6075f2"/>
<path
android:pathData="m41.5,26.5v11L32,43V60L56.25,46V18Z"
android:fillColor="#6b57ff"/>
<path
android:pathData="m32,43 l-9.5,-5.5v-11L7.75,18V46L32,60Z">
<aapt:attr name="android:fillColor">
<gradient
android:centerX="23.131"
android:centerY="18.441"
android:gradientRadius="42.132"
android:type="radial">
<item android:offset="0" android:color="#FF5383EC"/>
<item android:offset="0.867" android:color="#FF7F52FF"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M22.5,26.5 L32,21 41.5,26.5 56.25,18 32,4 7.75,18Z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="44.172"
android:startY="4.377"
android:endX="17.973"
android:endY="34.035"
android:type="linear">
<item android:offset="0" android:color="#FF33C3FF"/>
<item android:offset="0.878" android:color="#FF5383EC"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="m32,21 l9.526,5.5v11L32,43 22.474,37.5v-11z"
android:fillColor="#000000"/>
</vector>
@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:pathData="M6.713,27.346C4.706,25.6 3.388,23.387 2.757,20.71C2.125,18.032 2.32,15.472 3.339,13.031C4.358,10.589 6.137,9.077 8.676,8.494C11.215,7.911 13.488,8.493 15.495,10.24C17.501,11.986 18.82,14.199 19.451,16.876C20.082,19.554 19.888,22.114 18.868,24.555C17.849,26.997 16.07,28.509 13.531,29.092C10.992,29.675 8.719,29.093 6.713,27.346ZM16.142,22.902C16.698,21.218 16.758,19.449 16.321,17.595C15.884,15.741 15.039,14.18 13.787,12.912C12.535,11.643 11.074,11.2 9.405,11.584C7.735,11.967 6.622,13.001 6.066,14.685C5.51,16.368 5.45,18.137 5.887,19.991C6.324,21.845 7.168,23.406 8.42,24.674C9.673,25.943 11.133,26.386 12.803,26.002C14.472,25.619 15.585,24.585 16.142,22.902ZM13.543,22.141C13.791,21.795 13.871,21.396 13.783,20.946C13.696,20.496 13.476,20.148 13.125,19.903C12.903,19.737 12.7,19.539 12.518,19.31C12.335,19.08 12.212,18.828 12.147,18.553C12.082,18.279 12.08,17.999 12.142,17.713C12.203,17.428 12.296,17.162 12.421,16.916C12.627,16.544 12.679,16.142 12.578,15.713C12.476,15.284 12.233,14.951 11.847,14.714C11.469,14.511 11.072,14.458 10.655,14.554C10.237,14.65 9.908,14.888 9.668,15.269C9.294,15.861 9.052,16.505 8.941,17.2C8.83,17.895 8.855,18.586 9.017,19.272C9.179,19.959 9.456,20.592 9.849,21.171C10.243,21.75 10.739,22.224 11.339,22.593C11.725,22.83 12.127,22.901 12.544,22.805C12.962,22.709 13.294,22.488 13.543,22.141Z"
android:fillColor="#E3E3E3"/>
<path
android:pathData="M25.8,8.887C27.188,6.63 29.162,4.958 31.721,3.873C34.28,2.788 36.867,2.525 39.481,3.085C42.096,3.644 43.918,5.106 44.947,7.47C45.976,9.834 45.796,12.145 44.408,14.403C43.02,16.66 41.047,18.331 38.488,19.417C35.929,20.502 33.342,20.765 30.727,20.205C28.113,19.645 26.291,18.183 25.262,15.819C24.233,13.455 24.412,11.145 25.8,8.887ZM31.896,17.264C33.673,17.508 35.447,17.254 37.219,16.502C38.991,15.751 40.398,14.655 41.443,13.215C42.487,11.774 42.671,10.277 41.994,8.723C41.317,7.168 40.09,6.269 38.313,6.026C36.536,5.782 34.761,6.036 32.99,6.787C31.218,7.539 29.81,8.635 28.766,10.075C27.722,11.515 27.538,13.013 28.215,14.567C28.891,16.121 30.118,17.02 31.896,17.264ZM32.195,14.605C32.585,14.785 32.996,14.793 33.43,14.628C33.863,14.463 34.171,14.189 34.354,13.804C34.48,13.559 34.642,13.328 34.838,13.11C35.035,12.892 35.265,12.728 35.527,12.616C35.79,12.505 36.069,12.454 36.364,12.463C36.659,12.472 36.941,12.516 37.208,12.594C37.616,12.728 38.025,12.708 38.435,12.534C38.846,12.36 39.135,12.064 39.303,11.648C39.439,11.245 39.422,10.849 39.253,10.461C39.083,10.072 38.788,9.795 38.366,9.629C37.709,9.37 37.024,9.248 36.312,9.263C35.599,9.277 34.915,9.424 34.258,9.702C33.602,9.98 33.02,10.361 32.512,10.845C32.004,11.33 31.618,11.896 31.357,12.544C31.188,12.961 31.188,13.363 31.358,13.752C31.527,14.14 31.806,14.425 32.195,14.605Z"
android:fillColor="#E3E3E3"/>
<path
android:pathData="M30.19,43.863C32.421,42.406 34.036,40.395 35.035,37.828C36.035,35.262 36.2,32.701 35.531,30.144C34.863,27.587 33.312,25.847 30.879,24.924C28.445,24.001 26.113,24.268 23.882,25.725C21.65,27.181 20.035,29.193 19.036,31.759C18.037,34.325 17.872,36.887 18.54,39.444C19.208,42.001 20.759,43.741 23.193,44.664C25.626,45.587 27.958,45.32 30.19,43.863ZM21.472,38.177C21.156,36.434 21.344,34.674 22.036,32.897C22.728,31.121 23.782,29.689 25.2,28.603C26.617,27.517 28.126,27.278 29.726,27.885C31.326,28.492 32.284,29.667 32.599,31.41C32.915,33.154 32.727,34.914 32.035,36.69C31.344,38.467 30.289,39.898 28.872,40.984C27.454,42.07 25.945,42.31 24.346,41.703C22.746,41.096 21.788,39.921 21.472,38.177ZM24.152,37.778C23.955,37.401 23.931,36.995 24.081,36.561C24.231,36.127 24.497,35.813 24.878,35.618C25.122,35.484 25.35,35.315 25.563,35.113C25.776,34.91 25.933,34.678 26.036,34.414C26.138,34.151 26.179,33.874 26.159,33.583C26.138,33.292 26.083,33.016 25.993,32.756C25.842,32.358 25.846,31.954 26.006,31.543C26.166,31.132 26.454,30.835 26.869,30.653C27.272,30.503 27.673,30.504 28.073,30.656C28.473,30.808 28.765,31.089 28.95,31.499C29.237,32.136 29.387,32.807 29.4,33.51C29.413,34.214 29.292,34.894 29.035,35.552C28.779,36.21 28.416,36.799 27.946,37.32C27.476,37.84 26.917,38.242 26.271,38.525C25.856,38.708 25.448,38.723 25.048,38.571C24.649,38.419 24.35,38.155 24.152,37.778Z"
android:fillColor="#E3E3E3"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:pathData="M451.5,828.5Q440,817 440,800v-160q0,-17 11.5,-28.5T480,600q17,0 28.5,11.5T520,640v40h280q17,0 28.5,11.5T840,720q0,17 -11.5,28.5T800,760L520,760v40q0,17 -11.5,28.5T480,840q-17,0 -28.5,-11.5ZM160,760q-17,0 -28.5,-11.5T120,720q0,-17 11.5,-28.5T160,680h160q17,0 28.5,11.5T360,720q0,17 -11.5,28.5T320,760L160,760ZM291.5,588.5Q280,577 280,560v-40L160,520q-17,0 -28.5,-11.5T120,480q0,-17 11.5,-28.5T160,440h120v-40q0,-17 11.5,-28.5T320,360q17,0 28.5,11.5T360,400v160q0,17 -11.5,28.5T320,600q-17,0 -28.5,-11.5ZM480,520q-17,0 -28.5,-11.5T440,480q0,-17 11.5,-28.5T480,440h320q17,0 28.5,11.5T840,480q0,17 -11.5,28.5T800,520L480,520ZM611.5,348.5Q600,337 600,320v-160q0,-17 11.5,-28.5T640,120q17,0 28.5,11.5T680,160v40h120q17,0 28.5,11.5T840,240q0,17 -11.5,28.5T800,280L680,280v40q0,17 -11.5,28.5T640,360q-17,0 -28.5,-11.5ZM160,280q-17,0 -28.5,-11.5T120,240q0,-17 11.5,-28.5T160,200h320q17,0 28.5,11.5T520,240q0,17 -11.5,28.5T480,280L160,280Z"
android:fillColor="#e3e3e3"/>
</vector>
@@ -0,0 +1,53 @@
<resources>
<string name="grant_permissions">Предоставьте разрешения!</string>
<string name="grant_permissions_text">Разрешения необдимы для работы анализа</string>
<string name="grant_text">Предоставить</string>
<string name="select_image">Выбрать изображение</string>
<string name="start_analysis">Начать анализ</string>
<string name="confidence">Уверенность</string>
<string name="iou">Допустимое пересечение</string>
<string name="analysis">Анализ</string>
<string name="cell_class_lymphocyte">Лимфоциты</string>
<string name="cell_class_metamyelocyte">Метамиелоциты</string>
<string name="cell_class_monocyte">Моноциты</string>
<string name="cell_class_myelocyte">Миелоциты</string>
<string name="cell_class_normoblasts">Нормобласты</string>
<string name="cell_class_rod_neutrophil">Палочкоядерные нейтрофилы</string>
<string name="cell_class_segmentonuclear_neutrophil">Сегментоядерные нейтрофилы</string>
<string name="cell_class_blast">Бласты</string>
<string name="cell_class_promyelocyte">Промиелоциты</string>
<string name="cell_class_eosinophil">Эозинофилы</string>
<string name="cell_class_basophil">Базофилы</string>
<string name="cell_class_magakariocyte">Мегакариоциты</string>
<string name="cell_class_bad_cells">Плохие клетки</string>
<string name="cell_class_plasma_cell">Плазматические клетки</string>
<string name="cell_class_artifacts">Артефакты</string>
<string name="cell_class_gumpricht_shadows">Тени Гумпрехта</string>
<string name="cell_class_methoses">Митозы</string>
<string name="cell_class_normoblasts_oxyfilny">Нормобласты оксифильные</string>
<string name="cell_class_macrophages">Макрофаги</string>
<string name="cell_class_normoblasts_erythroblasts">Нормобласты эритробластические</string>
<string name="cell_class_normoblasts_basophilic">Нормобласты базофильные</string>
<string name="cell_class_normoblasts_polychromatophilic">Нормобласты полихроматофильные</string>
<string name="report_title">Отчёт</string>
<string name="report_inference_duration">Время инференса:</string>
<string name="report_preprocessing_duration">Время предобработки:</string>
<string name="report_postprocessing_duration">Время постобработки:</string>
<string name="report_total_duration">Общее время:</string>
<string name="parameters_title">Параметры анализа</string>
<string name="preprocess_variant_deafult">По умолчанию</string>
<string name="preprocess_variant_brightness_contrast">Яркость и контраст</string>
<string name="roi_variant_none">Отключено</string>
<string name="roi_variant_threshold_filter">Пороговая фильтрация</string>
<string name="roi_variant_morph">Пороговая маска</string>
<string name="roi_params_title">Выделение областей интереса</string>
<string name="preprocess_params_title">Предобработка</string>
</resources>
@@ -0,0 +1,53 @@
<resources>
<string name="grant_permissions">Grant permissions!</string>
<string name="grant_permissions_text">Grant permissions to access image analysis</string>
<string name="grant_text">Grant</string>
<string name="select_image">Select an image</string>
<string name="start_analysis">Start analysis</string>
<string name="confidence">Confidence</string>
<string name="iou">Intersection over Union</string>
<string name="analysis">Analysis</string>
<string name="cell_class_lymphocyte">Lymphocytes</string>
<string name="cell_class_metamyelocyte">Metamyelocytes</string>
<string name="cell_class_monocyte">Monocytes</string>
<string name="cell_class_myelocyte">Myelocytes</string>
<string name="cell_class_normoblasts">Normoblastes</string>
<string name="cell_class_rod_neutrophil">Rod neutrophiles</string>
<string name="cell_class_segmentonuclear_neutrophil">Segmentonuclear neutrophils</string>
<string name="cell_class_blast">Blasts</string>
<string name="cell_class_promyelocyte">Promyelocytes</string>
<string name="cell_class_eosinophil">Eosinophils</string>
<string name="cell_class_basophil">Basophils</string>
<string name="cell_class_magakariocyte">Megakaryocytes</string>
<string name="cell_class_bad_cells">Bad cells</string>
<string name="cell_class_plasma_cell">Plasma cells</string>
<string name="cell_class_artifacts">Artifacts</string>
<string name="cell_class_gumpricht_shadows">Gumpricht shadows</string>
<string name="cell_class_methoses">Mitoses</string>
<string name="cell_class_normoblasts_oxyfilny">Oxyphilic normoblasts</string>
<string name="cell_class_macrophages">Macrophages</string>
<string name="cell_class_normoblasts_erythroblasts">Erythroblast normoblasts</string>
<string name="cell_class_normoblasts_basophilic">Basophilic normoblasts</string>
<string name="cell_class_normoblasts_polychromatophilic">Polychromatophilic normoblasts</string>
<string name="report_title">Report</string>
<string name="report_inference_duration">Inference duration:</string>
<string name="report_preprocessing_duration">Preprocessing duration:</string>
<string name="report_postprocessing_duration">Postprocessing duration:</string>
<string name="report_total_duration">Total duration:</string>
<string name="parameters_title">Analysis parameters</string>
<string name="preprocess_variant_deafult">Default</string>
<string name="preprocess_variant_brightness_contrast">Brightness contrast</string>
<string name="roi_variant_none">None</string>
<string name="roi_variant_threshold_filter">Threshold filter</string>
<string name="roi_variant_morph">Threshold morph</string>
<string name="roi_params_title">Highlight regions of interests</string>
<string name="preprocess_params_title">Preprocessing</string>
</resources>
@@ -0,0 +1,122 @@
package ru.tanexc.hemoanalysis.analysis.di
import com.arkivanov.decompose.ComponentContext
import io.github.vinceglb.filekit.FileKit
import io.github.vinceglb.filekit.dialogs.FileKitType
import io.github.vinceglb.filekit.dialogs.compose.util.toImageBitmap
import io.github.vinceglb.filekit.dialogs.openFilePicker
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.tanexc.hemoanalysis.analysis.presentation.AnalysisUiEvent
import ru.tanexc.hemoanalysis.analysis.presentation.AnalysisUiState
import ru.tanexc.hemoanalysis.analysis.presentation.Status
import ru.tanexc.hemoanalysis.analysis.presentation.UpdateParametersUiEvent
import ru.tanexc.hemoanalysis.analysis.util.imageBuffer
import ru.tanexc.hemoanalysis.navigation.BaseComponent
import ru.tanexc.hemoanalysis.navigation.Config
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.parameters.AnalysisParameters
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.results.AnalysisResult
import ru.tanexc.hemoanalysis.tool.analysis.api.AnalysisTool
import ru.tanexc.hemoanalysis.domain.repository.AnalysisParamsRepository
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.parameters.PreprocessVariant
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.parameters.RoiVariant
class AnalysisComponent(
componentContext: ComponentContext,
onNavigate: (Config) -> Unit,
onBack: () -> Unit,
private val analysisTool: AnalysisTool,
private val analysisParamsRepositoryImpl: AnalysisParamsRepository
) : BaseComponent(componentContext, onNavigate, onBack) {
private val _state = MutableStateFlow(
AnalysisUiState(
confidenceThreshold = analysisParamsRepositoryImpl.confThreshold,
iouThreshold = analysisParamsRepositoryImpl.iouThreshold,
preprocessVariant = PreprocessVariant.entries[analysisParamsRepositoryImpl.preprocessVariant],
roiVariant = RoiVariant.entries[analysisParamsRepositoryImpl.roiVariant]
)
)
val state: StateFlow<AnalysisUiState> = _state.asStateFlow()
fun onEvent(event: AnalysisUiEvent) {
when (event) {
is AnalysisUiEvent.SelectImage -> selectImage()
is AnalysisUiEvent.StartAnalysis -> startAnalysis()
is AnalysisUiEvent.CancelImage -> cancelImage()
is UpdateParametersUiEvent.ChangeConfidenceThreshold -> {
_state.update { it.copy(confidenceThreshold = event.value) }
analysisParamsRepositoryImpl.confThreshold = event.value
}
is UpdateParametersUiEvent.ChangeIouThreshold -> {
_state.update { it.copy(iouThreshold = event.value) }
analysisParamsRepositoryImpl.iouThreshold = event.value
}
is AnalysisUiEvent.CloseParameters -> _state.update { it.copy(showParameters = false) }
is AnalysisUiEvent.OpenParameters -> _state.update { it.copy(showParameters = true) }
is UpdateParametersUiEvent.ChangePreprocessVariant -> _state.update {
analysisParamsRepositoryImpl.preprocessVariant = event.value.ordinal
it.copy(preprocessVariant = event.value)
}
is UpdateParametersUiEvent.ChangeRoiVariant -> _state.update {
analysisParamsRepositoryImpl.roiVariant = event.value.ordinal
it.copy(roiVariant = event.value)
}
}
}
private fun selectImage() {
componentScope.launch {
FileKit.openFilePicker(FileKitType.Image)?.let { imageFile ->
_state.update {
it.copy(imageBitmap = imageFile.toImageBitmap())
}
}
}
}
private fun startAnalysis() {
componentScope.launch {
_state.update { it.copy(status = Status.Loading) }
val bitmap = state.value.imageBitmap ?: return@launch
val params = AnalysisParameters(
imageBuffer = bitmap.imageBuffer,
confThreshold = state.value.confidenceThreshold,
iouThreshold = state.value.iouThreshold,
preprocessVariant = state.value.preprocessVariant,
roiVariant = state.value.roiVariant
)
val result = analysisTool.performAnalysis(params)
when (result) {
is AnalysisResult.Error -> _state.update {
it.copy(status = Status.Failed(exception = result.exception))
}
is AnalysisResult.Success -> _state.update {
it.copy(
status = Status.Success(
detections = result.detection,
duration = result.duration
),
)
}
}
}
}
private fun cancelImage() {
_state.update {
it.copy(
status = Status.Idle,
imageBitmap = null
)
}
}
}
@@ -0,0 +1,11 @@
package ru.tanexc.hemoanalysis.analysis.di
import com.arkivanov.decompose.ComponentContext
import org.koin.dsl.module
import ru.tanexc.hemoanalysis.navigation.Config
val analysisModule = module {
factory<AnalysisComponent> { (context: ComponentContext, onNavigate: (Config) -> Unit, onBack: () -> Unit) ->
AnalysisComponent(context, onNavigate, onBack, get(), get())
}
}
@@ -0,0 +1,34 @@
package ru.tanexc.hemoanalysis.analysis.presentation
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.parameters.PreprocessVariant
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.parameters.RoiVariant
sealed interface AnalysisUiEvent {
data object SelectImage: AnalysisUiEvent
data object StartAnalysis: AnalysisUiEvent
data object CancelImage: AnalysisUiEvent
data object OpenParameters: AnalysisUiEvent
data object CloseParameters: AnalysisUiEvent
}
sealed interface UpdateParametersUiEvent: AnalysisUiEvent {
data class ChangeConfidenceThreshold(
val value: Float
): UpdateParametersUiEvent
data class ChangeIouThreshold(
val value: Float
): UpdateParametersUiEvent
data class ChangePreprocessVariant(
val value: PreprocessVariant
): UpdateParametersUiEvent
data class ChangeRoiVariant(
val value: RoiVariant
): UpdateParametersUiEvent
}
@@ -0,0 +1,15 @@
package ru.tanexc.hemoanalysis.analysis.presentation
import androidx.compose.ui.graphics.ImageBitmap
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.parameters.PreprocessVariant
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.parameters.RoiVariant
data class AnalysisUiState(
val status: Status = Status.Idle,
val imageBitmap: ImageBitmap? = null,
val showParameters: Boolean = false,
val confidenceThreshold: Float = 0.5f,
val iouThreshold: Float = 0.5f,
val preprocessVariant: PreprocessVariant = PreprocessVariant.Default,
val roiVariant: RoiVariant = RoiVariant.None
)
@@ -0,0 +1,20 @@
package ru.tanexc.hemoanalysis.analysis.presentation
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.AnalysisException
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.results.AnalysisDuration
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.results.Detection
sealed interface Status {
data object Idle: Status
data object Loading: Status
data class Success(
val detections: List<Detection>,
val duration: AnalysisDuration
): Status
data class Failed(
val exception: AnalysisException
): Status
}
@@ -0,0 +1,156 @@
package ru.tanexc.hemoanalysis.analysis.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import hemoanalysis.feature.analysis.generated.resources.Res
import hemoanalysis.feature.analysis.generated.resources.analysis
import hemoanalysis.feature.analysis.generated.resources.parameters
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import ru.tanexc.hemoanalysis.analysis.di.AnalysisComponent
import ru.tanexc.hemoanalysis.analysis.presentation.AnalysisUiEvent
import ru.tanexc.hemoanalysis.analysis.presentation.Status
import ru.tanexc.hemoanalysis.analysis.presentation.UpdateParametersUiEvent
import ru.tanexc.hemoanalysis.analysis.ui.components.AnalysisControlButton
import ru.tanexc.hemoanalysis.analysis.ui.components.AnalysisParametersSheet
import ru.tanexc.hemoanalysis.analysis.ui.components.Picture
import ru.tanexc.hemoanalysis.analysis.ui.components.errorBlock
import ru.tanexc.hemoanalysis.analysis.ui.components.reportBlock
import ru.tanexc.hemoanalysis.analysis.util.round
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun AnalysisScreen(
paddingValues: PaddingValues,
component: AnalysisComponent
) {
val state by component
.state
.collectAsState()
Column(
modifier = Modifier.padding(
top = 0.dp,
bottom = paddingValues.calculateBottomPadding()
).fillMaxSize()
) {
CenterAlignedTopAppBar(
title = { Text(stringResource(Res.string.analysis)) },
actions = {
IconButton(
onClick = { component.onEvent(AnalysisUiEvent.OpenParameters) },
) {
Icon(
painter = painterResource(Res.drawable.parameters),
contentDescription = null
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer
)
)
LazyColumn(
modifier = Modifier
.fillMaxSize()
.weight(1f),
overscrollEffect = null,
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
Column(
modifier = Modifier
.clip(RoundedCornerShape(0.dp, 0.dp, 36.dp, 36.dp))
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(12.dp),
) {
Picture(
modifier = Modifier.fillMaxWidth(),
imageBitmap = state.imageBitmap,
status = state.status,
onSelectImage = { component.onEvent(AnalysisUiEvent.SelectImage) }
)
Spacer(Modifier.size(12.dp))
AnalysisControlButton(
onSelectImage = { component.onEvent(AnalysisUiEvent.SelectImage) },
onCancelImage = { component.onEvent(AnalysisUiEvent.CancelImage) },
onStartAnalysis = { component.onEvent(AnalysisUiEvent.StartAnalysis) },
isImageSelected = state.imageBitmap != null,
isAnalysisInProgress = state.status == Status.Loading
)
}
}
when (val status = state.status) {
is Status.Success -> reportBlock(
detections = status.detections,
duration = status.duration
)
is Status.Failed -> errorBlock(
status.exception
)
else -> {}
}
}
}
AnalysisParametersSheet(
isVisible = state.showParameters,
confidenceThreshold = state.confidenceThreshold,
iouThreshold = state.iouThreshold,
onDismiss = { component.onEvent(AnalysisUiEvent.CloseParameters) },
onConfThresholdChange = {
component.onEvent(
UpdateParametersUiEvent.ChangeConfidenceThreshold(
it.round(2)
)
)
},
onIouThresholdChange = {
component.onEvent(
UpdateParametersUiEvent.ChangeIouThreshold(
it.round(2)
)
)
},
preprocessVariant = state.preprocessVariant,
roiVariant = state.roiVariant,
onPreprocessVariantChange = {
component.onEvent(
UpdateParametersUiEvent.ChangePreprocessVariant(it)
)
},
onRoiVariantChange = {
component.onEvent(
UpdateParametersUiEvent.ChangeRoiVariant(it)
)
},
)
}
@@ -0,0 +1,86 @@
package ru.tanexc.hemoanalysis.analysis.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import hemoanalysis.feature.analysis.generated.resources.Res
import hemoanalysis.feature.analysis.generated.resources.cell_class_artifacts
import hemoanalysis.feature.analysis.generated.resources.cell_class_bad_cells
import hemoanalysis.feature.analysis.generated.resources.cell_class_basophil
import hemoanalysis.feature.analysis.generated.resources.cell_class_blast
import hemoanalysis.feature.analysis.generated.resources.cell_class_eosinophil
import hemoanalysis.feature.analysis.generated.resources.cell_class_gumpricht_shadows
import hemoanalysis.feature.analysis.generated.resources.cell_class_lymphocyte
import hemoanalysis.feature.analysis.generated.resources.cell_class_macrophages
import hemoanalysis.feature.analysis.generated.resources.cell_class_magakariocyte
import hemoanalysis.feature.analysis.generated.resources.cell_class_metamyelocyte
import hemoanalysis.feature.analysis.generated.resources.cell_class_methoses
import hemoanalysis.feature.analysis.generated.resources.cell_class_monocyte
import hemoanalysis.feature.analysis.generated.resources.cell_class_myelocyte
import hemoanalysis.feature.analysis.generated.resources.cell_class_normoblasts
import hemoanalysis.feature.analysis.generated.resources.cell_class_normoblasts_basophilic
import hemoanalysis.feature.analysis.generated.resources.cell_class_normoblasts_erythroblasts
import hemoanalysis.feature.analysis.generated.resources.cell_class_normoblasts_oxyfilny
import hemoanalysis.feature.analysis.generated.resources.cell_class_normoblasts_polychromatophilic
import hemoanalysis.feature.analysis.generated.resources.cell_class_plasma_cell
import hemoanalysis.feature.analysis.generated.resources.cell_class_promyelocyte
import hemoanalysis.feature.analysis.generated.resources.cell_class_rod_neutrophil
import hemoanalysis.feature.analysis.generated.resources.cell_class_segmentonuclear_neutrophil
import org.jetbrains.compose.resources.stringResource
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.results.CellClass
fun CellClass.strokeColor(): Color = when (this) {
CellClass.Blast -> Color(0xFFD50000)
CellClass.BadCells -> Color(0xFFFF6D00)
CellClass.Promyelocyte -> Color(0xFFC62828)
CellClass.Myelocyte -> Color(0xFFAD1457)
CellClass.Metamyelocyte -> Color(0xFF6A1B9A)
CellClass.RodNeutrophil -> Color(0xFF1565C0)
CellClass.SegmentonuclearNeutrophil -> Color(0xFF00838F)
CellClass.Lymphocyte -> Color(0xFF2E7D32)
CellClass.Monocyte -> Color(0xFF00695C)
CellClass.Eosinophil -> Color(0xFFF9A825)
CellClass.Basophil -> Color(0xFF283593)
CellClass.Macrophages -> Color(0xFF00897B)
CellClass.PlasmaCell -> Color(0xFF8E24AA)
CellClass.Magakariocyte -> Color(0xFF5D4037)
CellClass.Methoses -> Color(0xFF4E342E)
CellClass.Normoblasts -> Color(0xFF3949AB)
CellClass.NormoblastsOxyfilny -> Color(0xFFFF8A65)
CellClass.NormoblastsErythroblasts -> Color(0xFFE53935)
CellClass.NormoblastsBasophilic -> Color(0xFF1E88E5)
CellClass.NormoblastsPolychromatophilic -> Color(0xFF8E24AA)
CellClass.Artifacts -> Color(0xFF757575)
CellClass.GumprichtShadows -> Color(0xFF9E9E9E)
}
@Composable
fun CellClass.title() = when (this) {
CellClass.Lymphocyte -> stringResource(Res.string.cell_class_lymphocyte)
CellClass.Metamyelocyte -> stringResource(Res.string.cell_class_metamyelocyte)
CellClass.Monocyte -> stringResource(Res.string.cell_class_monocyte)
CellClass.Myelocyte -> stringResource(Res.string.cell_class_myelocyte)
CellClass.Normoblasts -> stringResource(Res.string.cell_class_normoblasts)
CellClass.RodNeutrophil -> stringResource(Res.string.cell_class_rod_neutrophil)
CellClass.SegmentonuclearNeutrophil -> stringResource(Res.string.cell_class_segmentonuclear_neutrophil)
CellClass.Blast -> stringResource(Res.string.cell_class_blast)
CellClass.Promyelocyte -> stringResource(Res.string.cell_class_promyelocyte)
CellClass.Eosinophil -> stringResource(Res.string.cell_class_eosinophil)
CellClass.Basophil -> stringResource(Res.string.cell_class_basophil)
CellClass.Magakariocyte -> stringResource(Res.string.cell_class_magakariocyte)
CellClass.BadCells -> stringResource(Res.string.cell_class_bad_cells)
CellClass.PlasmaCell -> stringResource(Res.string.cell_class_plasma_cell)
CellClass.Artifacts -> stringResource(Res.string.cell_class_artifacts)
CellClass.GumprichtShadows -> stringResource(Res.string.cell_class_gumpricht_shadows)
CellClass.Methoses -> stringResource(Res.string.cell_class_methoses)
CellClass.NormoblastsOxyfilny -> stringResource(Res.string.cell_class_normoblasts_oxyfilny)
CellClass.Macrophages -> stringResource(Res.string.cell_class_macrophages)
CellClass.NormoblastsErythroblasts -> stringResource(Res.string.cell_class_normoblasts_erythroblasts)
CellClass.NormoblastsBasophilic -> stringResource(Res.string.cell_class_normoblasts_basophilic)
CellClass.NormoblastsPolychromatophilic -> stringResource(Res.string.cell_class_normoblasts_polychromatophilic)
}
@@ -0,0 +1,86 @@
package ru.tanexc.hemoanalysis.analysis.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.slideInHorizontally
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import hemoanalysis.feature.analysis.generated.resources.Res
import hemoanalysis.feature.analysis.generated.resources.cancel
import hemoanalysis.feature.analysis.generated.resources.select_image
import hemoanalysis.feature.analysis.generated.resources.start_analysis
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
@Composable
fun AnalysisControlButton(
modifier: Modifier = Modifier,
onSelectImage: () -> Unit,
onCancelImage: () -> Unit,
onStartAnalysis: () -> Unit,
isImageSelected: Boolean,
isAnalysisInProgress: Boolean
) {
Row(
modifier = modifier.height(IntrinsicSize.Min),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Button(
modifier = Modifier.weight(1f),
shape = if (isImageSelected) {
RoundedCornerShape(50, 5, 5, 50)
} else {
CircleShape
},
enabled = !isAnalysisInProgress,
onClick = {
if (!isImageSelected) {
onSelectImage()
} else {
onStartAnalysis()
}
}
) {
val text = if (!isImageSelected) {
stringResource(Res.string.select_image)
} else {
stringResource(Res.string.start_analysis)
}
Text(
modifier = Modifier.padding(vertical = 16.dp),
text = text
)
}
AnimatedVisibility(
modifier = Modifier.fillMaxHeight(),
visible = isImageSelected,
enter = slideInHorizontally() + expandHorizontally()
) {
Button(
modifier = Modifier.fillMaxHeight(),
shape = RoundedCornerShape(5, 50, 50, 5),
enabled = !isAnalysisInProgress,
onClick = onCancelImage
) {
Icon(
painter = painterResource(Res.drawable.cancel),
contentDescription = null
)
}
}
}
}
@@ -0,0 +1,228 @@
package ru.tanexc.hemoanalysis.analysis.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ButtonGroup
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import hemoanalysis.feature.analysis.generated.resources.Res
import hemoanalysis.feature.analysis.generated.resources.confidence
import hemoanalysis.feature.analysis.generated.resources.iou
import hemoanalysis.feature.analysis.generated.resources.parameters_title
import hemoanalysis.feature.analysis.generated.resources.preprocess_params_title
import hemoanalysis.feature.analysis.generated.resources.preprocess_variant_brightness_contrast
import hemoanalysis.feature.analysis.generated.resources.preprocess_variant_deafult
import hemoanalysis.feature.analysis.generated.resources.roi_params_title
import hemoanalysis.feature.analysis.generated.resources.roi_variant_morph
import hemoanalysis.feature.analysis.generated.resources.roi_variant_none
import hemoanalysis.feature.analysis.generated.resources.roi_variant_threshold_filter
import org.jetbrains.compose.resources.stringResource
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.parameters.PreprocessVariant
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.parameters.RoiVariant
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun AnalysisParametersSheet(
isVisible: Boolean,
confidenceThreshold: Float,
iouThreshold: Float,
preprocessVariant: PreprocessVariant,
roiVariant: RoiVariant,
onDismiss: () -> Unit,
onConfThresholdChange: (Float) -> Unit,
onIouThresholdChange: (Float) -> Unit,
onPreprocessVariantChange: (PreprocessVariant) -> Unit,
onRoiVariantChange: (RoiVariant) -> Unit
) {
if (isVisible) {
ModalBottomSheet(
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
onDismissRequest = onDismiss
) {
Text(
modifier = Modifier.fillMaxWidth(),
text = stringResource(Res.string.parameters_title),
fontWeight = FontWeight.Black,
fontSize = 24.sp,
textAlign = TextAlign.Center
)
Column(
modifier = Modifier.padding(22.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(22.dp)
) {
TitledSlider(
value = confidenceThreshold,
title = stringResource(Res.string.confidence),
onValueChange = onConfThresholdChange
)
TitledSlider(
value = iouThreshold,
title = stringResource(Res.string.iou),
onValueChange = onIouThresholdChange
)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = stringResource(Res.string.preprocess_params_title),
fontWeight = FontWeight.Bold
)
SingleChoiceSegmentedButtonRow(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)
) {
SegmentedButton(
modifier = Modifier.fillMaxHeight(),
selected = preprocessVariant == PreprocessVariant.Default,
onClick = { onPreprocessVariantChange(PreprocessVariant.Default) },
shape = SegmentedButtonDefaults.itemShape(0, 2),
label = {
Text(
text = stringResource(Res.string.preprocess_variant_deafult),
textAlign = TextAlign.Center
)
},
icon = {}
)
SegmentedButton(
modifier = Modifier.fillMaxHeight(),
selected = preprocessVariant == PreprocessVariant.BrightnessContrast,
onClick = { onPreprocessVariantChange(PreprocessVariant.BrightnessContrast) },
shape = SegmentedButtonDefaults.itemShape(1, 2),
label = {
Text(
text = stringResource(Res.string.preprocess_variant_brightness_contrast),
textAlign = TextAlign.Center
)
},
icon = {}
)
}
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = stringResource(Res.string.roi_params_title),
fontWeight = FontWeight.Bold
)
SingleChoiceSegmentedButtonRow(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)
) {
SegmentedButton(
modifier = Modifier.fillMaxHeight(),
selected = roiVariant == RoiVariant.None,
onClick = { onRoiVariantChange(RoiVariant.None) },
shape = SegmentedButtonDefaults.itemShape(0, 3),
label = {
Text(
text = stringResource(Res.string.roi_variant_none),
textAlign = TextAlign.Center
)
},
icon = {}
)
SegmentedButton(
modifier = Modifier.fillMaxHeight(),
selected = roiVariant == RoiVariant.ThresholdMorph,
onClick = { onRoiVariantChange(RoiVariant.ThresholdMorph) },
shape = SegmentedButtonDefaults.itemShape(1, 3),
label = {
Text(
text = stringResource(Res.string.roi_variant_morph),
textAlign = TextAlign.Center
)
},
icon = {}
)
SegmentedButton(
modifier = Modifier.fillMaxHeight(),
selected = roiVariant == RoiVariant.ThresholdFilter,
onClick = { onRoiVariantChange(RoiVariant.ThresholdFilter) },
shape = SegmentedButtonDefaults.itemShape(2, 3),
label = {
Text(
text = stringResource(Res.string.roi_variant_threshold_filter),
textAlign = TextAlign.Center
)
},
icon = {}
)
}
}
}
}
}
}
@Composable
fun TitledSlider(
value: Float,
title: String,
onValueChange: (Float) -> Unit
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = title,
fontWeight = FontWeight.Bold
)
Text(value.toString())
}
Slider(
value = value,
modifier = Modifier.fillMaxWidth(),
steps = 100,
valueRange = 0f..1f,
colors = SliderDefaults.colors().copy(
activeTickColor = Color.Transparent,
inactiveTickColor = Color.Transparent
),
onValueChange = onValueChange,
)
}
}
@@ -0,0 +1,62 @@
package ru.tanexc.hemoanalysis.analysis.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import ru.tanexc.hemoanalysis.analysis.ui.strokeColor
import ru.tanexc.hemoanalysis.analysis.ui.title
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.results.CellClass
@Composable
fun CellHeader(
cell: CellClass,
count: Int
) {
Box(
modifier = Modifier
.padding(horizontal = 12.dp)
.clip(RoundedCornerShape(36.dp))
.fillMaxWidth()
.background(MaterialTheme.colorScheme.secondaryContainer)
.padding(vertical = 16.dp, horizontal = 22.dp),
) {
Box(
modifier = Modifier
.size(32.dp)
.clip(RoundedCornerShape(8.dp))
.background(cell.strokeColor())
.align(Alignment.CenterStart)
)
Text(
modifier = Modifier
.fillMaxWidth()
.basicMarquee()
.align(Alignment.Center),
text = cell.title(),
textAlign = TextAlign.Center,
overflow = TextOverflow.Visible,
fontWeight = FontWeight.Bold
)
Text(
modifier = Modifier
.align(Alignment.CenterEnd),
text = count.toString())
}
}
@@ -0,0 +1,97 @@
package ru.tanexc.hemoanalysis.analysis.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.AnalysisException
import kotlin.time.DurationUnit
fun LazyListScope.errorBlock(
analysisException: AnalysisException
) {
item {
Card(
modifier = Modifier
.padding(vertical = 8.dp, horizontal = 12.dp)
.clip(RoundedCornerShape(36.dp))
.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.error
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(22.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Ошибка во время анализа",
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Black,
fontSize = 24.sp
)
Spacer(Modifier.size(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom
) {
Text(
modifier = Modifier.weight(1f),
text = "Время выполнения:",
fontWeight = FontWeight.Bold
)
Text(
text = analysisException.duration.toString(
unit = DurationUnit.MILLISECONDS,
decimals = 1
)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
Text(
text = "Ошибка:",
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.error
)
Spacer(Modifier.width(8.dp))
Text(
text = analysisException.message ?: "Неизвестна",
modifier = Modifier.fillMaxWidth().weight(1f),
textAlign = TextAlign.Justify
)
}
}
}
}
}
@@ -0,0 +1,146 @@
package ru.tanexc.hemoanalysis.analysis.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.graphics.shapes.CornerRounding
import androidx.graphics.shapes.RoundedPolygon
import androidx.graphics.shapes.star
import hemoanalysis.feature.analysis.generated.resources.Res
import hemoanalysis.feature.analysis.generated.resources.analysis
import org.jetbrains.compose.resources.painterResource
import ru.tanexc.hemoanalysis.analysis.presentation.Status
import ru.tanexc.hemoanalysis.analysis.ui.strokeColor
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun Picture(
modifier: Modifier = Modifier,
status: Status,
imageBitmap: ImageBitmap? = null,
onSelectImage: () -> Unit
) {
val isLoading = status == Status.Loading
val detections by remember(status) {
derivedStateOf {
when (status) {
is Status.Success -> status.detections
else -> emptyList()
}
}
}
imageBitmap?.let {
Box(
modifier = modifier,
contentAlignment = Alignment.Center
) {
Canvas(
modifier = Modifier
.clip(RoundedCornerShape(36.dp))
.fillMaxWidth()
.aspectRatio(imageBitmap.width.toFloat() / imageBitmap.height)
) {
val height = size.height
val width = size.width
val scaleX = width / imageBitmap.width
val scaleY = height / imageBitmap.height
drawImage(
image = imageBitmap,
srcSize = IntSize(imageBitmap.width, imageBitmap.height),
dstSize = IntSize(width.toInt(), height.toInt()),
alpha = if (isLoading) 0.3f else 1f
)
detections.forEach { detection ->
val left = (detection.centerX - detection.width / 2f) * scaleX
val top = (detection.centerY - detection.height / 2f) * scaleY
val boxWidth = detection.width * scaleX
val boxHeight = detection.height * scaleY
drawRoundRect(
color = detection.cellClass.strokeColor(),
topLeft = Offset(left, top),
size = Size(boxWidth, boxHeight),
cornerRadius = CornerRadius(12f, 12f),
style = Stroke(
width = 8f,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(15f, 10f), 0f)
)
)
}
}
if (isLoading) {
CircularWavyProgressIndicator()
}
}
} ?: Card(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(2f)
.clip(RoundedCornerShape(36.dp))
.clickable(onClick = onSelectImage),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Column(
modifier = Modifier
.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
val shape = RoundedPolygon.star(
numVerticesPerRadius = 4,
innerRadius = 0.4f,
rounding = CornerRounding(0.5f, 0.2f)
).normalized().toShape(45)
Box(
modifier = Modifier
.size(140.dp)
.clip(shape)
.background(MaterialTheme.colorScheme.secondary, shape),
contentAlignment = Alignment.Center
) {
Icon(
modifier = Modifier.fillMaxSize(0.5f),
painter = painterResource(Res.drawable.analysis),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondary
)
}
}
}
}
@@ -0,0 +1,150 @@
package ru.tanexc.hemoanalysis.analysis.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.Text
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import hemoanalysis.feature.analysis.generated.resources.Res
import hemoanalysis.feature.analysis.generated.resources.report_inference_duration
import hemoanalysis.feature.analysis.generated.resources.report_postprocessing_duration
import hemoanalysis.feature.analysis.generated.resources.report_preprocessing_duration
import hemoanalysis.feature.analysis.generated.resources.report_title
import hemoanalysis.feature.analysis.generated.resources.report_total_duration
import org.jetbrains.compose.resources.stringResource
import ru.tanexc.hemoanalysis.analysis.ui.title
import ru.tanexc.hemoanalysis.analysis.util.round
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.results.AnalysisDuration
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.results.Detection
import kotlin.time.DurationUnit
fun LazyListScope.reportBlock(
detections: List<Detection>,
duration: AnalysisDuration
) {
item {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp, horizontal = 12.dp)
.clip(RoundedCornerShape(36.dp))
) {
Column(
modifier = Modifier
.padding(22.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom
) {
Text(
modifier = Modifier.weight(1f),
text = stringResource(Res.string.report_preprocessing_duration),
fontWeight = FontWeight.Bold
)
Text(
text = duration.preprocessing.toString(
unit = DurationUnit.MILLISECONDS,
decimals = 1
)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom
) {
Text(
modifier = Modifier.weight(1f),
text = stringResource(Res.string.report_inference_duration),
fontWeight = FontWeight.Bold
)
Text(
text = duration.inference.toString(
unit = DurationUnit.MILLISECONDS,
decimals = 1
)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom
) {
Text(
modifier = Modifier.weight(1f),
text = stringResource(Res.string.report_postprocessing_duration),
fontWeight = FontWeight.Bold
)
Text(
text = duration.postprocessing.toString(
unit = DurationUnit.MILLISECONDS,
decimals = 1
)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom
) {
Text(
modifier = Modifier.weight(1f),
text = stringResource(Res.string.report_total_duration),
fontWeight = FontWeight.Bold
)
Text(
text = duration.total.toString(
unit = DurationUnit.MILLISECONDS,
decimals = 1
)
)
}
}
}
}
if (detections.isNotEmpty()) {
item {
Text(
modifier = Modifier,
text = stringResource(Res.string.report_title),
fontWeight = FontWeight.Black,
fontSize = 24.sp
)
Spacer(Modifier.size(8.dp))
}
}
detections
.groupBy { it.cellClass }
.forEach { (cell, detects) ->
stickyHeader {
CellHeader(cell, detections.size)
}
items(detects) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp, horizontal = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(it.cellClass.title())
Text(it.confidence.round(2).toString())
}
}
}
}
@@ -0,0 +1,8 @@
package ru.tanexc.hemoanalysis.analysis.util
import kotlin.math.pow
import kotlin.math.round
fun Float.round(n: Int): Float {
return round(this * 10f.pow(n)) / 10f.pow(n)
}
@@ -0,0 +1,13 @@
package ru.tanexc.hemoanalysis.analysis.util
import androidx.compose.ui.graphics.ImageBitmap
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.ImageBuffer
internal expect val ImageBuffer.imageBitmap: ImageBitmap
internal val ImageBitmap.imageBuffer: ImageBuffer
get() = ImageBuffer(
pixels = IntArray(width * height).apply(::readPixels),
width = width,
height = height
)
@@ -0,0 +1,41 @@
package ru.tanexc.hemoanalysis.analysis.util
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import org.jetbrains.skia.ColorAlphaType
import org.jetbrains.skia.ColorType
import org.jetbrains.skia.Image
import org.jetbrains.skia.ImageInfo
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.ImageBuffer
internal actual val ImageBuffer.imageBitmap: ImageBitmap
get() {
val bytes = ByteArray(width * height * 4)
var byteIndex = 0
for (pixel in pixels) {
val a = pixel ushr 24 and 0xFF
val r = pixel ushr 16 and 0xFF
val g = pixel ushr 8 and 0xFF
val b = pixel and 0xFF
bytes[byteIndex++] = b.toByte()
bytes[byteIndex++] = g.toByte()
bytes[byteIndex++] = r.toByte()
bytes[byteIndex++] = a.toByte()
}
val imageInfo = ImageInfo(
width = width,
height = height,
colorType = ColorType.BGRA_8888,
alphaType = ColorAlphaType.UNPREMUL
)
return Image.makeRaster(
imageInfo = imageInfo,
bytes = bytes,
rowBytes = width * 4
).toComposeImageBitmap()
}
+1
View File
@@ -0,0 +1 @@
/build
+66
View File
@@ -0,0 +1,66 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidMultiplatformLibrary)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
}
kotlin {
android {
namespace = "ru.tanexc.hemoanalysis.settings"
minSdk = 26
compileSdk {
version = release(36)
}
androidResources {
enable = true
}
}
listOf(
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "settingskit"
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)
// 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>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:pathData="m336,680 l144,-144 144,144 56,-56 -144,-144 144,-144 -56,-56 -144,144 -144,-144 -56,56 144,144 -144,144 56,56ZM480,880q-83,0 -156,-31.5T197,763q-54,-54 -85.5,-127T80,480q0,-83 31.5,-156T197,197q54,-54 127,-85.5T480,80q83,0 156,31.5T763,197q54,54 85.5,127T880,480q0,83 -31.5,156T763,763q-54,54 -127,85.5T480,880ZM480,800q134,0 227,-93t93,-227q0,-134 -93,-227t-227,-93q-134,0 -227,93t-93,227q0,134 93,227t227,93ZM480,480Z"
android:fillColor="#e3e3e3"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:pathData="M446,514ZM552,419ZM446,514ZM552,419ZM446,514ZM552,419ZM791,904 L56,169l56,-57 736,736 -57,56ZM480,840q-151,0 -255.5,-46.5T120,680v-400q0,-26 17.5,-49.5T187,187l252,252q-72,-3 -133,-18t-106,-40v120q51,29 123,44t157,15q20,0 39,-0.5t38,-2.5l70,70q-34,7 -71,10t-76,3q-85,0 -157,-15t-123,-44v99q9,29 97.5,54.5T480,760q64,0 128.5,-13T715,715l58,58q-49,31 -125.5,49T480,840ZM830,717 L760,647v-66q-11,6 -22,11t-23,10l-61,-61q30,-8 56.5,-17.5T760,501v-120q-41,23 -94,37t-116,19l-76,-76q44,0 92,-7t89.5,-18.5q41.5,-11.5 70,-26T760,281q-11,-29 -100.5,-55T480,200q-37,0 -75.5,5T331,218l-66,-66q45,-15 100,-23.5t115,-8.5q149,0 254.5,47T840,280v400q0,10 -2.5,19t-7.5,18Z"
android:fillColor="#e3e3e3"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:pathData="M465,620.5q-7,-2.5 -13,-8.5L308,468q-12,-12 -11.5,-28t11.5,-28q12,-12 28.5,-12.5T365,411l75,75v-286q0,-17 11.5,-28.5T480,160q17,0 28.5,11.5T520,200v286l75,-75q12,-12 28.5,-11.5T652,412q11,12 11.5,28T652,468L508,612q-6,6 -13,8.5t-15,2.5q-8,0 -15,-2.5ZM240,800q-33,0 -56.5,-23.5T160,720v-80q0,-17 11.5,-28.5T200,600q17,0 28.5,11.5T240,640v80h480v-80q0,-17 11.5,-28.5T760,600q17,0 28.5,11.5T800,640v80q0,33 -23.5,56.5T720,800L240,800Z"
android:fillColor="#e3e3e3"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:pathData="M393,828q-103,-29 -168,-113.5T160,520q0,-57 19,-108.5t54,-94.5q11,-12 27,-12.5t29,12.5q11,11 11.5,27T290,374q-24,31 -37,68t-13,78q0,81 47.5,144.5T410,751q13,4 21.5,15t8.5,24q0,20 -14,31.5t-33,6.5ZM567,828q-19,5 -33,-7t-14,-32q0,-12 8.5,-23t21.5,-15q75,-24 122.5,-87T720,520q0,-100 -70,-170t-170,-70h-3l16,16q11,11 11,28t-11,28q-11,11 -28,11t-28,-11l-84,-84q-6,-6 -8.5,-13t-2.5,-15q0,-8 2.5,-15t8.5,-13l84,-84q11,-11 28,-11t28,11q11,11 11,28t-11,28l-16,16h3q134,0 227,93t93,227q0,109 -65,194T567,828Z"
android:fillColor="#e3e3e3"/>
</vector>
@@ -0,0 +1,15 @@
<resources>
<string name="settings">Настройки</string>
<string name="model_info_title">Текущая модель</string>
<string name="model_info_version">Версия:</string>
<string name="model_info_created_at">Дата создания:</string>
<string name="model_info_last_update">Последнее обновление:</string>
<string name="model_info_check_for_updates">Проверить обновление</string>
<string name="model_info_update_available">Доступно обновление!</string>
<string name="model_info_error">Ошибка загрузки. Попробуйте снова</string>
<string name="model_info_download_model">Загрузить модель</string>
<string name="model_info_no_model">Нет установленной модели</string>
<string name="model_info_size_title">Размер:</string>
<string name="model_info_size_content">Мб</string>
<string name="api_key_label">API-ключ</string>
</resources>
@@ -0,0 +1,15 @@
<resources>
<string name="settings">Settings</string>
<string name="model_info_title">CurrentModel</string>
<string name="model_info_version">Version:</string>
<string name="model_info_created_at">Created at:</string>
<string name="model_info_last_update">Last update at:</string>
<string name="model_info_check_for_updates">Check for updates</string>
<string name="model_info_update_available">An update is available!</string>
<string name="model_info_download_model">Download model</string>
<string name="model_info_error">Loading Error. Try Again</string>
<string name="model_info_no_model">Model is not installed</string>
<string name="model_info_size_title">Size:</string>
<string name="model_info_size_content">Mb</string>
<string name="api_key_label">API-key</string>
</resources>
@@ -0,0 +1,127 @@
package ru.tanexc.hemoanalysis.settings.di
import com.arkivanov.decompose.ComponentContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.tanexc.hemoanalysis.domain.State
import ru.tanexc.hemoanalysis.domain.repository.ModelRepository
import ru.tanexc.hemoanalysis.navigation.BaseComponent
import ru.tanexc.hemoanalysis.navigation.Config
import ru.tanexc.hemoanalysis.settings.presentation.LoadingState
import ru.tanexc.hemoanalysis.settings.presentation.ModelStatus
import ru.tanexc.hemoanalysis.settings.presentation.SettingsUiEvent
import ru.tanexc.hemoanalysis.settings.presentation.SettingsUiState
class SettingsComponent(
componentContext: ComponentContext,
onNavigate: (Config) -> Unit,
onBack: () -> Unit,
private val modelRepository: ModelRepository
) : BaseComponent(componentContext, onNavigate, onBack) {
private val _state = MutableStateFlow(
SettingsUiState(
currentModelInfo = modelRepository.getCurrentModelInfo(),
latestModelInfo = null,
loadingState = LoadingState.Idle,
apiKey = modelRepository.getKeyApi()
)
)
val state: StateFlow<SettingsUiState> = _state.asStateFlow()
init {
componentScope.launch(Dispatchers.IO) {
if (state.value.apiKey.isNotEmpty()) {
loadModelInfo()
}
}
}
fun onEvent(event: SettingsUiEvent) {
when (event) {
SettingsUiEvent.LoadLatestModel -> loadLatestModel()
SettingsUiEvent.UpdateModelInfo -> updateModelInfo()
SettingsUiEvent.NavigateBack -> onBack()
is SettingsUiEvent.UpdateApiKey -> updateApiKey(event.apiKey)
}
}
fun loadLatestModel() {
componentScope.launch(Dispatchers.IO) {
modelRepository.loadLatestModel().collect { state ->
when (state) {
is State.Success -> {
_state.update {
it.copy(
loadingState = LoadingState.Success(status = ModelStatus.UpToDate),
latestModelInfo = state.data,
currentModelInfo = state.data
)
}
}
is State.Error -> _state.update { it.copy(loadingState = LoadingState.Error(status = ModelStatus.OutOfDate)) }
is State.Loading -> _state.update { it.copy(loadingState = LoadingState.Loading) }
}
}
}
}
fun updateModelInfo() {
componentScope.launch(Dispatchers.IO) {
modelRepository.updateModelInfo().collect { state ->
when (state) {
is State.Success -> {
val latestModelInfo = state.data
val currentModelInfo = _state.value.currentModelInfo
val status = when {
currentModelInfo == null -> ModelStatus.NotPresented
!currentModelInfo.isLatest -> ModelStatus.OutOfDate
else -> ModelStatus.UpToDate
}
_state.update {
it.copy(
loadingState = LoadingState.Success(status = status),
latestModelInfo = latestModelInfo,
currentModelInfo = modelRepository.getCurrentModelInfo()
)
}
}
is State.Error -> _state.update { it.copy(loadingState = LoadingState.Error(status = ModelStatus.UpToDate)) }
is State.Loading -> _state.update { it.copy(loadingState = LoadingState.Loading) }
}
}
}
}
fun updateApiKey(apiKey: String) {
componentScope.launch(Dispatchers.IO) {
modelRepository.updateApiKey(apiKey)
_state.update { it.copy(apiKey = apiKey) }
if (apiKey.isNotEmpty()) {
loadModelInfo()
}
}
}
fun loadModelInfo() {
val currentModelInfo = modelRepository.getCurrentModelInfo()
if (currentModelInfo == null) {
_state.update { it.copy(loadingState = LoadingState.Success(status = ModelStatus.NotPresented)) }
} else {
_state.update {
it.copy(
currentModelInfo = currentModelInfo,
loadingState = LoadingState.Success(status = ModelStatus.UpToDate)
)
}
}
updateModelInfo()
}
}
@@ -0,0 +1,11 @@
package ru.tanexc.hemoanalysis.settings.di
import com.arkivanov.decompose.ComponentContext
import org.koin.dsl.module
import ru.tanexc.hemoanalysis.navigation.Config
val settingsModule = module {
factory<SettingsComponent> { (context: ComponentContext, onNavigate: (Config) -> Unit, onBack: () -> Unit) ->
SettingsComponent(context, onNavigate, onBack, get())
}
}
@@ -0,0 +1,17 @@
package ru.tanexc.hemoanalysis.settings.presentation
sealed interface LoadingState {
data object Idle: LoadingState
data class Success(
val status: ModelStatus
): LoadingState
data class Error(
val status: ModelStatus
): LoadingState
data object Loading: LoadingState
data object ApiKeyNotSet: LoadingState
}
@@ -0,0 +1,5 @@
package ru.tanexc.hemoanalysis.settings.presentation
enum class ModelStatus {
NotPresented, OutOfDate, UpToDate
}
@@ -0,0 +1,13 @@
package ru.tanexc.hemoanalysis.settings.presentation
sealed interface SettingsUiEvent {
data object UpdateModelInfo: SettingsUiEvent
data object LoadLatestModel: SettingsUiEvent
data object NavigateBack: SettingsUiEvent
data class UpdateApiKey(
val apiKey: String
): SettingsUiEvent
}
@@ -0,0 +1,10 @@
package ru.tanexc.hemoanalysis.settings.presentation
import ru.tanexc.hemoanalysis.domain.model.ModelInfo
data class SettingsUiState(
val currentModelInfo: ModelInfo?,
val latestModelInfo: ModelInfo?,
val loadingState: LoadingState,
val apiKey: String
)
@@ -0,0 +1,183 @@
package ru.tanexc.hemoanalysis.settings.ui
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import hemoanalysis.feature.settings.generated.resources.Res
import hemoanalysis.feature.settings.generated.resources.api_key_label
import hemoanalysis.feature.settings.generated.resources.cancel
import hemoanalysis.feature.settings.generated.resources.model_info_created_at
import hemoanalysis.feature.settings.generated.resources.model_info_last_update
import hemoanalysis.feature.settings.generated.resources.model_info_no_model
import hemoanalysis.feature.settings.generated.resources.model_info_title
import hemoanalysis.feature.settings.generated.resources.model_info_version
import hemoanalysis.feature.settings.generated.resources.settings
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import ru.tanexc.hemoanalysis.domain.model.ModelInfo
import ru.tanexc.hemoanalysis.settings.di.SettingsComponent
import ru.tanexc.hemoanalysis.settings.presentation.LoadingState
import ru.tanexc.hemoanalysis.settings.presentation.ModelStatus
import ru.tanexc.hemoanalysis.settings.presentation.SettingsUiEvent
import ru.tanexc.hemoanalysis.settings.ui.components.ErrorCard
import ru.tanexc.hemoanalysis.settings.ui.components.ModelInfoButton
import ru.tanexc.hemoanalysis.settings.ui.components.ModelInfoCard
import ru.tanexc.hemoanalysis.settings.ui.components.NoModelCard
import kotlin.time.Clock
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SettingsScreen(
paddingValues: PaddingValues,
component: SettingsComponent
) {
val state by component.state.collectAsState()
val focusManager = LocalFocusManager.current
Column(
modifier = Modifier.padding(
top = 0.dp,
bottom = paddingValues.calculateBottomPadding()
).fillMaxSize()
) {
CenterAlignedTopAppBar(
title = { Text(stringResource(Res.string.settings)) }
)
Column(
modifier = Modifier
.fillMaxSize()
.hideKeyboardOnTap(focusManager)
.padding(horizontal = 12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Spacer(Modifier.size(8.dp))
when (val modelInfo = state.currentModelInfo) {
null -> NoModelCard()
else -> ModelInfoCard(
version = modelInfo.version,
size = modelInfo.size,
createdAtTime = modelInfo.createdAt,
lastUpdateTime = modelInfo.lastUpdateTime
)
}
TextField(
modifier = Modifier.fillMaxWidth(),
value = state.apiKey,
onValueChange = { component.onEvent(SettingsUiEvent.UpdateApiKey(it)) },
label = {
Text(
text = stringResource(Res.string.api_key_label),
fontWeight = FontWeight.Bold
)
},
singleLine = true,
maxLines = 1,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
}
),
colors = TextFieldDefaults.colors(
unfocusedContainerColor = MaterialTheme.colorScheme.secondaryContainer,
focusedContainerColor = MaterialTheme.colorScheme.secondaryContainer,
errorContainerColor = MaterialTheme.colorScheme.secondaryContainer,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent
),
shape = RoundedCornerShape(36.dp),
trailingIcon = {
if (state.apiKey.isNotEmpty()) {
IconButton(
onClick = { component.onEvent(SettingsUiEvent.UpdateApiKey("")) }
) {
Icon(
painter = painterResource(Res.drawable.cancel),
contentDescription = null
)
}
}
},
isError = state.apiKey.isEmpty()
)
when (val loadingState = state.loadingState) {
is LoadingState.Success -> ModelInfoButton(
status = loadingState.status,
isEnabled = true,
onClick = {
when (loadingState.status) {
ModelStatus.UpToDate -> component.onEvent(SettingsUiEvent.UpdateModelInfo)
else -> component.onEvent(SettingsUiEvent.LoadLatestModel)
}
}
)
is LoadingState.Error -> ErrorCard(onClick = {
when (loadingState.status) {
ModelStatus.UpToDate -> component.onEvent(SettingsUiEvent.UpdateModelInfo)
else -> component.onEvent(SettingsUiEvent.LoadLatestModel)
}
})
is LoadingState.Loading -> LinearProgressIndicator(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)
)
is LoadingState.ApiKeyNotSet,
is LoadingState.Idle -> Unit
}
}
}
}
private fun Modifier.hideKeyboardOnTap(focusManager: FocusManager): Modifier =
pointerInput(focusManager) {
detectTapGestures {
focusManager.clearFocus()
}
}
@@ -0,0 +1,56 @@
package ru.tanexc.hemoanalysis.settings.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearWavyProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import hemoanalysis.feature.settings.generated.resources.Res
import hemoanalysis.feature.settings.generated.resources.model_info_error
import hemoanalysis.feature.settings.generated.resources.restart
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
@Composable
fun ErrorCard(
onClick: () -> Unit
) {
Button(
modifier = Modifier.fillMaxWidth(),
shape = CircleShape,
onClick = onClick,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Box(
modifier = Modifier.fillMaxWidth().padding(8.dp),
) {
Icon(
painter = painterResource(Res.drawable.restart),
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.align(Alignment.CenterStart)
)
Text(
text = stringResource(Res.string.model_info_error),
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.align(Alignment.Center)
)
}
}
}
@@ -0,0 +1,79 @@
package ru.tanexc.hemoanalysis.settings.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearWavyProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import hemoanalysis.feature.settings.generated.resources.Res
import hemoanalysis.feature.settings.generated.resources.download
import hemoanalysis.feature.settings.generated.resources.model_info_check_for_updates
import hemoanalysis.feature.settings.generated.resources.model_info_download_model
import hemoanalysis.feature.settings.generated.resources.model_info_error
import hemoanalysis.feature.settings.generated.resources.model_info_update_available
import hemoanalysis.feature.settings.generated.resources.restart
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import ru.tanexc.hemoanalysis.settings.presentation.ModelStatus
@Composable
fun ModelInfoButton(
status: ModelStatus,
isEnabled: Boolean,
onClick: () -> Unit
) {
Button(
modifier = Modifier.fillMaxWidth(),
shape = CircleShape,
enabled = isEnabled,
onClick = onClick,
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary),
) {
val (text, icon) = when (status) {
ModelStatus.NotPresented -> stringResource(Res.string.model_info_download_model) to painterResource(
Res.drawable.download
)
ModelStatus.OutOfDate -> stringResource(Res.string.model_info_update_available) to painterResource(
Res.drawable.download
)
ModelStatus.UpToDate -> stringResource(Res.string.model_info_check_for_updates) to painterResource(
Res.drawable.restart
)
}
Box(
modifier = Modifier.fillMaxWidth().padding(8.dp),
) {
Icon(
painter = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.align(Alignment.CenterStart)
)
Text(
text = text,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.align(Alignment.Center)
)
}
}
}
@@ -0,0 +1,112 @@
package ru.tanexc.hemoanalysis.settings.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import hemoanalysis.feature.settings.generated.resources.Res
import hemoanalysis.feature.settings.generated.resources.model_info_created_at
import hemoanalysis.feature.settings.generated.resources.model_info_last_update
import hemoanalysis.feature.settings.generated.resources.model_info_size_content
import hemoanalysis.feature.settings.generated.resources.model_info_size_title
import hemoanalysis.feature.settings.generated.resources.model_info_title
import hemoanalysis.feature.settings.generated.resources.model_info_version
import org.jetbrains.compose.resources.stringResource
import ru.tanexc.hemoanalysis.domain.model.Mb
import ru.tanexc.hemoanalysis.domain.model.Size
import kotlin.math.round
@Composable
fun ModelInfoCard(
version: String,
size: Size,
createdAtTime: String,
lastUpdateTime: String,
) {
Card(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(36.dp)),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Column(
modifier = Modifier
.padding(22.dp)
.fillMaxWidth(),
) {
Text(
modifier = Modifier.fillMaxWidth(),
text = stringResource(Res.string.model_info_title),
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
textAlign = TextAlign.Center
)
Spacer(Modifier.size(22.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(Res.string.model_info_version),
fontWeight = FontWeight.Bold
)
Text(text = version)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(Res.string.model_info_size_title),
fontWeight = FontWeight.Bold
)
Text(text = "${size.formattedMb()} " + stringResource(Res.string.model_info_size_content))
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(Res.string.model_info_created_at),
fontWeight = FontWeight.Bold
)
Text(text = createdAtTime)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(Res.string.model_info_last_update),
fontWeight = FontWeight.Bold
)
Text(text = lastUpdateTime)
}
}
}
}
private fun Size.formattedMb(): String {
val rounded = round(Mb * 10) / 10
return rounded.toString()
}
@@ -0,0 +1,57 @@
package ru.tanexc.hemoanalysis.settings.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import hemoanalysis.feature.settings.generated.resources.Res
import hemoanalysis.feature.settings.generated.resources.database_off
import hemoanalysis.feature.settings.generated.resources.model_info_no_model
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
@Composable
fun NoModelCard() {
Card(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(36.dp)),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer)
) {
Column(
modifier = Modifier
.padding(22.dp)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
modifier = Modifier.size(56.dp),
painter = painterResource(Res.drawable.database_off),
tint = MaterialTheme.colorScheme.onSecondaryContainer,
contentDescription = null,
)
Text(
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
text = stringResource(Res.string.model_info_no_model),
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
}