diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..aa724b7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,15 @@
+*.iml
+.gradle
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..26d3352
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,3 @@
+# Default ignored files
+/shelf/
+/workspace.xml
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
new file mode 100644
index 0000000..b589d56
--- /dev/null
+++ b/.idea/compiler.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
new file mode 100644
index 0000000..ae388c2
--- /dev/null
+++ b/.idea/gradle.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 0000000..103e00c
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
new file mode 100644
index 0000000..8d81632
--- /dev/null
+++ b/.idea/kotlinc.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..8978d23
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..94a25f7
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/README.md
@@ -0,0 +1 @@
+
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/app/build.gradle b/app/build.gradle
new file mode 100644
index 0000000..21a33e6
--- /dev/null
+++ b/app/build.gradle
@@ -0,0 +1,128 @@
+plugins {
+ id 'com.android.application'
+ id 'org.jetbrains.kotlin.android'
+ id 'com.google.dagger.hilt.android'
+ id 'com.google.devtools.ksp'
+}
+
+android {
+ namespace 'com.scrollz.partrecognizer'
+ compileSdk 34
+
+ defaultConfig {
+ applicationId "com.scrollz.partrecognizer"
+ minSdk 21
+ targetSdk 34
+ versionCode 1
+ versionName "1.0"
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary true
+ }
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = '1.8'
+ }
+ buildFeatures {
+ compose true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion '1.5.8'
+ }
+}
+
+dependencies {
+
+ implementation 'androidx.core:core-ktx:1.12.0'
+ implementation 'androidx.activity:activity-compose:1.8.2'
+
+ // Test
+ testImplementation 'junit:junit:4.13.2'
+ androidTestImplementation 'androidx.test.ext:junit:1.1.5'
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
+ androidTestImplementation platform("androidx.compose:compose-bom:$compose_version")
+ androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
+ debugImplementation 'androidx.compose.ui:ui-tooling'
+ debugImplementation 'androidx.compose.ui:ui-test-manifest'
+
+ // Compose
+ implementation platform("androidx.compose:compose-bom:$compose_version")
+ implementation 'androidx.compose.ui:ui'
+ implementation 'androidx.compose.ui:ui-graphics'
+ implementation 'androidx.compose.ui:ui-tooling-preview'
+ implementation 'androidx.compose.material3:material3'
+ implementation 'androidx.compose.material:material-icons-extended'
+
+ // Lifecycle
+ implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
+ implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version"
+ implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version"
+
+ // Coroutines
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
+
+ // Dagger Hilt
+ implementation "com.google.dagger:hilt-android:$hilt_version"
+ ksp "com.google.dagger:hilt-compiler:$hilt_version"
+
+ // Navigation
+ implementation 'androidx.navigation:navigation-compose:2.7.6'
+ implementation 'androidx.hilt:hilt-navigation-compose:1.1.0'
+
+ // OkHTTP
+ implementation "com.squareup.okhttp3:okhttp:$okHttp_version"
+ implementation "com.squareup.okhttp3:logging-interceptor:$okHttp_version"
+
+ // Retrofit
+ implementation "com.squareup.retrofit2:retrofit:$retrofit_version"
+ implementation "com.squareup.retrofit2:converter-moshi:$retrofit_version"
+
+ // Moshi
+ implementation "com.squareup.moshi:moshi-kotlin:$moshi_version"
+ ksp "com.squareup.moshi:moshi-kotlin-codegen:$moshi_version"
+
+ // Room
+ implementation "androidx.room:room-runtime:$room_version"
+ implementation "androidx.room:room-ktx:$room_version"
+ implementation "androidx.room:room-paging:$room_version"
+ annotationProcessor "androidx.room:room-compiler:$room_version"
+ ksp "androidx.room:room-compiler:$room_version"
+
+ // CameraX
+ implementation "androidx.camera:camera-core:$camerax_version"
+ implementation "androidx.camera:camera-camera2:$camerax_version"
+ implementation "androidx.camera:camera-lifecycle:$camerax_version"
+ implementation "androidx.camera:camera-view:$camerax_version"
+ implementation "androidx.camera:camera-extensions:$camerax_version"
+ implementation 'com.google.mlkit:vision-common:17.3.0'
+
+ // ZXing
+ implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
+ implementation 'com.google.zxing:core:3.5.2'
+
+ // Coil Compose
+ implementation 'io.coil-kt:coil-compose:2.5.0'
+
+ // Immutable Collections
+ implementation 'org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7'
+
+ // Paging
+ implementation "androidx.paging:paging-runtime-ktx:$paging_version"
+ implementation "androidx.paging:paging-compose:$paging_version"
+
+ // Datastore Preferences
+ implementation "androidx.datastore:datastore-preferences:1.0.0"
+
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/app/release/output-metadata.json b/app/release/output-metadata.json
new file mode 100644
index 0000000..4d8289f
--- /dev/null
+++ b/app/release/output-metadata.json
@@ -0,0 +1,20 @@
+{
+ "version": 3,
+ "artifactType": {
+ "type": "APK",
+ "kind": "Directory"
+ },
+ "applicationId": "com.scrollz.partrecognizer",
+ "variantName": "release",
+ "elements": [
+ {
+ "type": "SINGLE",
+ "filters": [],
+ "attributes": [],
+ "versionCode": 1,
+ "versionName": "1.0",
+ "outputFile": "app-release.apk"
+ }
+ ],
+ "elementType": "File"
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/scrollz/partrecognizer/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/scrollz/partrecognizer/ExampleInstrumentedTest.kt
new file mode 100644
index 0000000..c4f901b
--- /dev/null
+++ b/app/src/androidTest/java/com/scrollz/partrecognizer/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package com.scrollz.partrecognizer
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.scrollz.partrecognizer", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..2840007
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/icon-playstore.png b/app/src/main/icon-playstore.png
new file mode 100644
index 0000000..530e9bb
Binary files /dev/null and b/app/src/main/icon-playstore.png differ
diff --git a/app/src/main/java/com/scrollz/partrecognizer/PartRecognizerApp.kt b/app/src/main/java/com/scrollz/partrecognizer/PartRecognizerApp.kt
new file mode 100644
index 0000000..6524e5c
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/PartRecognizerApp.kt
@@ -0,0 +1,7 @@
+package com.scrollz.partrecognizer
+
+import android.app.Application
+import dagger.hilt.android.HiltAndroidApp
+
+@HiltAndroidApp
+class PartRecognizerApp : Application()
diff --git a/app/src/main/java/com/scrollz/partrecognizer/data/local/Dao.kt b/app/src/main/java/com/scrollz/partrecognizer/data/local/Dao.kt
new file mode 100644
index 0000000..96a7102
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/data/local/Dao.kt
@@ -0,0 +1,30 @@
+package com.scrollz.partrecognizer.data.local
+
+import androidx.paging.PagingSource
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.Query
+import com.scrollz.partrecognizer.domain.model.Report
+
+@Dao
+interface Dao {
+
+ @Query("SELECT * FROM report")
+ fun getReports(): PagingSource
+
+ @Query("SELECT * FROM report WHERE id = :id")
+ suspend fun getReport(id: Long): Report?
+
+ @Query("SELECT resultImagePath FROM report WHERE id in (:ids)")
+ suspend fun getImagePaths(ids: Set): List
+
+ @Insert(entity = Report::class)
+ suspend fun insertReport(report: Report): Long
+
+ @Query("DELETE FROM report WHERE id = :id")
+ suspend fun deleteReport(id: Long)
+
+ @Query("DELETE FROM report WHERE id in (:ids)")
+ suspend fun deleteReports(ids: Set)
+
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/data/local/DataBase.kt b/app/src/main/java/com/scrollz/partrecognizer/data/local/DataBase.kt
new file mode 100644
index 0000000..c370e7a
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/data/local/DataBase.kt
@@ -0,0 +1,13 @@
+package com.scrollz.partrecognizer.data.local
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+import com.scrollz.partrecognizer.domain.model.Report
+
+@Database(entities = [Report::class], version = 2)
+abstract class DataBase: RoomDatabase() {
+ abstract fun Dao(): Dao
+ companion object {
+ const val DATABASE_NAME = "gear_vision"
+ }
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/data/remote/Api.kt b/app/src/main/java/com/scrollz/partrecognizer/data/remote/Api.kt
new file mode 100644
index 0000000..5d97e3d
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/data/remote/Api.kt
@@ -0,0 +1,15 @@
+package com.scrollz.partrecognizer.data.remote
+
+import com.scrollz.partrecognizer.domain.model.Response
+import com.scrollz.partrecognizer.domain.model.Request
+import retrofit2.http.Body
+import retrofit2.http.POST
+
+interface Api {
+
+ @POST("/api/recognizer")
+ suspend fun analyzeImage(
+ @Body base64Image: Request
+ ): Response
+
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/data/repository/ImageRepositoryImpl.kt b/app/src/main/java/com/scrollz/partrecognizer/data/repository/ImageRepositoryImpl.kt
new file mode 100644
index 0000000..5a5cf4f
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/data/repository/ImageRepositoryImpl.kt
@@ -0,0 +1,52 @@
+package com.scrollz.partrecognizer.data.repository
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.util.Base64
+import androidx.camera.core.ImageProxy
+import com.google.mlkit.vision.common.InputImage
+import com.google.mlkit.vision.common.internal.ImageConvertUtils
+import com.scrollz.partrecognizer.domain.repository.ImageRepository
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.io.FileOutputStream
+import javax.inject.Inject
+
+class ImageRepositoryImpl @Inject constructor(
+ @ApplicationContext private val appContext: Context,
+ private val dispatcher: CoroutineDispatcher = Dispatchers.IO
+): ImageRepository {
+
+ @androidx.annotation.OptIn(androidx.camera.core.ExperimentalGetImage::class)
+ override suspend fun imageProxyToBase64(imageProxy: ImageProxy): Result = runCatching {
+ withContext(dispatcher) {
+ val inputImage = InputImage.fromMediaImage(
+ imageProxy.image!!,
+ imageProxy.imageInfo.rotationDegrees
+ )
+ val bitmap = ImageConvertUtils.getInstance().getUpRightBitmap(inputImage)
+ val outputStream = ByteArrayOutputStream()
+ bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
+ Base64.encodeToString(outputStream.toByteArray(), Base64.DEFAULT) ?: throw Exception()
+ }
+ }
+
+ override suspend fun saveImage(imageBase64: String, name: String): Result {
+ return withContext(dispatcher) {
+ runCatching {
+ val file = File(appContext.filesDir, name)
+ FileOutputStream(file).use { it.write(Base64.decode(imageBase64, Base64.DEFAULT)) }
+ file.path
+ }
+ }
+ }
+
+ override suspend fun deleteImage(imagePath: String): Result = runCatching {
+ withContext(dispatcher) { File(imagePath).delete() }
+ }
+
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/data/repository/NetworkRepositoryImpl.kt b/app/src/main/java/com/scrollz/partrecognizer/data/repository/NetworkRepositoryImpl.kt
new file mode 100644
index 0000000..4210b7d
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/data/repository/NetworkRepositoryImpl.kt
@@ -0,0 +1,21 @@
+package com.scrollz.partrecognizer.data.repository
+
+import com.scrollz.partrecognizer.data.remote.Api
+import com.scrollz.partrecognizer.domain.model.Request
+import com.scrollz.partrecognizer.domain.model.Response
+import com.scrollz.partrecognizer.domain.repository.NetworkRepository
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+
+class NetworkRepositoryImpl @Inject constructor(
+ private val api: Api,
+ private val dispatcher: CoroutineDispatcher = Dispatchers.IO
+): NetworkRepository {
+
+ override suspend fun analyzeImage(base64Image: String): Result = runCatching {
+ withContext(dispatcher) { api.analyzeImage(Request(base64Image)) }
+ }
+
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/data/repository/PreferencesRepositoryImpl.kt b/app/src/main/java/com/scrollz/partrecognizer/data/repository/PreferencesRepositoryImpl.kt
new file mode 100644
index 0000000..3e5c20e
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/data/repository/PreferencesRepositoryImpl.kt
@@ -0,0 +1,79 @@
+package com.scrollz.partrecognizer.data.repository
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.intPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+import com.scrollz.partrecognizer.domain.repository.PreferencesRepository
+import com.scrollz.partrecognizer.domain.model.BaseUrl
+import com.scrollz.partrecognizer.domain.model.Theme
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+
+class PreferencesRepositoryImpl @Inject constructor(
+ private val dataStore: DataStore,
+ private val dispatcher: CoroutineDispatcher = Dispatchers.IO
+): PreferencesRepository {
+ private object PreferencesKeys {
+ val URL_DOMAIN = stringPreferencesKey("url_domain")
+ val URL_PORT = stringPreferencesKey("url_port")
+ val THEME = intPreferencesKey("theme")
+ }
+
+ override val theme: Flow = dataStore.data
+ .catch { Theme.System }
+ .map { preferences ->
+ when (preferences[PreferencesKeys.THEME]) {
+ Theme.Dark.value -> Theme.Dark
+ Theme.Light.value -> Theme.Light
+ else -> Theme.System
+ }
+ }
+ .flowOn(dispatcher)
+
+ override val baseUrl: Flow = dataStore.data
+ .catch { BaseUrl() }
+ .map { preferences ->
+ BaseUrl(
+ domain = preferences[PreferencesKeys.URL_DOMAIN] ?: "",
+ port = preferences[PreferencesKeys.URL_PORT] ?: ""
+ )
+ }
+ .flowOn(dispatcher)
+
+ override val baseUrlRaw: BaseUrl get() = runBlocking(dispatcher) {
+ dataStore.data.first().let { preferences ->
+ BaseUrl(
+ domain = preferences[PreferencesKeys.URL_DOMAIN] ?: "",
+ port = preferences[PreferencesKeys.URL_PORT] ?: ""
+ )
+ }
+ }
+
+ override suspend fun updateTheme(theme: Theme): Result = runCatching {
+ withContext(dispatcher) {
+ dataStore.edit { preferences ->
+ preferences[PreferencesKeys.THEME] = theme.value
+ }
+ }
+ }
+
+ override suspend fun updateBaseUrl(domain: String, port: String): Result = runCatching {
+ withContext(dispatcher) {
+ dataStore.edit { preferences ->
+ preferences[PreferencesKeys.URL_DOMAIN] = domain
+ preferences[PreferencesKeys.URL_PORT] = port
+ }
+ }
+ }
+
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/data/repository/ReportRepositoryImpl.kt b/app/src/main/java/com/scrollz/partrecognizer/data/repository/ReportRepositoryImpl.kt
new file mode 100644
index 0000000..c41aadb
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/data/repository/ReportRepositoryImpl.kt
@@ -0,0 +1,47 @@
+package com.scrollz.partrecognizer.data.repository
+
+import androidx.paging.Pager
+import androidx.paging.PagingConfig
+import androidx.paging.PagingData
+import com.scrollz.partrecognizer.data.local.DataBase
+import com.scrollz.partrecognizer.domain.model.Report
+import com.scrollz.partrecognizer.domain.repository.ReportRepository
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+
+class ReportRepositoryImpl @Inject constructor(
+ db: DataBase,
+ private val dispatcher: CoroutineDispatcher = Dispatchers.IO
+): ReportRepository {
+
+ private val dao = db.Dao()
+
+ override fun getReports(): Flow> = Pager(
+ PagingConfig(pageSize = 10, prefetchDistance = 20)
+ ) { dao.getReports() }.flow
+
+ override suspend fun getReport(id: Long): Result = withContext(dispatcher) {
+ val report = dao.getReport(id)
+ if (report != null) Result.success(report) else Result.failure(NoSuchElementException())
+ }
+
+ override suspend fun getImagePaths(ids: Set): List = withContext(dispatcher) {
+ dao.getImagePaths(ids)
+ }
+
+ override suspend fun insertReport(report: Report): Long = withContext(dispatcher) {
+ dao.insertReport(report)
+ }
+
+ override suspend fun deleteReport(id: Long) = withContext(dispatcher) {
+ dao.deleteReport(id)
+ }
+
+ override suspend fun deleteReports(ids: Set) = withContext(dispatcher) {
+ dao.deleteReports(ids)
+ }
+
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/di/DataModule.kt b/app/src/main/java/com/scrollz/partrecognizer/di/DataModule.kt
new file mode 100644
index 0000000..7931ada
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/di/DataModule.kt
@@ -0,0 +1,44 @@
+package com.scrollz.partrecognizer.di
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.preferencesDataStoreFile
+import androidx.room.Room
+import com.scrollz.partrecognizer.data.local.DataBase
+import com.scrollz.partrecognizer.domain.repository.PreferencesRepository
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object DataModule {
+
+ @Provides
+ @Singleton
+ fun provideDataBase(@ApplicationContext appContext: Context): DataBase {
+ return Room.databaseBuilder(
+ appContext,
+ DataBase::class.java,
+ DataBase.DATABASE_NAME
+ ).fallbackToDestructiveMigration().build()
+ }
+
+ @Provides
+ @Singleton
+ fun providePreferencesDataStore(
+ @ApplicationContext appContext: Context
+ ): DataStore {
+ return PreferenceDataStoreFactory.create(
+ produceFile = {
+ appContext.preferencesDataStoreFile(PreferencesRepository.PREFERENCES_NAME)
+ }
+ )
+ }
+
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/di/NetworkModule.kt b/app/src/main/java/com/scrollz/partrecognizer/di/NetworkModule.kt
new file mode 100644
index 0000000..7e01205
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/di/NetworkModule.kt
@@ -0,0 +1,31 @@
+package com.scrollz.partrecognizer.di
+
+import com.squareup.moshi.Moshi
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import okhttp3.OkHttpClient
+import retrofit2.converter.moshi.MoshiConverterFactory
+import java.util.concurrent.TimeUnit
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object NetworkModule {
+
+ @Provides
+ @Singleton
+ fun provideOkHttpClient(): OkHttpClient {
+ return OkHttpClient.Builder()
+ .connectTimeout(5, TimeUnit.SECONDS)
+ .build()
+ }
+
+ @Provides
+ @Singleton
+ fun provideMoshiConverterFactory(): MoshiConverterFactory {
+ return MoshiConverterFactory.create(Moshi.Builder().build())
+ }
+
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/di/RepositoryModule.kt b/app/src/main/java/com/scrollz/partrecognizer/di/RepositoryModule.kt
new file mode 100644
index 0000000..70f5881
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/di/RepositoryModule.kt
@@ -0,0 +1,49 @@
+package com.scrollz.partrecognizer.di
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import com.scrollz.partrecognizer.data.local.DataBase
+import com.scrollz.partrecognizer.data.repository.ImageRepositoryImpl
+import com.scrollz.partrecognizer.data.repository.PreferencesRepositoryImpl
+import com.scrollz.partrecognizer.data.repository.ReportRepositoryImpl
+import com.scrollz.partrecognizer.domain.repository.ImageRepository
+import com.scrollz.partrecognizer.domain.repository.PreferencesRepository
+import com.scrollz.partrecognizer.domain.repository.ReportRepository
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object RepositoryModule{
+
+ @Provides
+ @Singleton
+ fun providePreferencesRepository(
+ dataStore: DataStore
+ ): PreferencesRepository {
+ return PreferencesRepositoryImpl(dataStore)
+ }
+
+ @Provides
+ @Singleton
+ fun provideImageRepository(
+ @ApplicationContext appContext: Context
+ ): ImageRepository {
+ return ImageRepositoryImpl(appContext)
+ }
+
+ @Provides
+ @Singleton
+ fun provideReportRepository(
+ db: DataBase
+ ): ReportRepository {
+ return ReportRepositoryImpl(db)
+ }
+
+}
+
diff --git a/app/src/main/java/com/scrollz/partrecognizer/di/ScannerModule.kt b/app/src/main/java/com/scrollz/partrecognizer/di/ScannerModule.kt
new file mode 100644
index 0000000..f206a23
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/di/ScannerModule.kt
@@ -0,0 +1,59 @@
+package com.scrollz.partrecognizer.di
+
+import com.scrollz.partrecognizer.data.remote.Api
+import com.scrollz.partrecognizer.data.repository.NetworkRepositoryImpl
+import com.scrollz.partrecognizer.domain.repository.ImageRepository
+import com.scrollz.partrecognizer.domain.repository.NetworkRepository
+import com.scrollz.partrecognizer.domain.repository.PreferencesRepository
+import com.scrollz.partrecognizer.domain.use_cases.AnalyzeImageUseCase
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.components.ViewModelComponent
+import dagger.hilt.android.scopes.ViewModelScoped
+import okhttp3.OkHttpClient
+import retrofit2.Retrofit
+import retrofit2.converter.moshi.MoshiConverterFactory
+
+@Module
+@InstallIn(ViewModelComponent::class)
+object ScannerModule {
+
+ @Provides
+ @ViewModelScoped
+ fun provideRetrofit(
+ preferencesRepository: PreferencesRepository,
+ okHttpClient: OkHttpClient,
+ moshiConverterFactory: MoshiConverterFactory
+ ): Retrofit {
+ return Retrofit.Builder()
+ .baseUrl(preferencesRepository.baseUrlRaw.url)
+ .client(okHttpClient)
+ .addConverterFactory(moshiConverterFactory)
+ .build()
+ }
+
+ @Provides
+ @ViewModelScoped
+ fun provideApi(retrofit: Retrofit): Api {
+ return retrofit.create(Api::class.java)
+ }
+
+ @Provides
+ @ViewModelScoped
+ fun provideNetworkRepository(
+ api: Api
+ ): NetworkRepository {
+ return NetworkRepositoryImpl(api)
+ }
+
+ @Provides
+ @ViewModelScoped
+ fun provideAnalyzeImageUseCase(
+ imageRepository: ImageRepository,
+ networkRepository: NetworkRepository
+ ): AnalyzeImageUseCase {
+ return AnalyzeImageUseCase(imageRepository, networkRepository)
+ }
+
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/di/UseCaseModule.kt b/app/src/main/java/com/scrollz/partrecognizer/di/UseCaseModule.kt
new file mode 100644
index 0000000..5148913
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/di/UseCaseModule.kt
@@ -0,0 +1,108 @@
+package com.scrollz.partrecognizer.di
+
+import com.scrollz.partrecognizer.domain.repository.ImageRepository
+import com.scrollz.partrecognizer.domain.repository.PreferencesRepository
+import com.scrollz.partrecognizer.domain.repository.ReportRepository
+import com.scrollz.partrecognizer.domain.use_cases.DeleteImageUseCase
+import com.scrollz.partrecognizer.domain.use_cases.DeleteReportUseCase
+import com.scrollz.partrecognizer.domain.use_cases.DeleteReportsUseCase
+import com.scrollz.partrecognizer.domain.use_cases.GetReportUseCase
+import com.scrollz.partrecognizer.domain.use_cases.GetReportsUseCase
+import com.scrollz.partrecognizer.domain.use_cases.GetSettingsUseCase
+import com.scrollz.partrecognizer.domain.use_cases.SaveBaseUrlUseCase
+import com.scrollz.partrecognizer.domain.use_cases.SaveReportUseCase
+import com.scrollz.partrecognizer.domain.use_cases.SaveImageUseCase
+import com.scrollz.partrecognizer.domain.use_cases.SwitchThemeUseCase
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object UseCaseModule {
+
+ @Provides
+ @Singleton
+ fun provideSaveImageUseCase(
+ imageRepository: ImageRepository
+ ): SaveImageUseCase {
+ return SaveImageUseCase(imageRepository)
+ }
+
+ @Provides
+ @Singleton
+ fun provideDeleteImageUseCase(
+ imageRepository: ImageRepository
+ ): DeleteImageUseCase {
+ return DeleteImageUseCase(imageRepository)
+ }
+
+ @Provides
+ @Singleton
+ fun provideGetReportsUseCase(
+ reportRepository: ReportRepository
+ ): GetReportsUseCase {
+ return GetReportsUseCase(reportRepository)
+ }
+
+ @Provides
+ @Singleton
+ fun provideGetReportUseCase(
+ reportRepository: ReportRepository
+ ): GetReportUseCase {
+ return GetReportUseCase(reportRepository)
+ }
+
+ @Provides
+ @Singleton
+ fun provideSaveReportUseCase(
+ reportRepository: ReportRepository
+ ): SaveReportUseCase {
+ return SaveReportUseCase(reportRepository)
+ }
+
+ @Provides
+ @Singleton
+ fun provideDeleteReportUseCase(
+ reportRepository: ReportRepository,
+ deleteImageUseCase: DeleteImageUseCase
+ ): DeleteReportUseCase {
+ return DeleteReportUseCase(reportRepository, deleteImageUseCase)
+ }
+
+ @Provides
+ @Singleton
+ fun provideDeleteReportsUseCase(
+ reportRepository: ReportRepository,
+ deleteImageUseCase: DeleteImageUseCase
+ ): DeleteReportsUseCase {
+ return DeleteReportsUseCase(reportRepository, deleteImageUseCase)
+ }
+
+ @Provides
+ @Singleton
+ fun provideGetSettingsUseCase(
+ preferencesRepository: PreferencesRepository
+ ): GetSettingsUseCase {
+ return GetSettingsUseCase(preferencesRepository)
+ }
+
+ @Provides
+ @Singleton
+ fun provideSwitchThemeUseCase(
+ preferencesRepository: PreferencesRepository
+ ): SwitchThemeUseCase {
+ return SwitchThemeUseCase(preferencesRepository)
+ }
+
+ @Provides
+ @Singleton
+ fun provideSaveBaseUrlUseCase(
+ preferencesRepository: PreferencesRepository
+ ): SaveBaseUrlUseCase {
+ return SaveBaseUrlUseCase(preferencesRepository)
+ }
+
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/domain/model/BaseUrl.kt b/app/src/main/java/com/scrollz/partrecognizer/domain/model/BaseUrl.kt
new file mode 100644
index 0000000..ff36f3c
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/domain/model/BaseUrl.kt
@@ -0,0 +1,13 @@
+package com.scrollz.partrecognizer.domain.model
+
+data class BaseUrl(
+ val domain: String = "",
+ val port: String = ""
+) {
+ val url: String
+ get() {
+ val domain = domain.ifBlank { "0.0.0.0" }
+ val port = if (port.isNotBlank()) ":$port" else ""
+ return "http://$domain$port"
+ }
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/domain/model/Report.kt b/app/src/main/java/com/scrollz/partrecognizer/domain/model/Report.kt
new file mode 100644
index 0000000..bc3a627
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/domain/model/Report.kt
@@ -0,0 +1,14 @@
+package com.scrollz.partrecognizer.domain.model
+
+import androidx.compose.runtime.Immutable
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Immutable
+@Entity(tableName = "report")
+data class Report(
+ @PrimaryKey(autoGenerate = true) val id: Long? = null,
+ val detectedDetails: String,
+ val dateTime: String,
+ val resultImagePath: String?
+)
diff --git a/app/src/main/java/com/scrollz/partrecognizer/domain/model/Request.kt b/app/src/main/java/com/scrollz/partrecognizer/domain/model/Request.kt
new file mode 100644
index 0000000..f4fcdc1
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/domain/model/Request.kt
@@ -0,0 +1,10 @@
+package com.scrollz.partrecognizer.domain.model
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = true)
+data class Request(
+ @Json(name = "photoBase64")
+ val photoBase64: String
+)
diff --git a/app/src/main/java/com/scrollz/partrecognizer/domain/model/Response.kt b/app/src/main/java/com/scrollz/partrecognizer/domain/model/Response.kt
new file mode 100644
index 0000000..64f20a3
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/domain/model/Response.kt
@@ -0,0 +1,16 @@
+package com.scrollz.partrecognizer.domain.model
+
+import androidx.compose.runtime.Immutable
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@Immutable
+@JsonClass(generateAdapter = true)
+data class Response(
+ @Json(name = "tagged_image_base64")
+ val resultImage: String,
+ @Json(name = "recognized_parts")
+ val detectedDetails: List,
+ @Json(name = "datetime")
+ val dateTime: String
+)
diff --git a/app/src/main/java/com/scrollz/partrecognizer/domain/model/Settings.kt b/app/src/main/java/com/scrollz/partrecognizer/domain/model/Settings.kt
new file mode 100644
index 0000000..763e174
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/domain/model/Settings.kt
@@ -0,0 +1,6 @@
+package com.scrollz.partrecognizer.domain.model
+
+data class Settings(
+ val theme: Theme = Theme.System,
+ val baseUrl: BaseUrl = BaseUrl()
+)
diff --git a/app/src/main/java/com/scrollz/partrecognizer/domain/model/Theme.kt b/app/src/main/java/com/scrollz/partrecognizer/domain/model/Theme.kt
new file mode 100644
index 0000000..af8f721
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/domain/model/Theme.kt
@@ -0,0 +1,20 @@
+package com.scrollz.partrecognizer.domain.model
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
+
+@Immutable
+sealed class Theme(val value: Int) {
+ data object Dark: Theme(-1)
+ data object Light: Theme(1)
+ data object System: Theme(0)
+
+ @Composable
+ fun darkTheme(): Boolean = when (this) {
+ Dark -> true
+ Light -> false
+ System -> isSystemInDarkTheme()
+ }
+
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/domain/repository/ImageRepository.kt b/app/src/main/java/com/scrollz/partrecognizer/domain/repository/ImageRepository.kt
new file mode 100644
index 0000000..97af392
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/domain/repository/ImageRepository.kt
@@ -0,0 +1,13 @@
+package com.scrollz.partrecognizer.domain.repository
+
+import androidx.camera.core.ImageProxy
+
+interface ImageRepository {
+
+ suspend fun imageProxyToBase64(imageProxy: ImageProxy): Result
+
+ suspend fun saveImage(imageBase64: String, name: String): Result
+
+ suspend fun deleteImage(imagePath: String): Result
+
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/domain/repository/NetworkRepository.kt b/app/src/main/java/com/scrollz/partrecognizer/domain/repository/NetworkRepository.kt
new file mode 100644
index 0000000..68fff5f
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/domain/repository/NetworkRepository.kt
@@ -0,0 +1,9 @@
+package com.scrollz.partrecognizer.domain.repository
+
+import com.scrollz.partrecognizer.domain.model.Response
+
+interface NetworkRepository {
+
+ suspend fun analyzeImage(base64Image: String): Result
+
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/domain/repository/PreferencesRepository.kt b/app/src/main/java/com/scrollz/partrecognizer/domain/repository/PreferencesRepository.kt
new file mode 100644
index 0000000..53f99a3
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/domain/repository/PreferencesRepository.kt
@@ -0,0 +1,21 @@
+package com.scrollz.partrecognizer.domain.repository
+
+import com.scrollz.partrecognizer.domain.model.BaseUrl
+import com.scrollz.partrecognizer.domain.model.Theme
+import kotlinx.coroutines.flow.Flow
+
+interface PreferencesRepository {
+
+ val theme: Flow
+ val baseUrl: Flow
+ val baseUrlRaw: BaseUrl
+
+ suspend fun updateTheme(theme: Theme): Result
+
+ suspend fun updateBaseUrl(domain: String, port: String): Result
+
+ companion object {
+ const val PREFERENCES_NAME = "gear_vision_preferences"
+ }
+
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/domain/repository/ReportRepository.kt b/app/src/main/java/com/scrollz/partrecognizer/domain/repository/ReportRepository.kt
new file mode 100644
index 0000000..c6d3f04
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/domain/repository/ReportRepository.kt
@@ -0,0 +1,21 @@
+package com.scrollz.partrecognizer.domain.repository
+
+import androidx.paging.PagingData
+import com.scrollz.partrecognizer.domain.model.Report
+import kotlinx.coroutines.flow.Flow
+
+interface ReportRepository {
+
+ fun getReports(): Flow>
+
+ suspend fun getReport(id: Long): Result
+
+ suspend fun getImagePaths(ids: Set): List
+
+ suspend fun insertReport(report: Report): Long
+
+ suspend fun deleteReport(id: Long)
+
+ suspend fun deleteReports(ids: Set)
+
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/domain/use_cases/AnalyzeImageUseCase.kt b/app/src/main/java/com/scrollz/partrecognizer/domain/use_cases/AnalyzeImageUseCase.kt
new file mode 100644
index 0000000..a0d2329
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/domain/use_cases/AnalyzeImageUseCase.kt
@@ -0,0 +1,18 @@
+package com.scrollz.partrecognizer.domain.use_cases
+
+import androidx.camera.core.ImageProxy
+import com.scrollz.partrecognizer.domain.model.Response
+import com.scrollz.partrecognizer.domain.repository.ImageRepository
+import com.scrollz.partrecognizer.domain.repository.NetworkRepository
+import com.scrollz.partrecognizer.utils.NetworkException
+import javax.inject.Inject
+
+class AnalyzeImageUseCase @Inject constructor(
+ private val imageRepository: ImageRepository,
+ private val networkRepository: NetworkRepository
+) {
+ suspend operator fun invoke(imageProxy: ImageProxy): Result = runCatching {
+ val base64 = imageRepository.imageProxyToBase64(imageProxy).getOrThrow()
+ networkRepository.analyzeImage(base64).getOrElse { e -> throw NetworkException(e.message) }
+ }
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/domain/use_cases/DeleteImageUseCase.kt b/app/src/main/java/com/scrollz/partrecognizer/domain/use_cases/DeleteImageUseCase.kt
new file mode 100644
index 0000000..c7a26d5
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/domain/use_cases/DeleteImageUseCase.kt
@@ -0,0 +1,12 @@
+package com.scrollz.partrecognizer.domain.use_cases
+
+import com.scrollz.partrecognizer.domain.repository.ImageRepository
+import javax.inject.Inject
+
+class DeleteImageUseCase @Inject constructor(
+ private val imageRepository: ImageRepository
+) {
+ suspend operator fun invoke(imagePath: String): Result {
+ return imageRepository.deleteImage(imagePath)
+ }
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/domain/use_cases/DeleteReportUseCase.kt b/app/src/main/java/com/scrollz/partrecognizer/domain/use_cases/DeleteReportUseCase.kt
new file mode 100644
index 0000000..5ce8802
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/domain/use_cases/DeleteReportUseCase.kt
@@ -0,0 +1,14 @@
+package com.scrollz.partrecognizer.domain.use_cases
+
+import com.scrollz.partrecognizer.domain.repository.ReportRepository
+import javax.inject.Inject
+
+class DeleteReportUseCase @Inject constructor(
+ private val reportRepository: ReportRepository,
+ private val deleteImageUseCase: DeleteImageUseCase
+) {
+ suspend operator fun invoke(id: Long, imagePath: String? = null) {
+ reportRepository.deleteReport(id)
+ imagePath?.let { deleteImageUseCase(imagePath) }
+ }
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/domain/use_cases/DeleteReportsUseCase.kt b/app/src/main/java/com/scrollz/partrecognizer/domain/use_cases/DeleteReportsUseCase.kt
new file mode 100644
index 0000000..522465b
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/domain/use_cases/DeleteReportsUseCase.kt
@@ -0,0 +1,17 @@
+package com.scrollz.partrecognizer.domain.use_cases
+
+import com.scrollz.partrecognizer.domain.repository.ReportRepository
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+class DeleteReportsUseCase @Inject constructor(
+ private val reportRepository: ReportRepository,
+ private val deleteImageUseCase: DeleteImageUseCase
+) {
+ suspend operator fun invoke(ids: Set) = coroutineScope {
+ val imagePaths = reportRepository.getImagePaths(ids)
+ launch { reportRepository.deleteReports(ids) }
+ imagePaths.forEach { imagePath -> launch { deleteImageUseCase(imagePath) } }
+ }
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/domain/use_cases/GetReportUseCase.kt b/app/src/main/java/com/scrollz/partrecognizer/domain/use_cases/GetReportUseCase.kt
new file mode 100644
index 0000000..aa27a7a
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/domain/use_cases/GetReportUseCase.kt
@@ -0,0 +1,11 @@
+package com.scrollz.partrecognizer.domain.use_cases
+
+import com.scrollz.partrecognizer.domain.model.Report
+import com.scrollz.partrecognizer.domain.repository.ReportRepository
+import javax.inject.Inject
+
+class GetReportUseCase @Inject constructor(
+ private val reportRepository: ReportRepository
+) {
+ suspend operator fun invoke(id: Long): Result = reportRepository.getReport(id)
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/domain/use_cases/GetReportsUseCase.kt b/app/src/main/java/com/scrollz/partrecognizer/domain/use_cases/GetReportsUseCase.kt
new file mode 100644
index 0000000..4cf5c8f
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/domain/use_cases/GetReportsUseCase.kt
@@ -0,0 +1,13 @@
+package com.scrollz.partrecognizer.domain.use_cases
+
+import androidx.paging.PagingData
+import com.scrollz.partrecognizer.domain.model.Report
+import com.scrollz.partrecognizer.domain.repository.ReportRepository
+import kotlinx.coroutines.flow.Flow
+import javax.inject.Inject
+
+class GetReportsUseCase @Inject constructor(
+ private val reportRepository: ReportRepository
+) {
+ operator fun invoke(): Flow> = reportRepository.getReports()
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/domain/use_cases/GetSettingsUseCase.kt b/app/src/main/java/com/scrollz/partrecognizer/domain/use_cases/GetSettingsUseCase.kt
new file mode 100644
index 0000000..9e2845d
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/domain/use_cases/GetSettingsUseCase.kt
@@ -0,0 +1,18 @@
+package com.scrollz.partrecognizer.domain.use_cases
+
+import com.scrollz.partrecognizer.domain.model.Settings
+import com.scrollz.partrecognizer.domain.repository.PreferencesRepository
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.combine
+import javax.inject.Inject
+
+class GetSettingsUseCase @Inject constructor(
+ private val preferencesRepository: PreferencesRepository
+) {
+ operator fun invoke(): Flow = combine(
+ preferencesRepository.theme,
+ preferencesRepository.baseUrl
+ ) { theme, baseUrl ->
+ Settings(theme, baseUrl)
+ }
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/domain/use_cases/SaveBaseUrlUseCase.kt b/app/src/main/java/com/scrollz/partrecognizer/domain/use_cases/SaveBaseUrlUseCase.kt
new file mode 100644
index 0000000..01ea01b
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/domain/use_cases/SaveBaseUrlUseCase.kt
@@ -0,0 +1,12 @@
+package com.scrollz.partrecognizer.domain.use_cases
+
+import com.scrollz.partrecognizer.domain.repository.PreferencesRepository
+import javax.inject.Inject
+
+class SaveBaseUrlUseCase @Inject constructor(
+ private val preferencesRepository: PreferencesRepository
+) {
+ suspend operator fun invoke(domain: String, port: String): Result {
+ return preferencesRepository.updateBaseUrl(domain, port)
+ }
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/domain/use_cases/SaveImageUseCase.kt b/app/src/main/java/com/scrollz/partrecognizer/domain/use_cases/SaveImageUseCase.kt
new file mode 100644
index 0000000..1033ce6
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/domain/use_cases/SaveImageUseCase.kt
@@ -0,0 +1,12 @@
+package com.scrollz.partrecognizer.domain.use_cases
+
+import com.scrollz.partrecognizer.domain.repository.ImageRepository
+import javax.inject.Inject
+
+class SaveImageUseCase @Inject constructor(
+ private val imageRepository: ImageRepository
+) {
+ suspend operator fun invoke(imageBase64: String, name: String): Result {
+ return imageRepository.saveImage(imageBase64, name)
+ }
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/domain/use_cases/SaveReportUseCase.kt b/app/src/main/java/com/scrollz/partrecognizer/domain/use_cases/SaveReportUseCase.kt
new file mode 100644
index 0000000..732b193
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/domain/use_cases/SaveReportUseCase.kt
@@ -0,0 +1,11 @@
+package com.scrollz.partrecognizer.domain.use_cases
+
+import com.scrollz.partrecognizer.domain.model.Report
+import com.scrollz.partrecognizer.domain.repository.ReportRepository
+import javax.inject.Inject
+
+class SaveReportUseCase @Inject constructor(
+ private val reportRepository: ReportRepository
+) {
+ suspend operator fun invoke(report: Report): Long = reportRepository.insertReport(report)
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/domain/use_cases/SwitchThemeUseCase.kt b/app/src/main/java/com/scrollz/partrecognizer/domain/use_cases/SwitchThemeUseCase.kt
new file mode 100644
index 0000000..adaca73
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/domain/use_cases/SwitchThemeUseCase.kt
@@ -0,0 +1,13 @@
+package com.scrollz.partrecognizer.domain.use_cases
+
+import com.scrollz.partrecognizer.domain.model.Theme
+import com.scrollz.partrecognizer.domain.repository.PreferencesRepository
+import javax.inject.Inject
+
+class SwitchThemeUseCase @Inject constructor(
+ private val preferencesRepository: PreferencesRepository
+) {
+ suspend operator fun invoke(theme: Theme): Result {
+ return preferencesRepository.updateTheme(theme)
+ }
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/MainActivity.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/MainActivity.kt
new file mode 100644
index 0000000..4e7d794
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/MainActivity.kt
@@ -0,0 +1,32 @@
+package com.scrollz.partrecognizer.presentation
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.runtime.getValue
+import androidx.core.view.WindowCompat
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.scrollz.partrecognizer.domain.model.Theme
+import com.scrollz.partrecognizer.domain.repository.PreferencesRepository
+import com.scrollz.partrecognizer.presentation.navigation.Navigation
+import com.scrollz.partrecognizer.ui.theme.PartRecognizerTheme
+import dagger.hilt.android.AndroidEntryPoint
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class MainActivity : ComponentActivity() {
+
+ @Inject
+ lateinit var preferences: PreferencesRepository
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+ super.onCreate(savedInstanceState)
+ setContent {
+ val theme by preferences.theme.collectAsStateWithLifecycle(Theme.System)
+ PartRecognizerTheme(darkTheme = theme.darkTheme()) {
+ Navigation()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/common/ImageView.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/common/ImageView.kt
new file mode 100644
index 0000000..198a15c
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/common/ImageView.kt
@@ -0,0 +1,116 @@
+package com.scrollz.partrecognizer.presentation.common
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.core.AnimationState
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.SpringSpec
+import androidx.compose.animation.core.VectorConverter
+import androidx.compose.animation.core.animateTo
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.rememberTransformableState
+import androidx.compose.foundation.gestures.transformable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clipToBounds
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.layout.LayoutCoordinates
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalContext
+import coil.compose.AsyncImage
+import coil.request.ImageRequest
+
+@Composable
+fun ImageView(
+ modifier: Modifier = Modifier,
+ image: Any?,
+ closeImage: () -> Unit
+) {
+ var layout: LayoutCoordinates? = null
+ var scale by remember { mutableFloatStateOf(1f) }
+ var translation by remember { mutableStateOf(Offset.Zero) }
+ val transformableState = rememberTransformableState { zoomChange, panChange, _ ->
+ scale *= zoomChange
+ translation += panChange.times(scale*0.4f)
+ }
+
+ BackHandler(onBack = closeImage)
+
+ Box(
+ modifier = modifier
+ .background(Color.Black)
+ .fillMaxSize()
+ .clipToBounds()
+ .onGloballyPositioned { layout = it }
+ .transformable(state = transformableState),
+ contentAlignment = Alignment.Center
+ ) {
+ AsyncImage(
+ modifier = Modifier
+ .fillMaxSize()
+ .graphicsLayer(
+ scaleX = scale,
+ scaleY = scale,
+ translationX = translation.x,
+ translationY = translation.y
+ ),
+ model = ImageRequest.Builder(LocalContext.current)
+ .data(image)
+ .build(),
+ contentDescription = null,
+ contentScale = ContentScale.Fit
+ )
+ }
+
+ LaunchedEffect(transformableState.isTransformInProgress) {
+ if (!transformableState.isTransformInProgress) {
+ if (scale < 1f) {
+ val originScale = scale
+ val originTranslation = translation
+ AnimationState(initialValue = 0f).animateTo(
+ targetValue = 1f,
+ animationSpec = SpringSpec(stiffness = Spring.StiffnessMediumLow)
+ ) {
+ scale = originScale + (1 - originScale) * this.value
+ translation = originTranslation * (1 - this.value)
+ }
+ }
+ if (scale > 4f) {
+ val originScale = scale
+ AnimationState(initialValue = 1f).animateTo(
+ targetValue = 0f,
+ animationSpec = SpringSpec(stiffness = Spring.StiffnessMediumLow)
+ ) {
+ scale = 3 + (originScale - 3) * this.value
+ }
+ }
+ if (layout == null) return@LaunchedEffect
+ val maxX = layout!!.size.width * (scale - 1) / 2f
+ val maxY = layout!!.size.height * (scale - 1) / 2f
+ val target = Offset(
+ translation.x.coerceIn(-maxX, maxX),
+ translation.y.coerceIn(-maxY, maxY)
+ )
+ AnimationState(
+ typeConverter = Offset.VectorConverter,
+ initialValue = translation
+ ).animateTo(
+ targetValue = target,
+ animationSpec = SpringSpec(stiffness = Spring.StiffnessMediumLow)
+ ) {
+ translation = this.value
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/common/PermissionDialog.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/common/PermissionDialog.kt
new file mode 100644
index 0000000..d32d7a6
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/common/PermissionDialog.kt
@@ -0,0 +1,63 @@
+package com.scrollz.partrecognizer.presentation.common
+
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.scrollz.partrecognizer.R
+
+@Composable
+fun PermissionDialog(
+ onDismiss: () -> Unit,
+ onConfirm: () -> Unit
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ containerColor = MaterialTheme.colorScheme.background,
+ titleContentColor = MaterialTheme.colorScheme.onBackground,
+ textContentColor = MaterialTheme.colorScheme.onSecondary,
+ tonalElevation = 0.dp,
+ title = {
+ Text(
+ text = stringResource(R.string.dialog_camera),
+ style = MaterialTheme.typography.titleLarge
+ )
+ },
+ text = {
+ Text(
+ text = stringResource(R.string.dialog_camera_body),
+ style = MaterialTheme.typography.bodyMedium
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = onConfirm,
+ colors = ButtonDefaults.textButtonColors(
+ contentColor = MaterialTheme.colorScheme.primary
+ )
+ ) {
+ Text(
+ text = stringResource(R.string.button_open),
+ style = MaterialTheme.typography.displaySmall
+ )
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = onDismiss,
+ colors = ButtonDefaults.textButtonColors(
+ contentColor = MaterialTheme.colorScheme.onBackground
+ )
+ ) {
+ Text(
+ text = stringResource(R.string.button_cancel),
+ style = MaterialTheme.typography.displaySmall
+ )
+ }
+ }
+ )
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/common/ReferenceImagesRow.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/common/ReferenceImagesRow.kt
new file mode 100644
index 0000000..e23dd02
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/common/ReferenceImagesRow.kt
@@ -0,0 +1,59 @@
+package com.scrollz.partrecognizer.presentation.common
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.dp
+import coil.compose.SubcomposeAsyncImage
+import coil.request.ImageRequest
+import com.scrollz.partrecognizer.utils.getImageURL
+
+@Composable
+fun ReferenceImagesRow(
+ modifier: Modifier = Modifier,
+ referenceName: String,
+ viewImage: (String) -> Unit
+) {
+ val context = LocalContext.current
+ val density = LocalDensity.current.density
+ var size by remember { mutableStateOf(0.dp) }
+
+ Row(
+ modifier = Modifier.onGloballyPositioned { coordinates ->
+ size = ((coordinates.size.width / density).dp - 8.dp) / 3
+ },
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ for (i in 1..3) {
+ val url = remember { "$referenceName-$i".getImageURL() }
+ SubcomposeAsyncImage(
+ modifier = modifier
+ .size(size)
+ .weight(1.0f)
+ .clip(RoundedCornerShape(8.dp))
+ .clickable {
+ viewImage(url)
+ },
+ model = ImageRequest.Builder(context)
+ .data(url)
+ .crossfade(true)
+ .build(),
+ contentScale = ContentScale.Crop,
+ contentDescription = null
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/main_screen/MainEvent.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/main_screen/MainEvent.kt
new file mode 100644
index 0000000..9025b18
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/main_screen/MainEvent.kt
@@ -0,0 +1,9 @@
+package com.scrollz.partrecognizer.presentation.main_screen
+
+sealed class MainEvent {
+ data class SelectReport(val id: Long): MainEvent()
+ data object CancelSelecting : MainEvent()
+ data object TogglePermissionDialog: MainEvent()
+ data object ToggleDeleteDialog: MainEvent()
+ data object DeleteReports: MainEvent()
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/main_screen/MainState.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/main_screen/MainState.kt
new file mode 100644
index 0000000..4ca6c2e
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/main_screen/MainState.kt
@@ -0,0 +1,13 @@
+package com.scrollz.partrecognizer.presentation.main_screen
+
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.snapshots.SnapshotStateMap
+
+@Stable
+data class MainState(
+ val isPermissionDialogVisible: Boolean = false,
+ val isDeleteDialogVisible: Boolean = false,
+ val isSelecting: Boolean = false,
+ val selectedReportsIds: SnapshotStateMap = mutableStateMapOf()
+)
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/main_screen/MainViewModel.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/main_screen/MainViewModel.kt
new file mode 100644
index 0000000..5f92dd7
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/main_screen/MainViewModel.kt
@@ -0,0 +1,65 @@
+package com.scrollz.partrecognizer.presentation.main_screen
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.paging.cachedIn
+import com.scrollz.partrecognizer.domain.use_cases.DeleteReportsUseCase
+import com.scrollz.partrecognizer.domain.use_cases.GetReportsUseCase
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class MainViewModel @Inject constructor(
+ getReports: GetReportsUseCase,
+ private val deleteReportsUseCase: DeleteReportsUseCase
+): ViewModel() {
+
+ private val _state = MutableStateFlow(MainState())
+ val state = _state.asStateFlow()
+
+ val reports = getReports().cachedIn(viewModelScope)
+
+ fun onEvent(event: MainEvent) {
+ when (event) {
+ is MainEvent.SelectReport -> selectReport(event.id)
+ is MainEvent.CancelSelecting -> cancelSelecting()
+ is MainEvent.DeleteReports -> deleteReports()
+ is MainEvent.ToggleDeleteDialog -> _state.update {
+ it.copy(isDeleteDialogVisible = !it.isDeleteDialogVisible)
+ }
+ is MainEvent.TogglePermissionDialog -> _state.update {
+ it.copy(isPermissionDialogVisible = !it.isPermissionDialogVisible)
+ }
+ }
+ }
+
+ private fun selectReport(id: Long) {
+ _state.update { state ->
+ if (state.selectedReportsIds.containsKey(id)) {
+ state.selectedReportsIds.remove(id)
+ state.copy(isSelecting = state.selectedReportsIds.isNotEmpty())
+ } else {
+ state.selectedReportsIds[id] = true
+ state.copy(isSelecting = true)
+ }
+ }
+ }
+
+ private fun cancelSelecting() {
+ _state.update { state ->
+ state.selectedReportsIds.clear()
+ state.copy(isSelecting = false, isDeleteDialogVisible = false)
+ }
+ }
+
+ private fun deleteReports() {
+ val ids = _state.value.selectedReportsIds.keys.toSet()
+ viewModelScope.launch { deleteReportsUseCase(ids) }
+ cancelSelecting()
+ }
+
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/main_screen/components/DeleteDialog.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/main_screen/components/DeleteDialog.kt
new file mode 100644
index 0000000..3d45cd8
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/main_screen/components/DeleteDialog.kt
@@ -0,0 +1,58 @@
+package com.scrollz.partrecognizer.presentation.main_screen.components
+
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.scrollz.partrecognizer.R
+
+@Composable
+fun DeleteDialog(
+ selectedItemsCount: Int,
+ onDismiss: () -> Unit,
+ onConfirm: () -> Unit
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ containerColor = MaterialTheme.colorScheme.background,
+ titleContentColor = MaterialTheme.colorScheme.onBackground,
+ textContentColor = MaterialTheme.colorScheme.onSecondary,
+ tonalElevation = 0.dp,
+ title = {
+ Text(
+ text = "${stringResource(R.string.delete_reports)} ($selectedItemsCount)?",
+ style = MaterialTheme.typography.titleLarge
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = onConfirm,
+ colors = ButtonDefaults.textButtonColors(
+ contentColor = MaterialTheme.colorScheme.primary
+ )
+ ) {
+ Text(
+ text = stringResource(R.string.button_delete),
+ style = MaterialTheme.typography.displaySmall
+ )
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = onDismiss,
+ colors = ButtonDefaults.textButtonColors(
+ contentColor = MaterialTheme.colorScheme.onBackground
+ )
+ ) {
+ Text(
+ text = stringResource(R.string.button_cancel),
+ style = MaterialTheme.typography.displaySmall
+ )
+ }
+ }
+ )
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/main_screen/components/MainScreen.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/main_screen/components/MainScreen.kt
new file mode 100644
index 0000000..fe02bdf
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/main_screen/components/MainScreen.kt
@@ -0,0 +1,140 @@
+package com.scrollz.partrecognizer.presentation.main_screen.components
+
+import android.Manifest
+import android.app.Activity
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.app.ActivityCompat.shouldShowRequestPermissionRationale
+import androidx.paging.compose.LazyPagingItems
+import androidx.paging.compose.itemContentType
+import androidx.paging.compose.itemKey
+import com.scrollz.partrecognizer.domain.model.Report
+import com.scrollz.partrecognizer.presentation.common.PermissionDialog
+import com.scrollz.partrecognizer.presentation.main_screen.MainEvent
+import com.scrollz.partrecognizer.presentation.main_screen.MainState
+import com.scrollz.partrecognizer.utils.ScannerContract
+import com.scrollz.partrecognizer.utils.SettingsContract
+import com.scrollz.partrecognizer.utils.toFineDateTime
+
+@Composable
+@OptIn(ExperimentalFoundationApi::class)
+fun MainScreen(
+ modifier: Modifier = Modifier,
+ state: MainState,
+ reports: LazyPagingItems,
+ onEvent: (MainEvent) -> Unit,
+ navigateToSettings: () -> Unit,
+ onReportClick: (Long) -> Unit
+) {
+ val context = LocalContext.current
+ val isPermissionRequested = remember { mutableStateOf(false) }
+
+ val scannerActivityResultLauncher = rememberLauncherForActivityResult(
+ contract = ScannerContract(),
+ onResult = { }
+ )
+
+ val settingsActivityResultLauncher = rememberLauncherForActivityResult(
+ contract = SettingsContract(),
+ onResult = { }
+ )
+
+ val cameraPermissionResultLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestPermission(),
+ onResult = { isGranted ->
+ if (isGranted) {
+ scannerActivityResultLauncher.launch(null)
+ } else {
+ if (!isPermissionRequested.value && shouldShowRequestPermissionRationale(
+ context as Activity, Manifest.permission.CAMERA
+ )
+ ) {
+ isPermissionRequested.value = true
+ } else {
+ onEvent(MainEvent.TogglePermissionDialog)
+ }
+ }
+ }
+ )
+
+ if (state.isPermissionDialogVisible) {
+ PermissionDialog(
+ onDismiss = { onEvent(MainEvent.TogglePermissionDialog) },
+ onConfirm = {
+ onEvent(MainEvent.TogglePermissionDialog)
+ settingsActivityResultLauncher.launch(null)
+ }
+ )
+ }
+
+ if (state.isDeleteDialogVisible) {
+ DeleteDialog(
+ selectedItemsCount = state.selectedReportsIds.size,
+ onDismiss = { onEvent(MainEvent.ToggleDeleteDialog) },
+ onConfirm = { onEvent(MainEvent.DeleteReports) },
+ )
+ }
+
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopBar(
+ isSelecting = state.isSelecting,
+ selectedItemsCount = state.selectedReportsIds.size,
+ cancelSelecting = { onEvent(MainEvent.CancelSelecting) },
+ deleteReports = { onEvent(MainEvent.ToggleDeleteDialog) },
+ navigateToSettings = navigateToSettings,
+ launchScanner = {
+ cameraPermissionResultLauncher.launch(Manifest.permission.CAMERA)
+ }
+ )
+ },
+ ) { paddingValues ->
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ ) {
+ items(
+ count = reports.itemCount,
+ key = reports.itemKey{ report: Report -> report.id ?: -1 },
+ contentType = reports.itemContentType { "report" }
+ ) {index ->
+ reports[index]?.let { report ->
+ ReportItem(
+ modifier = Modifier
+ .fillMaxWidth()
+ .combinedClickable(
+ onClick = {
+ if (state.isSelecting) {
+ onEvent(MainEvent.SelectReport(report.id ?: -1))
+ } else {
+ onReportClick(report.id ?: -1)
+ }
+ },
+ onLongClick = {
+ onEvent(MainEvent.SelectReport(report.id ?: -1))
+ }
+ ),
+ selected = state.selectedReportsIds.containsKey(report.id),
+ details = report.detectedDetails,
+ dateTime = report.dateTime.toFineDateTime(),
+ resultImage = report.resultImagePath
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/main_screen/components/ReportItem.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/main_screen/components/ReportItem.kt
new file mode 100644
index 0000000..e2292d5
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/main_screen/components/ReportItem.kt
@@ -0,0 +1,135 @@
+package com.scrollz.partrecognizer.presentation.main_screen.components
+
+import androidx.compose.animation.Crossfade
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+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.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import coil.compose.SubcomposeAsyncImage
+import coil.request.ImageRequest
+
+@Composable
+fun ReportItem(
+ modifier: Modifier = Modifier,
+ selected: Boolean,
+ details: String,
+ dateTime: String,
+ resultImage: String?
+) {
+ Surface(
+ modifier = modifier,
+ color = MaterialTheme.colorScheme.background
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 10.dp, horizontal = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Box (
+ modifier = Modifier.size(56.dp),
+ contentAlignment = Alignment.BottomEnd
+ ) {
+ SubcomposeAsyncImage(
+ modifier = Modifier
+ .clip(CircleShape)
+ .fillMaxSize(),
+ model = ImageRequest.Builder(LocalContext.current)
+ .data(resultImage)
+ .crossfade(true)
+ .build(),
+ contentScale = ContentScale.Crop,
+ contentDescription = null,
+ loading = {
+ Surface(
+ modifier = Modifier.size(52.dp),
+ color = MaterialTheme.colorScheme.surface
+ ) {}
+ },
+ error = {
+ Surface(
+ modifier = Modifier.size(52.dp),
+ color = MaterialTheme.colorScheme.surface
+ ) {}
+ }
+ )
+ Crossfade(
+ targetState = selected,
+ animationSpec = tween(200),
+ label = "selection_badge"
+ ) { selected ->
+ if (selected) {
+ Box(
+ modifier = Modifier
+ .size(24.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.background),
+ contentAlignment = Alignment.Center
+ ) {
+ Box(
+ modifier = Modifier
+ .size(20.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.primary)
+ )
+ Icon(
+ modifier = Modifier.size(16.dp),
+ imageVector = Icons.Default.Check,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onPrimary
+ )
+ }
+ }
+ }
+ }
+ Spacer(modifier = Modifier.width(16.dp))
+ Column(
+ modifier = Modifier
+ .fillMaxHeight()
+ .weight(1.0f, fill = true),
+ horizontalAlignment = Alignment.Start,
+ verticalArrangement = Arrangement.Top
+ ) {
+ Text(
+ text = details,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onBackground,
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 1
+ )
+ Spacer(modifier = Modifier.height(2.dp))
+ Text(
+ text = dateTime,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSecondary,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/main_screen/components/TopBar.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/main_screen/components/TopBar.kt
new file mode 100644
index 0000000..58b85b6
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/main_screen/components/TopBar.kt
@@ -0,0 +1,121 @@
+package com.scrollz.partrecognizer.presentation.main_screen.components
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.Crossfade
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.animation.togetherWith
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material.icons.outlined.Videocam
+import androidx.compose.material3.CenterAlignedTopAppBar
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import com.scrollz.partrecognizer.R
+
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+fun TopBar(
+ modifier: Modifier = Modifier,
+ isSelecting: Boolean,
+ selectedItemsCount: Int,
+ cancelSelecting: () -> Unit,
+ deleteReports: () -> Unit,
+ navigateToSettings: () -> Unit,
+ launchScanner: () -> Unit
+) {
+ Crossfade(
+ targetState = isSelecting,
+ animationSpec = tween(400),
+ label = "top_bar"
+ ) { selecting ->
+ if (selecting) {
+ TopAppBar(
+ modifier = modifier,
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = Color.Transparent,
+ titleContentColor = MaterialTheme.colorScheme.onBackground,
+ navigationIconContentColor = MaterialTheme.colorScheme.onBackground,
+ actionIconContentColor = MaterialTheme.colorScheme.onBackground
+ ),
+ title = {
+ AnimatedContent(
+ targetState = selectedItemsCount,
+ transitionSpec = {
+ slideInVertically(tween(200)) { -it } togetherWith
+ slideOutVertically(tween(200)) { it }
+ },
+ label = "counter"
+ ) { targetCount ->
+ if (targetCount > 0) {
+ Text(
+ text = "$targetCount",
+ style = MaterialTheme.typography.titleMedium
+ )
+ }
+ }
+ },
+ navigationIcon = {
+ IconButton(onClick = cancelSelecting) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = stringResource(R.string.button_close)
+ )
+ }
+ },
+ actions = {
+ IconButton(onClick = deleteReports) {
+ Icon(
+ imageVector = Icons.Outlined.Delete,
+ contentDescription = stringResource(R.string.button_delete)
+ )
+ }
+ }
+ )
+ } else {
+ CenterAlignedTopAppBar(
+ modifier = modifier,
+ colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
+ containerColor = Color.Transparent,
+ titleContentColor = MaterialTheme.colorScheme.onBackground,
+ navigationIconContentColor = MaterialTheme.colorScheme.onBackground,
+ actionIconContentColor = MaterialTheme.colorScheme.onBackground
+ ),
+ title = {
+ Text(
+ text = stringResource(R.string.app_name),
+ style = MaterialTheme.typography.titleMedium
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = navigateToSettings) {
+ Icon(
+ imageVector = Icons.Default.Settings,
+ contentDescription = stringResource(R.string.settings)
+ )
+ }
+ },
+ actions = {
+ IconButton(onClick = launchScanner) {
+ Icon(
+ imageVector = Icons.Outlined.Videocam,
+ contentDescription = stringResource(R.string.button_scanner)
+ )
+ }
+ }
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/navigation/Destination.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/navigation/Destination.kt
new file mode 100644
index 0000000..25ceea9
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/navigation/Destination.kt
@@ -0,0 +1,7 @@
+package com.scrollz.partrecognizer.presentation.navigation
+
+sealed class Destination(val route: String) {
+ data object Main: Destination(route = "main")
+ data object Report: Destination(route = "report")
+ data object Settings: Destination(route = "settings")
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/navigation/Navigation.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/navigation/Navigation.kt
new file mode 100644
index 0000000..028287c
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/navigation/Navigation.kt
@@ -0,0 +1,119 @@
+package com.scrollz.partrecognizer.presentation.navigation
+
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.navigation.NavType
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.navArgument
+import androidx.paging.compose.collectAsLazyPagingItems
+import com.scrollz.partrecognizer.presentation.main_screen.MainViewModel
+import com.scrollz.partrecognizer.presentation.main_screen.components.MainScreen
+import com.scrollz.partrecognizer.presentation.report_screen.ReportViewModel
+import com.scrollz.partrecognizer.presentation.report_screen.components.ReportScreen
+import com.scrollz.partrecognizer.presentation.settings_screen.SettingsViewModel
+import com.scrollz.partrecognizer.presentation.settings_screen.components.SettingsScreen
+
+@Composable
+fun Navigation() {
+ val navController = rememberNavController()
+
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ NavHost(
+ navController = navController,
+ startDestination = Destination.Main.route,
+ enterTransition = { EnterTransition.None },
+ exitTransition = { ExitTransition.None }
+ ) {
+ composable(
+ route = Destination.Main.route
+ ) {
+ val mainViewModel = hiltViewModel()
+ val mainState by mainViewModel.state.collectAsStateWithLifecycle()
+ val reports = mainViewModel.reports.collectAsLazyPagingItems()
+ MainScreen(
+ modifier = Modifier.fillMaxSize(),
+ state = mainState,
+ reports = reports,
+ onEvent = mainViewModel::onEvent,
+ navigateToSettings = {
+ navController.navigate(Destination.Settings.route) {
+ launchSingleTop = true
+ }
+ },
+ onReportClick = { reportID ->
+ navController.navigate(Destination.Report.route + "/$reportID") {
+ launchSingleTop = true
+ }
+ }
+ )
+ }
+
+ composable(
+ route = Destination.Report.route + "/{reportID}",
+ arguments = listOf(navArgument(name = "reportID") { type = NavType.LongType }),
+ enterTransition = {
+ fadeIn(tween(300)) +
+ slideInVertically(
+ animationSpec = tween(300),
+ initialOffsetY = { fullHeight -> fullHeight/2 }
+ )
+ },
+ exitTransition = {
+ fadeOut(tween(300)) +
+ slideOutVertically(
+ animationSpec = tween(300),
+ targetOffsetY = { fullHeight -> fullHeight/2 }
+ )
+ }
+ ) {
+ val reportViewModel = hiltViewModel()
+ val repostState by reportViewModel.state.collectAsStateWithLifecycle()
+ ReportScreen(
+ modifier = Modifier.fillMaxSize(),
+ state = repostState,
+ onEvent = reportViewModel::onEvent,
+ navigateBack = { navController.popBackStack() }
+ )
+ }
+
+ composable(
+ route = Destination.Settings.route,
+ enterTransition = {
+ fadeIn(tween(300)) + slideInHorizontally(tween(300))
+ },
+ exitTransition = {
+ fadeOut(tween(300)) + slideOutHorizontally(tween(300))
+ }
+ ) {
+ val settingsViewModel = hiltViewModel()
+ val settingsState by settingsViewModel.state.collectAsStateWithLifecycle()
+ SettingsScreen(
+ modifier = Modifier.fillMaxSize(),
+ state = settingsState,
+ onEvent = settingsViewModel::onEvent,
+ navigateBack = { navController.popBackStack() }
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/report_screen/ReportEvent.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/report_screen/ReportEvent.kt
new file mode 100644
index 0000000..7c957b8
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/report_screen/ReportEvent.kt
@@ -0,0 +1,9 @@
+package com.scrollz.partrecognizer.presentation.report_screen
+
+sealed class ReportEvent {
+ data object ConsumeUIEvent: ReportEvent()
+ data object ToggleDeleteDialog: ReportEvent()
+ data object DeleteReport: ReportEvent()
+ data object CloseImage: ReportEvent()
+ data class OpenImage(val data: String?): ReportEvent()
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/report_screen/ReportState.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/report_screen/ReportState.kt
new file mode 100644
index 0000000..e18416b
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/report_screen/ReportState.kt
@@ -0,0 +1,17 @@
+package com.scrollz.partrecognizer.presentation.report_screen
+
+import androidx.compose.runtime.Immutable
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+@Immutable
+data class ReportState(
+ val isDeleteDialogVisible: Boolean = false,
+ val uiEvent: ReportUIEvent? = null,
+ val detectedDetails: ImmutableList = persistentListOf(),
+ val detectedDetailsNames: String = "",
+ val dateTime: String = "",
+ val resultImage: String? = null,
+ val openImage: String? = null,
+ val screenError: Boolean = false
+)
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/report_screen/ReportUIEvent.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/report_screen/ReportUIEvent.kt
new file mode 100644
index 0000000..9d58b8c
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/report_screen/ReportUIEvent.kt
@@ -0,0 +1,8 @@
+package com.scrollz.partrecognizer.presentation.report_screen
+
+import androidx.compose.runtime.Immutable
+
+@Immutable
+sealed class ReportUIEvent {
+ data object NavigateBack: ReportUIEvent()
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/report_screen/ReportViewModel.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/report_screen/ReportViewModel.kt
new file mode 100644
index 0000000..99656c8
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/report_screen/ReportViewModel.kt
@@ -0,0 +1,74 @@
+package com.scrollz.partrecognizer.presentation.report_screen
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.scrollz.partrecognizer.domain.use_cases.DeleteReportUseCase
+import com.scrollz.partrecognizer.domain.use_cases.GetReportUseCase
+import com.scrollz.partrecognizer.utils.toFineDateTime
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class ReportViewModel @Inject constructor(
+ private val getReportUseCase: GetReportUseCase,
+ private val deleteReportUseCase: DeleteReportUseCase,
+ savedStateHandle: SavedStateHandle
+): ViewModel() {
+
+ private val _state = MutableStateFlow(ReportState())
+ val state = _state.asStateFlow()
+
+ private var id = -1L
+
+ init {
+ id = savedStateHandle.get("reportID") ?: -1
+ getReport()
+ }
+
+ fun onEvent(event: ReportEvent) {
+ when (event) {
+ is ReportEvent.DeleteReport -> deleteReport()
+ is ReportEvent.ConsumeUIEvent -> _state.update { it.copy(uiEvent = null) }
+ is ReportEvent.CloseImage -> _state.update { it.copy(openImage = null) }
+ is ReportEvent.OpenImage -> _state.update { it.copy(openImage = event.data) }
+ is ReportEvent.ToggleDeleteDialog -> _state.update {
+ it.copy(isDeleteDialogVisible = !it.isDeleteDialogVisible)
+ }
+ }
+ }
+
+ private fun getReport() {
+ viewModelScope.launch {
+ getReportUseCase(id)
+ .onSuccess { report ->
+ val detectedDetails = report.detectedDetails.split(", ").toImmutableList()
+ _state.update { state ->
+ state.copy(
+ detectedDetails = detectedDetails,
+ detectedDetailsNames = detectedDetails.joinToString(),
+ dateTime = report.dateTime.toFineDateTime(),
+ resultImage = report.resultImagePath
+ )
+ }
+ }
+ .onFailure { _state.update { it.copy(screenError = true) } }
+ }
+ }
+
+ private fun deleteReport() {
+ viewModelScope.launch { deleteReportUseCase(id, _state.value.resultImage) }
+ _state.update {
+ it.copy(
+ isDeleteDialogVisible = false,
+ uiEvent = ReportUIEvent.NavigateBack
+ )
+ }
+ }
+
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/report_screen/components/DeleteDialog.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/report_screen/components/DeleteDialog.kt
new file mode 100644
index 0000000..341a009
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/report_screen/components/DeleteDialog.kt
@@ -0,0 +1,57 @@
+package com.scrollz.partrecognizer.presentation.report_screen.components
+
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.scrollz.partrecognizer.R
+
+@Composable
+fun DeleteDialog(
+ onDismiss: () -> Unit,
+ onConfirm: () -> Unit
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ containerColor = MaterialTheme.colorScheme.background,
+ titleContentColor = MaterialTheme.colorScheme.onBackground,
+ textContentColor = MaterialTheme.colorScheme.onSecondary,
+ tonalElevation = 0.dp,
+ title = {
+ Text(
+ text = stringResource(R.string.delete_report),
+ style = MaterialTheme.typography.titleLarge
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = onConfirm,
+ colors = ButtonDefaults.textButtonColors(
+ contentColor = MaterialTheme.colorScheme.primary
+ )
+ ) {
+ Text(
+ text = stringResource(R.string.button_delete),
+ style = MaterialTheme.typography.displaySmall
+ )
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = onDismiss,
+ colors = ButtonDefaults.textButtonColors(
+ contentColor = MaterialTheme.colorScheme.onBackground
+ )
+ ) {
+ Text(
+ text = stringResource(R.string.button_cancel),
+ style = MaterialTheme.typography.displaySmall
+ )
+ }
+ }
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/report_screen/components/ReportScreen.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/report_screen/components/ReportScreen.kt
new file mode 100644
index 0000000..ec3ce56
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/report_screen/components/ReportScreen.kt
@@ -0,0 +1,358 @@
+package com.scrollz.partrecognizer.presentation.report_screen.components
+
+import android.annotation.SuppressLint
+import androidx.compose.animation.Crossfade
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CalendarMonth
+import androidx.compose.material.icons.filled.Compare
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.outlined.Article
+import androidx.compose.material3.Divider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+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.graphics.BlendMode
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.min
+import androidx.compose.ui.zIndex
+import coil.compose.SubcomposeAsyncImage
+import coil.request.ImageRequest
+import com.scrollz.partrecognizer.R
+import com.scrollz.partrecognizer.presentation.common.ImageView
+import com.scrollz.partrecognizer.presentation.common.ReferenceImagesRow
+import com.scrollz.partrecognizer.presentation.report_screen.ReportEvent
+import com.scrollz.partrecognizer.presentation.report_screen.ReportState
+import com.scrollz.partrecognizer.presentation.report_screen.ReportUIEvent
+import kotlinx.collections.immutable.ImmutableList
+
+@Composable
+@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
+fun ReportScreen(
+ modifier: Modifier = Modifier,
+ state: ReportState,
+ onEvent: (ReportEvent) -> Unit,
+ navigateBack: () -> Unit
+) {
+ val scrollState = rememberScrollState()
+ val scrollOffset by remember { derivedStateOf { scrollState.value } }
+
+ val screenWidth = LocalConfiguration.current.screenWidthDp.dp
+ val screenHeight = LocalConfiguration.current.screenHeightDp.dp
+ val imageHeight by remember { derivedStateOf { min(screenHeight, screenWidth) } }
+ val imageHeightPx = with(LocalDensity.current) { imageHeight.toPx() }
+ val maxOffset by remember { derivedStateOf { imageHeightPx / 1.6f } }
+
+ val interactionSource = remember { MutableInteractionSource() }
+
+ LaunchedEffect(state.uiEvent) {
+ state.uiEvent?.let { uiEvent ->
+ when (uiEvent) {
+ is ReportUIEvent.NavigateBack -> navigateBack()
+ }
+ onEvent(ReportEvent.ConsumeUIEvent)
+ }
+ }
+
+ if (state.isDeleteDialogVisible) {
+ DeleteDialog(
+ onDismiss = { onEvent(ReportEvent.ToggleDeleteDialog) },
+ onConfirm = { onEvent(ReportEvent.DeleteReport) }
+ )
+ }
+
+ Crossfade(
+ modifier = Modifier.zIndex(1f),
+ targetState = state.openImage,
+ animationSpec = tween(400),
+ label = "image_view"
+ ) { image ->
+ if (image != null) {
+ ImageView(
+ image = image,
+ closeImage = { onEvent(ReportEvent.CloseImage) }
+ )
+ }
+ }
+
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopBar(
+ isScrolled = scrollOffset >= maxOffset,
+ navigateBack = navigateBack,
+ deleteReport = { onEvent(ReportEvent.ToggleDeleteDialog) }
+ )
+ }
+ ) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.TopCenter
+ ) {
+ SubcomposeAsyncImage(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(imageHeight),
+ model = ImageRequest.Builder(LocalContext.current)
+ .data(state.resultImage)
+ .crossfade(true)
+ .build(),
+ contentScale = ContentScale.Crop,
+ contentDescription = null,
+ colorFilter = ColorFilter.tint(
+ color = MaterialTheme.colorScheme.surface.copy(
+ alpha = if (scrollOffset >= maxOffset) 1f else scrollOffset / maxOffset
+ ),
+ blendMode = BlendMode.SrcOver
+ ),
+ error = {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {}
+ },
+ )
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(scrollState)
+ ) {
+ Spacer(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(imageHeight - 32.dp)
+ .clickable(
+ interactionSource = interactionSource,
+ indication = null
+ ) { onEvent(ReportEvent.OpenImage(state.resultImage)) }
+ )
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(32.dp),
+ color = MaterialTheme.colorScheme.surface
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(24.dp)
+ ) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Names(
+ modifier = Modifier.fillMaxWidth(),
+ screenWidth = screenWidth.value,
+ detectedDetailsNames = state.detectedDetailsNames
+ )
+ Spacer(modifier = Modifier.height(32.dp))
+ Divider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outline)
+ Spacer(modifier = Modifier.height(24.dp))
+ DateTime(
+ modifier = Modifier.fillMaxWidth(),
+ dateTime = state.dateTime
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+ Divider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outline)
+ Spacer(modifier = Modifier.height(24.dp))
+ MockCharacteristics()
+ Spacer(modifier = Modifier.height(24.dp))
+ Divider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outline)
+ Spacer(modifier = Modifier.height(24.dp))
+ Compare(
+ detectedDetails = state.detectedDetails,
+ viewImage = { image -> onEvent(ReportEvent.OpenImage(image)) }
+ )
+ Spacer(modifier = Modifier.windowInsetsPadding(WindowInsets.navigationBars))
+ }
+ }
+ }
+ }
+ }
+}
+
+
+@Composable
+fun Names(
+ modifier: Modifier = Modifier,
+ screenWidth: Float,
+ detectedDetailsNames: String
+) {
+ Row(
+ modifier = modifier,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Default.Settings,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ SlidingText(
+ durationMillis = (detectedDetailsNames.length * 250 - screenWidth * 10).toInt()
+ .coerceAtLeast(1000)
+ ) {
+ Text(
+ text = detectedDetailsNames,
+ style = MaterialTheme.typography.headlineLarge,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+ }
+}
+
+@Composable
+fun DateTime(
+ modifier: Modifier = Modifier,
+ dateTime: String
+) {
+ Row(
+ modifier = modifier,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Default.CalendarMonth,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurface
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(
+ text = dateTime,
+ style = MaterialTheme.typography.headlineMedium,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+ }
+}
+
+@Composable
+fun MockCharacteristics(
+ modifier: Modifier = Modifier
+) {
+ Row(
+ modifier = modifier,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.Article,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurface
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(
+ text = "Характеристики",
+ style = MaterialTheme.typography.headlineMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+ Spacer(modifier = Modifier.height(24.dp))
+ Text(
+ text = "Наименование",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSecondary
+ )
+ Text(
+ text = "Кронштейн",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ Text(
+ text = "Вес",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSecondary
+ )
+ Text(
+ text = "720 г",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ Text(
+ text = "Размеры Д×Ш×В",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSecondary
+ )
+ Text(
+ text = "120x65x45",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+}
+
+@Composable
+fun Compare(
+ modifier: Modifier = Modifier,
+ detectedDetails: ImmutableList,
+ viewImage: (String?) -> Unit
+) {
+ Row(
+ modifier = modifier,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Default.Compare,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurface
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(
+ text = if (detectedDetails.size == 1) stringResource(R.string.detail_references)
+ else stringResource(R.string.detail_many),
+ style = MaterialTheme.typography.headlineMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+ if (detectedDetails.size == 1) {
+ Spacer(modifier = Modifier.height(32.dp))
+ ReferenceImagesRow(
+ referenceName = detectedDetails.first(),
+ viewImage = viewImage
+ )
+ } else {
+ for (detectedDetail in detectedDetails) {
+ Spacer(modifier = Modifier.height(32.dp))
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = detectedDetail,
+ style = MaterialTheme.typography.headlineSmall,
+ textAlign = TextAlign.Center
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ ReferenceImagesRow(
+ referenceName = detectedDetail,
+ viewImage = viewImage
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/report_screen/components/SlidingText.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/report_screen/components/SlidingText.kt
new file mode 100644
index 0000000..1523e40
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/report_screen/components/SlidingText.kt
@@ -0,0 +1,53 @@
+package com.scrollz.partrecognizer.presentation.report_screen.components
+
+import androidx.compose.animation.core.AnimationVector
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.TwoWayConverter
+import androidx.compose.animation.core.animateValue
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Modifier
+
+@Composable
+fun SlidingText(
+ modifier: Modifier = Modifier,
+ durationMillis: Int,
+ text: @Composable () -> Unit
+) {
+ val scrollState = rememberScrollState()
+ val infiniteTransition = rememberInfiniteTransition(label = "offset")
+ val offset = infiniteTransition.animateValue(
+ initialValue = 0,
+ targetValue = scrollState.maxValue,
+ typeConverter = TwoWayConverter(
+ convertToVector = { value -> AnimationVector(value.toFloat()) },
+ convertFromVector = { vector -> vector.value.toInt() }
+ ),
+ animationSpec = infiniteRepeatable(
+ animation = tween(durationMillis, 2400, LinearEasing),
+ repeatMode = RepeatMode.Restart
+ ),
+ label = "offset"
+ )
+
+ LaunchedEffect(offset.value) {
+ if (offset.value == 0) {
+ scrollState.animateScrollTo(0, tween(400, 1000))
+ } else {
+ scrollState.scrollTo(offset.value)
+ }
+ }
+
+ Row(
+ modifier = modifier.horizontalScroll(scrollState, enabled = false),
+ ) {
+ text()
+ }
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/report_screen/components/TopBar.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/report_screen/components/TopBar.kt
new file mode 100644
index 0000000..7d03458
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/report_screen/components/TopBar.kt
@@ -0,0 +1,91 @@
+package com.scrollz.partrecognizer.presentation.report_screen.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material3.CenterAlignedTopAppBar
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.scrollz.partrecognizer.R
+
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+fun TopBar(
+ modifier: Modifier = Modifier,
+ isScrolled: Boolean,
+ navigateBack: () -> Unit,
+ deleteReport: () -> Unit
+) {
+ CenterAlignedTopAppBar(
+ modifier = modifier.background(
+ if (isScrolled) MaterialTheme.colorScheme.inverseSurface else Color.Transparent
+ ),
+ colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
+ containerColor = Color.Transparent,
+ scrolledContainerColor = Color.Transparent,
+ titleContentColor = MaterialTheme.colorScheme.onBackground,
+ navigationIconContentColor = MaterialTheme.colorScheme.onBackground,
+ actionIconContentColor = MaterialTheme.colorScheme.onBackground
+ ),
+ title = { },
+ navigationIcon = {
+ Box(
+ modifier = Modifier
+ .height(48.dp)
+ .width(56.dp)
+ .padding(start = 8.dp)
+ .clip(RoundedCornerShape(16.dp))
+ .background(
+ if (isScrolled) Color.Transparent
+ else MaterialTheme.colorScheme.inverseSurface
+ )
+ .clickable { navigateBack() },
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = Icons.Default.ArrowBack,
+ contentDescription = stringResource(R.string.settings),
+ tint = MaterialTheme.colorScheme.onSurface
+ )
+ }
+ },
+ actions = {
+ Box(
+ modifier = Modifier
+ .size(48.dp)
+ .clip(RoundedCornerShape(16.dp))
+ .background(
+ if (isScrolled) Color.Transparent
+ else MaterialTheme.colorScheme.inverseSurface
+ )
+ .clickable { deleteReport() },
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.Delete,
+ contentDescription = stringResource(R.string.settings),
+ tint = MaterialTheme.colorScheme.onSurface
+ )
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ }
+ )
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/scanner/ScannerActivity.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/scanner/ScannerActivity.kt
new file mode 100644
index 0000000..1d556b0
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/scanner/ScannerActivity.kt
@@ -0,0 +1,40 @@
+package com.scrollz.partrecognizer.presentation.scanner
+
+import android.app.Activity
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.core.view.WindowCompat
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.scrollz.partrecognizer.presentation.scanner.components.ScannerScreen
+import com.scrollz.partrecognizer.ui.theme.ScannerTheme
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class ScannerActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+ super.onCreate(savedInstanceState)
+ setContent {
+ ScannerTheme {
+ val scannerViewModel = hiltViewModel()
+ val scannerState by scannerViewModel.state.collectAsStateWithLifecycle()
+
+ ScannerScreen(
+ modifier = Modifier.fillMaxSize(),
+ state = scannerState,
+ onEvent = scannerViewModel::onEvent,
+ closeScanner = {
+ setResult(Activity.RESULT_OK, Intent())
+ finish()
+ }
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/scanner/ScannerEvent.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/scanner/ScannerEvent.kt
new file mode 100644
index 0000000..e92e427
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/scanner/ScannerEvent.kt
@@ -0,0 +1,14 @@
+package com.scrollz.partrecognizer.presentation.scanner
+
+import androidx.camera.core.ImageProxy
+
+sealed class ScannerEvent {
+ data object ToggleTorch: ScannerEvent()
+ data object ToggleCloseDialog: ScannerEvent()
+ data object OpenDetailDialog: ScannerEvent()
+ data object CloseDetailDialog: ScannerEvent()
+ data object CloseImage: ScannerEvent()
+ data object Save: ScannerEvent()
+ data class ViewImage(val data: String?): ScannerEvent()
+ data class AnalyzeImage(val imageProxy: ImageProxy): ScannerEvent()
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/scanner/ScannerState.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/scanner/ScannerState.kt
new file mode 100644
index 0000000..f38cf08
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/scanner/ScannerState.kt
@@ -0,0 +1,23 @@
+package com.scrollz.partrecognizer.presentation.scanner
+
+import androidx.compose.runtime.Immutable
+import com.scrollz.partrecognizer.utils.UIText
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+@Immutable
+data class ScannerState(
+ val isTorchEnabled: Boolean = false,
+ val isSnackbarVisible: Boolean = true,
+ val isCloseDialogVisible: Boolean = false,
+ val isDetailDialogVisible: Boolean = false,
+ val isSaveButtonEnabled: Boolean = true,
+ val isSaved: Boolean = false,
+
+ val detectedDetails: ImmutableList = persistentListOf(),
+ val resultImage: String? = null,
+ val openImage: String? = null,
+ val error: UIText? = null
+) {
+ val isDialogVisible get() = isDetailDialogVisible || isCloseDialogVisible
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/scanner/ScannerViewModel.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/scanner/ScannerViewModel.kt
new file mode 100644
index 0000000..23891f4
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/scanner/ScannerViewModel.kt
@@ -0,0 +1,122 @@
+package com.scrollz.partrecognizer.presentation.scanner
+
+import androidx.camera.core.ImageProxy
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.scrollz.partrecognizer.R
+import com.scrollz.partrecognizer.domain.model.Report
+import com.scrollz.partrecognizer.domain.model.Response
+import com.scrollz.partrecognizer.domain.use_cases.AnalyzeImageUseCase
+import com.scrollz.partrecognizer.domain.use_cases.DeleteReportUseCase
+import com.scrollz.partrecognizer.domain.use_cases.SaveReportUseCase
+import com.scrollz.partrecognizer.domain.use_cases.SaveImageUseCase
+import com.scrollz.partrecognizer.utils.NetworkException
+import com.scrollz.partrecognizer.utils.UIText
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class ScannerViewModel @Inject constructor(
+ private val analyzeImageUseCase: AnalyzeImageUseCase,
+ private val saveImageUseCase: SaveImageUseCase,
+ private val saveReportUseCase: SaveReportUseCase,
+ private val deleteReportUseCase: DeleteReportUseCase
+): ViewModel() {
+
+ private val _state = MutableStateFlow(ScannerState())
+ val state = _state.asStateFlow()
+
+ private var response = Response("", emptyList(), "")
+ private var id = -1L
+
+ fun onEvent(event: ScannerEvent) {
+ when (event) {
+ is ScannerEvent.AnalyzeImage -> analyzeImage(event.imageProxy)
+ is ScannerEvent.Save -> if (_state.value.isSaved) unsave() else save()
+ is ScannerEvent.CloseImage -> _state.update { it.copy(openImage = null) }
+ is ScannerEvent.ViewImage -> _state.update { it.copy(openImage = event.data) }
+ is ScannerEvent.OpenDetailDialog -> openDetailDialog()
+ is ScannerEvent.CloseDetailDialog -> _state.update {
+ it.copy(isDetailDialogVisible = false, isSaved = false)
+ }
+ is ScannerEvent.ToggleTorch -> _state.update {
+ it.copy(isTorchEnabled = !it.isTorchEnabled)
+ }
+ is ScannerEvent.ToggleCloseDialog -> _state.update {
+ it.copy(
+ isCloseDialogVisible = !it.isCloseDialogVisible,
+ isSnackbarVisible = false
+ )
+ }
+ }
+ }
+
+ private fun analyzeImage(imageProxy: ImageProxy) {
+ if (_state.value.isDialogVisible) {
+ imageProxy.close()
+ return
+ }
+ viewModelScope.launch {
+ analyzeImageUseCase(imageProxy)
+ .onSuccess { response ->
+ _state.update { it.copy(error = null) }
+ if (!_state.value.isDialogVisible) {
+ this@ScannerViewModel.response = response
+ _state.update { state ->
+ state.copy(
+ isSnackbarVisible = true,
+ detectedDetails = response.detectedDetails.toImmutableList()
+ )
+ }
+ }
+ }
+ .onFailure { exception ->
+ val errorText = if (exception is NetworkException) {
+ UIText.StringResource(R.string.err_network)
+ } else {
+ UIText.StringResource(R.string.err_scanner)
+ }
+ _state.update { it.copy(isSnackbarVisible = false, error = errorText) }
+ }
+ }.invokeOnCompletion {
+ imageProxy.close()
+ }
+ }
+
+ private fun openDetailDialog() {
+ _state.update { it.copy(isDetailDialogVisible = true, isSnackbarVisible = false) }
+ viewModelScope.launch {
+ saveImageUseCase(response.resultImage, response.dateTime)
+ .onSuccess { resultImage -> _state.update { it.copy(resultImage = resultImage) } }
+ .onFailure { _state.update { it.copy(resultImage = null) } }
+ }
+ }
+
+ private fun save() {
+ viewModelScope.launch {
+ _state.update { it.copy(isSaveButtonEnabled = false) }
+ id = saveReportUseCase(
+ Report(
+ dateTime = response.dateTime,
+ resultImagePath = _state.value.resultImage,
+ detectedDetails = _state.value.detectedDetails.joinToString()
+ )
+ )
+ _state.update { it.copy(isSaveButtonEnabled = true, isSaved = true) }
+ }
+ }
+
+ private fun unsave() {
+ viewModelScope.launch {
+ _state.update { it.copy(isSaveButtonEnabled = false) }
+ deleteReportUseCase(id)
+ _state.update { it.copy(isSaveButtonEnabled = true, isSaved = false) }
+ }
+ }
+
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/scanner/components/CameraPreview.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/scanner/components/CameraPreview.kt
new file mode 100644
index 0000000..fc6237d
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/scanner/components/CameraPreview.kt
@@ -0,0 +1,88 @@
+package com.scrollz.partrecognizer.presentation.scanner.components
+
+import android.util.Log
+import android.util.Size
+import androidx.camera.core.Camera
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.ImageAnalysis
+import androidx.camera.core.ImageProxy
+import androidx.camera.core.Preview
+import androidx.camera.core.resolutionselector.ResolutionSelector
+import androidx.camera.core.resolutionselector.ResolutionStrategy
+import androidx.camera.lifecycle.ProcessCameraProvider
+import androidx.camera.view.PreviewView
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.core.content.ContextCompat
+import java.util.concurrent.Executors
+
+@Composable
+fun CameraPreview(
+ modifier: Modifier = Modifier,
+ isTorchEnabled: Boolean,
+ analyzeImage: (ImageProxy) -> Unit,
+ closeScanner: () -> Unit
+) {
+ val lifecycleOwner = LocalLifecycleOwner.current
+ val context = LocalContext.current
+
+ val cameraExecutor = remember { Executors.newSingleThreadExecutor() }
+ val previewView = remember { PreviewView(context) }
+ val camera = remember { mutableStateOf(null) }
+
+ remember {
+ ProcessCameraProvider.getInstance(context).apply {
+ addListener({
+ val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
+ val cameraProvider = get()
+
+ val preview = Preview.Builder()
+ .build()
+ .also { it.setSurfaceProvider(previewView.surfaceProvider) }
+
+ val resolutionSelector = ResolutionSelector.Builder()
+ .setResolutionStrategy(ResolutionStrategy.HIGHEST_AVAILABLE_STRATEGY)
+ .setResolutionFilter { _, _ -> listOf(Size(1200, 1200)) }
+ .build()
+
+ val detailAnalysis = ImageAnalysis.Builder()
+ .setResolutionSelector(resolutionSelector)
+ .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
+ .build()
+ .also {
+ it.setAnalyzer(cameraExecutor) { imageProxy ->
+ analyzeImage(imageProxy)
+ }
+ }
+
+ try {
+ cameraProvider.unbindAll()
+ camera.value = cameraProvider.bindToLifecycle(
+ lifecycleOwner,
+ cameraSelector,
+ preview,
+ detailAnalysis
+ )
+ } catch (e: Exception) {
+ Log.e("CameraX", "bind error")
+ closeScanner()
+ }
+ }, ContextCompat.getMainExecutor(context))
+ }
+ }
+
+ LaunchedEffect(isTorchEnabled) {
+ camera.value?.cameraControl?.enableTorch(isTorchEnabled)
+ }
+
+ AndroidView(
+ modifier = modifier,
+ factory = { previewView }
+ )
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/scanner/components/CloseDialog.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/scanner/components/CloseDialog.kt
new file mode 100644
index 0000000..eff8731
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/scanner/components/CloseDialog.kt
@@ -0,0 +1,56 @@
+package com.scrollz.partrecognizer.presentation.scanner.components
+
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.scrollz.partrecognizer.R
+
+@Composable
+fun CloseDialog(
+ onDismiss: () -> Unit,
+ onConfirm: () -> Unit
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ containerColor = MaterialTheme.colorScheme.background,
+ titleContentColor = MaterialTheme.colorScheme.onBackground,
+ tonalElevation = 0.dp,
+ title = {
+ Text(
+ text = stringResource(R.string.dialog_close),
+ style = MaterialTheme.typography.titleLarge
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = onConfirm,
+ colors = ButtonDefaults.textButtonColors(
+ contentColor = MaterialTheme.colorScheme.primary
+ )
+ ) {
+ Text(
+ text = stringResource(R.string.button_close),
+ style = MaterialTheme.typography.displaySmall
+ )
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = onDismiss,
+ colors = ButtonDefaults.textButtonColors(
+ contentColor = MaterialTheme.colorScheme.onBackground
+ )
+ ) {
+ Text(
+ text = stringResource(R.string.button_cancel),
+ style = MaterialTheme.typography.displaySmall
+ )
+ }
+ }
+ )
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/scanner/components/DetailSheet.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/scanner/components/DetailSheet.kt
new file mode 100644
index 0000000..5b0e92d
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/scanner/components/DetailSheet.kt
@@ -0,0 +1,257 @@
+package com.scrollz.partrecognizer.presentation.scanner.components
+
+import androidx.compose.animation.Crossfade
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.displayCutout
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBars
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+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.graphics.BlendMode
+import androidx.compose.ui.graphics.BlurEffect
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.TileMode
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.zIndex
+import coil.compose.SubcomposeAsyncImage
+import com.scrollz.partrecognizer.R
+import com.scrollz.partrecognizer.presentation.common.ImageView
+import com.scrollz.partrecognizer.presentation.common.ReferenceImagesRow
+import kotlinx.collections.immutable.ImmutableList
+
+@Composable
+fun DetailSheet(
+ modifier: Modifier = Modifier,
+ isSaveButtonEnabled: Boolean,
+ isSaved: Boolean,
+ detectedDetails: ImmutableList,
+ resultImage: String?,
+ openImage: String?,
+ viewImage: (String?) -> Unit,
+ closeImage: () -> Unit,
+ save: () -> Unit
+) {
+ val scrollState = rememberScrollState()
+
+ Crossfade(
+ modifier = Modifier.zIndex(1f),
+ targetState = openImage,
+ animationSpec = tween(400),
+ label = "image_view"
+ ) { image ->
+ if (image != null) {
+ ImageView(
+ image = image,
+ closeImage = closeImage
+ )
+ }
+ }
+
+ SubcomposeAsyncImage(
+ modifier = Modifier
+ .fillMaxSize()
+ .graphicsLayer {
+ renderEffect = BlurEffect(100f, 100f, TileMode.Repeated)
+ },
+ model = resultImage,
+ contentScale = ContentScale.Crop,
+ contentDescription = null,
+ colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.scrim, BlendMode.SrcOver),
+ loading = {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.surfaceVariant
+ ) {}
+ },
+ error = {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.surfaceVariant
+ ) {}
+ },
+ )
+
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(horizontal = 16.dp)
+ .verticalScroll(scrollState),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Spacer(
+ modifier = Modifier
+ .windowInsetsPadding(WindowInsets.statusBars)
+ .windowInsetsPadding(WindowInsets.displayCutout)
+ )
+
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = 64.dp),
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ shape = RoundedCornerShape(32.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ for (detail in detectedDetails) {
+ Text(
+ text = detail,
+ style = MaterialTheme.typography.headlineLarge
+ )
+ }
+ }
+ }
+
+ SubcomposeAsyncImage(
+ modifier = Modifier
+ .fillMaxWidth()
+ .aspectRatio(4 / 3f)
+ .clip(RoundedCornerShape(32.dp))
+ .clickable { viewImage(resultImage) },
+ model = resultImage,
+ contentScale = ContentScale.FillWidth,
+ contentDescription = null,
+ loading = {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .aspectRatio(4 / 3f),
+ shape = RoundedCornerShape(32.dp),
+ color = MaterialTheme.colorScheme.surfaceVariant
+ ) {}
+ },
+ error = {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .aspectRatio(4 / 3f),
+ shape = RoundedCornerShape(32.dp),
+ color = MaterialTheme.colorScheme.surfaceVariant
+ ) {}
+ }
+ )
+
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ shape = RoundedCornerShape(32.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ if (detectedDetails.size == 1) {
+ Text(
+ text = stringResource(R.string.detail_references),
+ style = MaterialTheme.typography.headlineMedium,
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+ ReferenceImagesRow(
+ referenceName = detectedDetails.first(),
+ viewImage = viewImage
+ )
+ } else {
+ Text(
+ text = stringResource(R.string.detail_many),
+ style = MaterialTheme.typography.headlineMedium,
+ )
+ for (detectedDetail in detectedDetails) {
+ Spacer(modifier = Modifier.height(24.dp))
+ Text(
+ text = detectedDetail,
+ style = MaterialTheme.typography.headlineSmall,
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ ReferenceImagesRow(
+ referenceName = detectedDetail,
+ viewImage = viewImage
+ )
+ }
+ }
+ }
+ }
+
+ Crossfade(
+ targetState = isSaved,
+ animationSpec = tween(200),
+ label = "save_button_content"
+ ) { isSaved ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp)
+ .clip(RoundedCornerShape(32.dp))
+ .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.6f))
+ .clickable(enabled = isSaveButtonEnabled) { save() },
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center
+ ) {
+ if (isSaved) {
+ Icon(
+ modifier = Modifier.size(20.dp),
+ imageVector = Icons.Default.Check,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onPrimary
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = stringResource(R.string.button_saved),
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ } else {
+ Text(
+ text = stringResource(R.string.button_save),
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onPrimary,
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.windowInsetsPadding(WindowInsets.navigationBars))
+ }
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/scanner/components/ScannerScaffold.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/scanner/components/ScannerScaffold.kt
new file mode 100644
index 0000000..cf6a276
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/scanner/components/ScannerScaffold.kt
@@ -0,0 +1,108 @@
+package com.scrollz.partrecognizer.presentation.scanner.components
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowForward
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+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.unit.dp
+import kotlinx.collections.immutable.ImmutableList
+
+@Composable
+fun ScannerScaffold(
+ modifier: Modifier = Modifier,
+ isSnackbarVisible: Boolean,
+ isDetailDialogVisible: Boolean,
+ isSaveButtonEnabled: Boolean,
+ isSaved: Boolean,
+ detectedDetails: ImmutableList,
+ resultImage: String?,
+ openImage: String?,
+ openDetailDialog: () -> Unit,
+ viewImage: (String?) -> Unit,
+ closeImage: () -> Unit,
+ save: () -> Unit
+) {
+ Box(
+ modifier = modifier,
+ contentAlignment = Alignment.Center
+ ) {
+ AnimatedVisibility(
+ visible = isDetailDialogVisible,
+ enter = fadeIn(tween(400)),
+ exit = fadeOut(tween(400)),
+ label = "detail_dialog"
+ ) {
+ DetailSheet(
+ isSaveButtonEnabled = isSaveButtonEnabled,
+ isSaved = isSaved,
+ detectedDetails = detectedDetails,
+ resultImage = resultImage,
+ openImage = openImage,
+ viewImage = viewImage,
+ closeImage = closeImage,
+ save = save
+ )
+ }
+
+ AnimatedVisibility(
+ modifier = Modifier.align(Alignment.BottomCenter),
+ visible = isSnackbarVisible && detectedDetails.isNotEmpty(),
+ enter = fadeIn(tween(400)),
+ exit = fadeOut(tween(400)),
+ label = "snackbar"
+ ) {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .windowInsetsPadding(WindowInsets.navigationBars)
+ .padding(16.dp)
+ .clip(RoundedCornerShape(16.dp))
+ .clickable(enabled = detectedDetails.isNotEmpty()) { openDetailDialog() },
+ shape = RoundedCornerShape(16.dp),
+ color = MaterialTheme.colorScheme.background,
+ contentColor = MaterialTheme.colorScheme.onBackground
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ modifier = Modifier.weight(1.0f, fill = true),
+ text = detectedDetails.joinToString(separator = ", "),
+ style = MaterialTheme.typography.displayMedium
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ Icon(
+ imageVector = Icons.Default.ArrowForward,
+ contentDescription = null
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/scanner/components/ScannerScreen.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/scanner/components/ScannerScreen.kt
new file mode 100644
index 0000000..c553f27
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/scanner/components/ScannerScreen.kt
@@ -0,0 +1,73 @@
+package com.scrollz.partrecognizer.presentation.scanner.components
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.displayCutout
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBars
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.scrollz.partrecognizer.presentation.scanner.ScannerEvent
+import com.scrollz.partrecognizer.presentation.scanner.ScannerState
+
+@Composable
+fun ScannerScreen(
+ modifier: Modifier = Modifier,
+ state: ScannerState,
+ onEvent: (ScannerEvent) -> Unit,
+ closeScanner: () -> Unit
+) {
+ BackHandler {
+ if (state.isDetailDialogVisible) {
+ onEvent(ScannerEvent.CloseDetailDialog)
+ } else {
+ onEvent(ScannerEvent.ToggleCloseDialog)
+ }
+ }
+
+ Box(
+ modifier = modifier,
+ contentAlignment = Alignment.Center
+ ) {
+ CameraPreview(
+ modifier = Modifier.fillMaxSize(),
+ isTorchEnabled = state.isTorchEnabled,
+ analyzeImage = { imageProxy -> onEvent(ScannerEvent.AnalyzeImage(imageProxy)) },
+ closeScanner = closeScanner
+ )
+ ScannerUI(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(8.dp)
+ .windowInsetsPadding(WindowInsets.statusBars)
+ .windowInsetsPadding(WindowInsets.displayCutout)
+ .windowInsetsPadding(WindowInsets.navigationBars),
+ isTorchEnabled = state.isTorchEnabled,
+ isCloseDialogVisible = state.isCloseDialogVisible,
+ error = state.error,
+ toggleTorch = { onEvent(ScannerEvent.ToggleTorch) },
+ toggleCloseDialog = { onEvent(ScannerEvent.ToggleCloseDialog) },
+ closeScanner = closeScanner
+ )
+ ScannerScaffold(
+ modifier = Modifier.fillMaxSize(),
+ isSnackbarVisible = state.isSnackbarVisible,
+ isDetailDialogVisible = state.isDetailDialogVisible,
+ isSaveButtonEnabled = state.isSaveButtonEnabled,
+ isSaved = state.isSaved,
+ detectedDetails = state.detectedDetails,
+ resultImage = state.resultImage,
+ openImage = state.openImage,
+ openDetailDialog = { onEvent(ScannerEvent.OpenDetailDialog) },
+ viewImage = { image -> onEvent(ScannerEvent.ViewImage(image)) },
+ closeImage = { onEvent(ScannerEvent.CloseImage) },
+ save = { onEvent(ScannerEvent.Save) }
+ )
+ }
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/scanner/components/ScannerUI.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/scanner/components/ScannerUI.kt
new file mode 100644
index 0000000..9046ad7
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/scanner/components/ScannerUI.kt
@@ -0,0 +1,133 @@
+package com.scrollz.partrecognizer.presentation.scanner.components
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.Crossfade
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.FlashOff
+import androidx.compose.material.icons.filled.FlashOn
+import androidx.compose.material.icons.filled.WarningAmber
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.scrollz.partrecognizer.R
+import com.scrollz.partrecognizer.utils.UIText
+
+@Composable
+fun ScannerUI(
+ modifier: Modifier = Modifier,
+ isTorchEnabled: Boolean,
+ isCloseDialogVisible: Boolean,
+ error: UIText?,
+ toggleTorch: () -> Unit,
+ toggleCloseDialog: () -> Unit,
+ closeScanner: () -> Unit
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+
+ if (isCloseDialogVisible) {
+ CloseDialog(
+ onDismiss = toggleCloseDialog,
+ onConfirm = closeScanner
+ )
+ }
+
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.SpaceBetween
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.Top,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Box(
+ modifier = Modifier
+ .size(40.dp)
+ .clickable(
+ onClick = toggleCloseDialog,
+ interactionSource = interactionSource,
+ indication = null
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = stringResource(R.string.button_close),
+ tint = Color.White
+ )
+ }
+ Box(
+ modifier = Modifier
+ .size(40.dp)
+ .clickable(
+ onClick = toggleTorch,
+ interactionSource = interactionSource,
+ indication = null
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Crossfade(
+ targetState = isTorchEnabled,
+ animationSpec = tween(400),
+ label = "torch"
+ ) { isTorchEnabled ->
+ Icon(
+ imageVector = if (isTorchEnabled) Icons.Default.FlashOn else Icons.Default.FlashOff,
+ contentDescription = stringResource(R.string.button_flash),
+ tint = Color.White
+ )
+ }
+ }
+ }
+
+ AnimatedVisibility(
+ visible = error != null,
+ enter = fadeIn(animationSpec = tween(400)),
+ exit = fadeOut(animationSpec = tween(400)),
+ label = "error row"
+ ) {
+ if (error != null) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Start
+ ) {
+ Spacer(modifier = Modifier.width(8.dp))
+ Icon(
+ imageVector = Icons.Default.WarningAmber,
+ contentDescription = stringResource(R.string.error),
+ tint = MaterialTheme.colorScheme.error
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = error.asString(),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/settings_screen/SettingsEvent.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/settings_screen/SettingsEvent.kt
new file mode 100644
index 0000000..0326af3
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/settings_screen/SettingsEvent.kt
@@ -0,0 +1,13 @@
+package com.scrollz.partrecognizer.presentation.settings_screen
+
+import com.scrollz.partrecognizer.domain.model.Theme
+
+sealed class SettingsEvent {
+ data object ConsumeUIEvent: SettingsEvent()
+ data class ChangeIP(val value: String): SettingsEvent()
+ data class ChangePort(val value: String): SettingsEvent()
+ data class SwitchTheme(val theme: Theme): SettingsEvent()
+ data object TogglePermissionDialog: SettingsEvent()
+ data class SaveBaseUrlQR(val qr: String): SettingsEvent()
+ data object SaveBaseUrl: SettingsEvent()
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/settings_screen/SettingsState.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/settings_screen/SettingsState.kt
new file mode 100644
index 0000000..43bd35e
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/settings_screen/SettingsState.kt
@@ -0,0 +1,14 @@
+package com.scrollz.partrecognizer.presentation.settings_screen
+
+import androidx.compose.runtime.Immutable
+import com.scrollz.partrecognizer.domain.model.Theme
+
+@Immutable
+data class SettingsState(
+ val uiEvent: SettingsUIEvent? = null,
+ val isButtonsEnabled: Boolean = true,
+ val isPermissionDialogVisible: Boolean = false,
+ val theme: Theme = Theme.System,
+ val domain: String = "",
+ val port: String = ""
+)
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/settings_screen/SettingsUIEvent.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/settings_screen/SettingsUIEvent.kt
new file mode 100644
index 0000000..88061a9
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/settings_screen/SettingsUIEvent.kt
@@ -0,0 +1,9 @@
+package com.scrollz.partrecognizer.presentation.settings_screen
+
+import androidx.compose.runtime.Immutable
+import com.scrollz.partrecognizer.utils.UIText
+
+@Immutable
+sealed class SettingsUIEvent {
+ data class ShowSnackbar(val message: UIText): SettingsUIEvent()
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/settings_screen/SettingsViewModel.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/settings_screen/SettingsViewModel.kt
new file mode 100644
index 0000000..0eca2af
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/settings_screen/SettingsViewModel.kt
@@ -0,0 +1,90 @@
+package com.scrollz.partrecognizer.presentation.settings_screen
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.scrollz.partrecognizer.R
+import com.scrollz.partrecognizer.domain.model.Theme
+import com.scrollz.partrecognizer.domain.use_cases.GetSettingsUseCase
+import com.scrollz.partrecognizer.domain.use_cases.SaveBaseUrlUseCase
+import com.scrollz.partrecognizer.domain.use_cases.SwitchThemeUseCase
+import com.scrollz.partrecognizer.utils.UIText
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class SettingsViewModel @Inject constructor(
+ private val getSettingsUseCase: GetSettingsUseCase,
+ private val switchThemeUseCase: SwitchThemeUseCase,
+ private val saveBaseUrlUseCase: SaveBaseUrlUseCase
+): ViewModel() {
+
+ private val _state = MutableStateFlow(SettingsState())
+ val state = _state.asStateFlow()
+
+ init { getSettings() }
+
+ fun onEvent(event: SettingsEvent) {
+ when (event) {
+ is SettingsEvent.SaveBaseUrl -> saveBaseUrl()
+ is SettingsEvent.SaveBaseUrlQR -> saveBaseUrlQR(event.qr)
+ is SettingsEvent.SwitchTheme -> switchTheme(event.theme)
+ is SettingsEvent.ChangeIP -> _state.update { it.copy(domain = event.value) }
+ is SettingsEvent.ChangePort -> _state.update { it.copy(port = event.value) }
+ is SettingsEvent.ConsumeUIEvent -> _state.update { it.copy(uiEvent = null) }
+ is SettingsEvent.TogglePermissionDialog -> _state.update {
+ it.copy(isPermissionDialogVisible = !it.isPermissionDialogVisible)
+ }
+ }
+ }
+
+ private fun getSettings() {
+ getSettingsUseCase().onEach { settings ->
+ _state.update { state ->
+ state.copy(
+ theme = settings.theme,
+ domain = settings.baseUrl.domain,
+ port = settings.baseUrl.port
+ )
+ }
+ }.launchIn(viewModelScope)
+ }
+
+ private fun saveBaseUrl() {
+ viewModelScope.launch {
+ val result = saveBaseUrlUseCase(_state.value.domain, _state.value.port)
+ showSnackbar(result)
+ }
+ }
+
+ private fun saveBaseUrlQR(qr: String) {
+ viewModelScope.launch {
+ val url = qr.trim().split(':')
+ val domain = url.getOrElse(0) { "" }
+ val port = url.getOrElse(1) { "" }
+ val result = saveBaseUrlUseCase(domain, port)
+ showSnackbar(result)
+ }
+ }
+
+ private fun showSnackbar(result: Result) {
+ _state.update { state ->
+ state.copy(
+ uiEvent = SettingsUIEvent.ShowSnackbar(
+ message = UIText.StringResource(
+ if (result.isSuccess) R.string.snackbar_changes_saved
+ else R.string.snackbar_err_save
+ )
+ )
+ )
+ }
+ }
+
+ private fun switchTheme(theme: Theme) = viewModelScope.launch { switchThemeUseCase(theme) }
+
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/settings_screen/components/SettingsScreen.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/settings_screen/components/SettingsScreen.kt
new file mode 100644
index 0000000..1e54f15
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/settings_screen/components/SettingsScreen.kt
@@ -0,0 +1,222 @@
+package com.scrollz.partrecognizer.presentation.settings_screen.components
+
+import android.Manifest
+import android.app.Activity
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+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.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.QrCode2
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Snackbar
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import androidx.core.app.ActivityCompat
+import com.google.zxing.BarcodeFormat
+import com.journeyapps.barcodescanner.ScanContract
+import com.journeyapps.barcodescanner.ScanOptions
+import com.scrollz.partrecognizer.R
+import com.scrollz.partrecognizer.presentation.common.PermissionDialog
+import com.scrollz.partrecognizer.presentation.settings_screen.SettingsEvent
+import com.scrollz.partrecognizer.presentation.settings_screen.SettingsState
+import com.scrollz.partrecognizer.presentation.settings_screen.SettingsUIEvent
+import com.scrollz.partrecognizer.utils.SettingsContract
+
+@Composable
+fun SettingsScreen(
+ modifier: Modifier = Modifier,
+ state: SettingsState,
+ onEvent: (SettingsEvent) -> Unit,
+ navigateBack: () -> Unit
+) {
+ val context = LocalContext.current
+ val scrollState = rememberScrollState()
+ val snackbarHostState = remember { SnackbarHostState() }
+ val isPermissionRequested = remember { mutableStateOf(false) }
+
+ val scannerLauncher = rememberLauncherForActivityResult(
+ contract = ScanContract(),
+ onResult = { result ->
+ result.contents?.let { onEvent(SettingsEvent.SaveBaseUrlQR(it)) }
+ }
+ )
+
+ val settingsActivityResultLauncher = rememberLauncherForActivityResult(
+ contract = SettingsContract(),
+ onResult = { }
+ )
+
+ val cameraPermissionResultLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestPermission(),
+ onResult = { isGranted ->
+ if (isGranted) {
+ scannerLauncher.launch(
+ ScanOptions()
+ .setOrientationLocked(false)
+ .setDesiredBarcodeFormats(BarcodeFormat.QR_CODE.name)
+ )
+ } else {
+ if (!isPermissionRequested.value && ActivityCompat.shouldShowRequestPermissionRationale(
+ context as Activity, Manifest.permission.CAMERA
+ )
+ ) {
+ isPermissionRequested.value = true
+ } else {
+ onEvent(SettingsEvent.TogglePermissionDialog)
+ }
+ }
+ }
+ )
+
+ LaunchedEffect(state.uiEvent) {
+ state.uiEvent?.let { uiEvent ->
+ when (uiEvent) {
+ is SettingsUIEvent.ShowSnackbar -> snackbarHostState.showSnackbar(
+ uiEvent.message.asString(context)
+ )
+ }
+ onEvent(SettingsEvent.ConsumeUIEvent)
+ }
+ }
+
+ if (state.isPermissionDialogVisible) {
+ PermissionDialog(
+ onDismiss = { onEvent(SettingsEvent.TogglePermissionDialog) },
+ onConfirm = {
+ onEvent(SettingsEvent.TogglePermissionDialog)
+ settingsActivityResultLauncher.launch(null)
+ }
+ )
+ }
+
+ Scaffold(
+ modifier = modifier,
+ topBar = { TopBar(navigateBack = navigateBack) },
+ snackbarHost = {
+ SnackbarHost(
+ hostState = snackbarHostState,
+ snackbar = {
+ Snackbar(
+ modifier = Modifier.padding(16.dp),
+ shape = RoundedCornerShape(16.dp),
+ containerColor = MaterialTheme.colorScheme.primary,
+ contentColor = MaterialTheme.colorScheme.onPrimary,
+ ) {
+ Text(
+ text = it.visuals.message,
+ style = MaterialTheme.typography.displaySmall
+ )
+ }
+ }
+ )
+ }
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .padding(horizontal = 64.dp)
+ .verticalScroll(scrollState),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Spacer(modifier = Modifier.height(64.dp))
+ TextField(
+ modifier = Modifier.fillMaxWidth(),
+ value = state.domain,
+ label = stringResource(R.string.label_ip),
+ onValueChange = { value -> onEvent(SettingsEvent.ChangeIP(value)) }
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ TextField(
+ modifier = Modifier.fillMaxWidth(),
+ value = state.port,
+ label = stringResource(R.string.label_port),
+ onValueChange = { value -> onEvent(SettingsEvent.ChangePort(value)) },
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword)
+ )
+ Spacer(modifier = Modifier.height(40.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Button(
+ modifier = Modifier.widthIn(min = 100.dp),
+ onClick = { cameraPermissionResultLauncher.launch(Manifest.permission.CAMERA) },
+ enabled = state.isButtonsEnabled,
+ contentPadding = PaddingValues(vertical = 10.dp, horizontal = 24.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ contentColor = MaterialTheme.colorScheme.onPrimary,
+ disabledContainerColor = MaterialTheme.colorScheme.onSecondary.copy(alpha = 0.5f),
+ disabledContentColor = MaterialTheme.colorScheme.onSecondary
+ )
+ ) {
+ Icon(
+ imageVector = Icons.Default.QrCode2,
+ contentDescription = stringResource(R.string.button_qr)
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = stringResource(R.string.button_qr),
+ style = MaterialTheme.typography.displaySmall
+ )
+ }
+ Button(
+ modifier = Modifier.widthIn(min = 100.dp),
+ onClick = { onEvent(SettingsEvent.SaveBaseUrl) },
+ enabled = state.isButtonsEnabled,
+ contentPadding = PaddingValues(vertical = 12.dp, horizontal = 24.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ contentColor = MaterialTheme.colorScheme.onPrimary,
+ disabledContainerColor = MaterialTheme.colorScheme.onSecondary.copy(alpha = 0.5f),
+ disabledContentColor = MaterialTheme.colorScheme.onSecondary
+ )
+ ) {
+ Text(
+ text = stringResource(R.string.button_save),
+ style = MaterialTheme.typography.displaySmall
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(128.dp))
+ ThemePicker(
+ modifier = Modifier.fillMaxWidth(),
+ theme = state.theme,
+ switchTheme = { theme -> onEvent(SettingsEvent.SwitchTheme(theme)) }
+ )
+ Spacer(modifier = Modifier.height(64.dp))
+ }
+ }
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/settings_screen/components/TextField.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/settings_screen/components/TextField.kt
new file mode 100644
index 0000000..69a2602
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/settings_screen/components/TextField.kt
@@ -0,0 +1,53 @@
+package com.scrollz.partrecognizer.presentation.settings_screen.components
+
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.OutlinedTextFieldDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun TextField(
+ modifier: Modifier = Modifier,
+ value: String,
+ onValueChange: (String) -> Unit,
+ label: String,
+ keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
+ keyboardActions: KeyboardActions = KeyboardActions.Default
+) {
+ OutlinedTextField(
+ modifier = modifier,
+ value = value,
+ onValueChange = onValueChange,
+ textStyle = MaterialTheme.typography.bodyMedium,
+ singleLine = true,
+ shape = RoundedCornerShape(4.dp),
+ keyboardOptions = keyboardOptions,
+ keyboardActions = keyboardActions,
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedTextColor = MaterialTheme.colorScheme.onBackground,
+ unfocusedTextColor = MaterialTheme.colorScheme.onSecondary,
+ focusedContainerColor = Color.Transparent,
+ unfocusedContainerColor = Color.Transparent,
+ cursorColor = MaterialTheme.colorScheme.primary,
+ focusedBorderColor = MaterialTheme.colorScheme.primary,
+ unfocusedBorderColor = MaterialTheme.colorScheme.onSecondary,
+ focusedLabelColor = MaterialTheme.colorScheme.primary,
+ unfocusedLabelColor = MaterialTheme.colorScheme.onSecondary,
+ focusedSupportingTextColor = MaterialTheme.colorScheme.error,
+ unfocusedSupportingTextColor = MaterialTheme.colorScheme.error,
+ ),
+ label = {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelSmall
+ )
+ }
+ )
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/settings_screen/components/ThemePicker.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/settings_screen/components/ThemePicker.kt
new file mode 100644
index 0000000..8e4d0ff
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/settings_screen/components/ThemePicker.kt
@@ -0,0 +1,126 @@
+package com.scrollz.partrecognizer.presentation.settings_screen.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.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.DarkMode
+import androidx.compose.material.icons.outlined.LightMode
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Slider
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+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.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.scrollz.partrecognizer.R
+import com.scrollz.partrecognizer.domain.model.Theme
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ThemePicker(
+ modifier: Modifier = Modifier,
+ theme: Theme,
+ switchTheme: (Theme) -> Unit
+) {
+
+ val value by remember(theme) {
+ derivedStateOf {
+ when (theme) {
+ Theme.Light -> 0f
+ Theme.System -> 1f
+ Theme.Dark -> 2f
+ }
+ }
+ }
+
+ Box(
+ modifier = modifier,
+ contentAlignment = Alignment.TopCenter
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.LightMode,
+ contentDescription = stringResource(R.string.theme_light),
+ tint = if (theme == Theme.Light) MaterialTheme.colorScheme.onBackground
+ else MaterialTheme.colorScheme.onSecondary
+ )
+ Slider(
+ modifier = Modifier.weight(1f, fill = true),
+ value = value,
+ onValueChange = {
+ switchTheme(when (it) {
+ 0f -> Theme.Light
+ 2f -> Theme.Dark
+ else -> Theme.System
+ })
+ },
+ valueRange = 0f..2f,
+ steps = 1,
+ thumb = {
+ Surface(
+ modifier = Modifier.size(20.dp),
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.primary
+ ) {}
+ },
+ track = {
+ Box(
+ contentAlignment = Alignment.Center
+ ) {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(1.dp),
+ shape = RoundedCornerShape(100),
+ color = MaterialTheme.colorScheme.onSecondary
+ ) {}
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ repeat(3) {
+ Surface(
+ modifier = Modifier.size(3.dp),
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.onSecondary
+ ) {}
+ }
+ }
+ }
+ }
+ )
+ Icon(
+ imageVector = Icons.Outlined.DarkMode,
+ contentDescription = stringResource(R.string.theme_dark),
+ tint = if (theme == Theme.Dark) MaterialTheme.colorScheme.onBackground
+ else MaterialTheme.colorScheme.onSecondary
+ )
+ }
+ Text(
+ text = stringResource(R.string.theme_system),
+ style = MaterialTheme.typography.displaySmall,
+ color = if (theme == Theme.System) MaterialTheme.colorScheme.onBackground
+ else MaterialTheme.colorScheme.onSecondary
+ )
+ }
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/presentation/settings_screen/components/TopBar.kt b/app/src/main/java/com/scrollz/partrecognizer/presentation/settings_screen/components/TopBar.kt
new file mode 100644
index 0000000..6fde885
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/presentation/settings_screen/components/TopBar.kt
@@ -0,0 +1,47 @@
+package com.scrollz.partrecognizer.presentation.settings_screen.components
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import com.scrollz.partrecognizer.R
+
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+fun TopBar(
+ modifier: Modifier = Modifier,
+ navigateBack: () -> Unit
+) {
+ TopAppBar(
+ modifier = modifier,
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = Color.Transparent,
+ titleContentColor = MaterialTheme.colorScheme.onBackground,
+ navigationIconContentColor = MaterialTheme.colorScheme.onBackground,
+ actionIconContentColor = MaterialTheme.colorScheme.onBackground
+ ),
+ title = {
+ Text(
+ text = stringResource(R.string.settings),
+ style = MaterialTheme.typography.titleMedium
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = navigateBack) {
+ Icon(
+ imageVector = Icons.Default.ArrowBack,
+ contentDescription = stringResource(R.string.button_back)
+ )
+ }
+ }
+ )
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/ui/theme/Color.kt b/app/src/main/java/com/scrollz/partrecognizer/ui/theme/Color.kt
new file mode 100644
index 0000000..2be10e0
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/ui/theme/Color.kt
@@ -0,0 +1,25 @@
+package com.scrollz.partrecognizer.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+// Common
+val Blue = Color(0xFF486CFF) // Primary
+val Red = Color(0xFFCD3030) // Error
+val Black = Color(0xFF000000) // D: Outline, L: OnBackground / OnSurface
+val White = Color(0xFFFFFFFF) // L: Surface, D: OnBackground / OnSurface
+
+// Dark Theme
+val DarkGrayD = Color(0xFF121212) // Background
+val GrayD = Color(0xFF1A1A1A) // Surface
+val TP70DarkGrayD = Color(0xB4212121) // Scrim
+val TP60GrayD = Color(0x9A888888) // SurfaceVariant
+val LightGrayD = Color(0xFF444444) // Secondary
+val BrightGrayD = Color(0xFFAAAAAA) // OnSecondary
+
+// Light Theme
+val BrightGrayL = Color(0xFFF0F0F0) // Background
+val LightGrayL = Color(0xFFDDDDDD) // Outline
+val TP70BrightGrayL = Color(0xB4F0F0F0) // Scrim
+val TP60WhiteL = Color(0x9AFFFFFF) // SurfaceVariant
+val GrayL = Color(0XFFBBBBBB) // Secondary
+val DarkGrayL = Color(0xFF555555) // OnSecondary
diff --git a/app/src/main/java/com/scrollz/partrecognizer/ui/theme/Theme.kt b/app/src/main/java/com/scrollz/partrecognizer/ui/theme/Theme.kt
new file mode 100644
index 0000000..ee5620f
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/ui/theme/Theme.kt
@@ -0,0 +1,113 @@
+package com.scrollz.partrecognizer.ui.theme
+
+import android.app.Activity
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.SideEffect
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalView
+import androidx.core.view.WindowCompat
+
+private val darkColorScheme = darkColorScheme(
+ background = DarkGrayD,
+ onBackground = White,
+
+ surface = GrayD,
+ inverseSurface = GrayD.copy(alpha = 0.9f),
+ onSurface = White,
+
+ surfaceVariant = TP60GrayD,
+ onSurfaceVariant = White,
+
+ primary = Blue,
+ onPrimary = White,
+
+ secondary = LightGrayD,
+ onSecondary = BrightGrayD,
+
+ scrim = TP70DarkGrayD,
+ outline = Black,
+ error = Red
+)
+
+private val lightColorScheme = lightColorScheme(
+ background = BrightGrayL,
+ onBackground = Black,
+
+ surface = White,
+ inverseSurface = White.copy(alpha = 0.9f),
+ onSurface = Black,
+
+ surfaceVariant = TP60WhiteL,
+ onSurfaceVariant = Black,
+
+ primary = Blue,
+ onPrimary = White,
+
+ secondary = GrayL,
+ onSecondary = DarkGrayL,
+
+ scrim = TP70BrightGrayL,
+ outline = LightGrayL,
+ error = Red
+)
+
+@Composable
+fun PartRecognizerTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ content: @Composable () -> Unit
+) {
+ val colorScheme = when {
+ darkTheme -> darkColorScheme
+ else -> lightColorScheme
+ }
+
+ val view = LocalView.current
+ if (!view.isInEditMode) {
+ SideEffect {
+ val window = (view.context as Activity).window
+ window.statusBarColor = Color.Transparent.toArgb()
+ window.navigationBarColor = Color.Transparent.toArgb()
+ WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
+ WindowCompat.getInsetsController(window, view).isAppearanceLightNavigationBars = !darkTheme
+ }
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = Typography,
+ content = content
+ )
+}
+
+@Composable
+fun ScannerTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ content: @Composable () -> Unit
+) {
+ val colorScheme = when {
+ darkTheme -> darkColorScheme
+ else -> lightColorScheme
+ }
+
+ val view = LocalView.current
+ if (!view.isInEditMode) {
+ SideEffect {
+ val window = (view.context as Activity).window
+ window.statusBarColor = Color.Transparent.toArgb()
+ window.navigationBarColor = Color.Transparent.toArgb()
+ WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = false
+ WindowCompat.getInsetsController(window, view).isAppearanceLightNavigationBars = false
+ }
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = Typography,
+ content = content
+ )
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/ui/theme/Type.kt b/app/src/main/java/com/scrollz/partrecognizer/ui/theme/Type.kt
new file mode 100644
index 0000000..8976599
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/ui/theme/Type.kt
@@ -0,0 +1,81 @@
+package com.scrollz.partrecognizer.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+val Typography = Typography(
+ headlineLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.W600,
+ fontSize = 24.sp
+ ),
+ headlineMedium = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.W500,
+ fontSize = 20.sp
+ ),
+ headlineSmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.W500,
+ fontSize = 16.sp,
+ lineHeight = 16.sp
+ ),
+
+ bodyLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.W400,
+ fontSize = 18.sp
+ ),
+ bodyMedium = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.W400,
+ fontSize = 16.sp
+ ),
+ bodySmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.W500,
+ fontSize = 14.sp
+ ),
+
+ labelMedium = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.W500,
+ fontSize = 17.sp,
+ letterSpacing = 0.2.sp
+ ),
+ labelSmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.W400,
+ fontSize = 14.sp
+ ),
+
+ displayMedium = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.W600,
+ fontSize = 16.sp
+ ),
+ displaySmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.W600,
+ fontSize = 14.sp,
+ ),
+
+ titleLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.W500,
+ fontSize = 22.sp
+ ),
+ titleMedium = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.W500,
+ fontSize = 20.sp
+ ),
+ titleSmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.W400,
+ fontSize = 16.sp
+ )
+)
diff --git a/app/src/main/java/com/scrollz/partrecognizer/utils/Extensions.kt b/app/src/main/java/com/scrollz/partrecognizer/utils/Extensions.kt
new file mode 100644
index 0000000..aad0125
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/utils/Extensions.kt
@@ -0,0 +1,97 @@
+package com.scrollz.partrecognizer.utils
+
+import java.text.SimpleDateFormat
+import java.util.Locale
+
+fun String.toFineDateTime(): String {
+ val inputFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault())
+ val outputFormat = SimpleDateFormat("dd.MM.yyyy HH:mm:ss", Locale.getDefault())
+ return try {
+ val date = inputFormat.parse(this)
+ date?.let { outputFormat.format(it) } ?: this
+ } catch (e: Exception) {
+ this
+ }
+}
+
+fun String.getImageURL(): String {
+ return when (this) {
+ "CS120.01.413-1" -> "https://i.postimg.cc/9fJbjbdr/CS120-01-413-1.jpg"
+ "CS120.01.413-2" -> "https://i.postimg.cc/8k3y5Zqv/CS120-01-413-2.jpg"
+ "CS120.01.413-3" -> "https://i.postimg.cc/zv9pcfn7/CS120-01-413-3.jpg"
+
+ "CS120.07.442-1" -> "https://i.postimg.cc/fbwCFp0G/CS120-07-442-1.jpg"
+ "CS120.07.442-2" -> "https://i.postimg.cc/9X46Jw2G/CS120-07-442-2.jpg"
+ "CS120.07.442-3" -> "https://i.postimg.cc/50rmXtkJ/CS120-07-442-3.jpg"
+
+ "CS150.01.427-01-1" -> "https://i.postimg.cc/XJq8N5Lx/CS150-01-427-01-1.jpg"
+ "CS150.01.427-01-2" -> "https://i.postimg.cc/kXKkdrBG/CS150-01-427-01-2.jpg"
+ "CS150.01.427-01-3" -> "https://i.postimg.cc/jC0QhFbk/CS150-01-427-01-3.jpg"
+
+ "SU160.00.404-1" -> "https://i.postimg.cc/rpFbWKgR/SU160-00-404-1.jpg"
+ "SU160.00.404-2" -> "https://i.postimg.cc/VNV7C4d2/SU160-00-404-2.jpg"
+ "SU160.00.404-3" -> "https://i.postimg.cc/NMKzJS3g/SU160-00-404-3.jpg"
+
+ "SU80.01.426-1" -> "https://i.postimg.cc/JzQvGb3W/SU80-01-426-1.jpg"
+ "SU80.01.426-2" -> "https://i.postimg.cc/59ZZG99D/SU80-01-426-2.jpg"
+ "SU80.01.426-3" -> "https://i.postimg.cc/C5fskVWq/SU80-01-426-3.jpg"
+
+ "SU80.10.409A-1" -> "https://i.postimg.cc/KYqW5xsq/SU80-10-409-A-1.jpg"
+ "SU80.10.409A-2" -> "https://i.postimg.cc/63F1DzKC/SU80-10-409-A-2.jpg"
+ "SU80.10.409A-3" -> "https://i.postimg.cc/RZGpMv9S/SU80-10-409-A-3.jpg"
+
+ "ЗВТ86.103К-02-1" -> "https://i.postimg.cc/D0sYFsfH/86-103-02-1.jpg"
+ "ЗВТ86.103К-02-2" -> "https://i.postimg.cc/MK7rFyFj/86-103-02-2.jpg"
+ "ЗВТ86.103К-02-3" -> "https://i.postimg.cc/mrYq1Jvk/86-103-02-3.jpg"
+
+ "СВМ.37.060-1" -> "https://i.postimg.cc/2jBt4pdG/37-060-1.jpg"
+ "СВМ.37.060-2" -> "https://i.postimg.cc/K8msSR6p/37-060-2.jpg"
+ "СВМ.37.060-3" -> "https://i.postimg.cc/wBvfMnTh/37-060-3.jpg"
+
+ "СВМ.37.060А-1" -> "https://i.postimg.cc/MTf9bRBH/37-060-1.jpg"
+ "СВМ.37.060А-2" -> "https://i.postimg.cc/FztDPRbZ/37-060-2.jpg"
+ "СВМ.37.060А-3" -> "https://i.postimg.cc/h4xCXLg3/37-060-3.jpg"
+
+ "СВП-120.00.060-1" -> "https://i.postimg.cc/bN93TqZx/120-00-060-1.jpg"
+ "СВП-120.00.060-2" -> "https://i.postimg.cc/q7zw4njw/120-00-060-2.jpg"
+ "СВП-120.00.060-3" -> "https://i.postimg.cc/SQWrSm7B/120-00-060-3.jpg"
+
+ "СВП120.42.020-1" -> "https://i.postimg.cc/zvnp4r01/120-42-020-1.jpg"
+ "СВП120.42.020-2" -> "https://i.postimg.cc/02sVDzy5/120-42-020-2.jpg"
+ "СВП120.42.020-3" -> "https://i.postimg.cc/XYNx1t5T/120-42-020-3.jpg"
+
+ "СВП120.42.030-1" -> "https://i.postimg.cc/HsdtjF6V/120-42-030-1.jpg"
+ "СВП120.42.030-2" -> "https://i.postimg.cc/9M9Bq23q/120-42-030-2.jpg"
+ "СВП120.42.030-3" -> "https://i.postimg.cc/90jYdkVg/120-42-030-3.jpg"
+
+ "СК20.01.01.01.406-1" -> "https://i.postimg.cc/qvmwPQXg/20-01-01-01-406-1.jpg"
+ "СК20.01.01.01.406-2" -> "https://i.postimg.cc/8cBBtbzp/20-01-01-01-406-2.jpg"
+ "СК20.01.01.01.406-3" -> "https://i.postimg.cc/k5zc44Hm/20-01-01-01-406-3.jpg"
+
+ "СК20.01.01.02.402-1" -> "https://i.postimg.cc/bvDxKPwF/20-01-01-02-402-1.jpg"
+ "СК20.01.01.02.402-2" -> "https://i.postimg.cc/5NgSzprp/20-01-01-02-402-2.jpg"
+ "СК20.01.01.02.402-3" -> "https://i.postimg.cc/tRt3029y/20-01-01-02-402-3.jpg"
+
+ "СК30.01.01.02.402-1" -> "https://i.postimg.cc/4xFvLys9/30-01-01-02-402-1.jpg"
+ "СК30.01.01.02.402-2" -> "https://i.postimg.cc/W30M17rG/30-01-01-02-402-2.jpg"
+ "СК30.01.01.02.402-3" -> "https://i.postimg.cc/7ZLMn084/30-01-01-02-402-3.jpg"
+
+ "СК30.01.01.03.403-1" -> "https://i.postimg.cc/pdwfvS9V/30-01-01-03-403-1.jpg"
+ "СК30.01.01.03.403-2" -> "https://i.postimg.cc/4y5VzNWn/30-01-01-03-403-2.jpg"
+ "СК30.01.01.03.403-3" -> "https://i.postimg.cc/dt5G337x/30-01-01-03-403-3.jpg"
+
+ "СК50.01.01.404-1" -> "https://i.postimg.cc/sDjp372W/50-01-01-404-1.jpg"
+ "СК50.01.01.404-2" -> "https://i.postimg.cc/VkTM9hW9/50-01-01-404-2.jpg"
+ "СК50.01.01.404-3" -> "https://i.postimg.cc/5tCzsmsh/50-01-01-404-3.jpg"
+
+ "СК50.02.01.411-1" -> "https://i.postimg.cc/nhMBDxBc/50-02-01-411-1.jpg"
+ "СК50.02.01.411-2" -> "https://i.postimg.cc/fLq8YCy6/50-02-01-411-2.jpg"
+ "СК50.02.01.411-3" -> "https://i.postimg.cc/hvJpsm7h/50-02-01-411-3.jpg"
+
+ "СПО250.14.190-1" -> "https://i.postimg.cc/6pxYVy65/250-14-190-1.jpg"
+ "СПО250.14.190-2" -> "https://i.postimg.cc/DyXxbqTM/250-14-190-2.jpg"
+ "СПО250.14.190-3" -> "https://i.postimg.cc/8zdwZNsd/250-14-190-3.jpg"
+
+ else -> this
+ }
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/utils/NetworkException.kt b/app/src/main/java/com/scrollz/partrecognizer/utils/NetworkException.kt
new file mode 100644
index 0000000..1633e28
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/utils/NetworkException.kt
@@ -0,0 +1,3 @@
+package com.scrollz.partrecognizer.utils
+
+class NetworkException(message: String?): Exception(message)
diff --git a/app/src/main/java/com/scrollz/partrecognizer/utils/ScannerContract.kt b/app/src/main/java/com/scrollz/partrecognizer/utils/ScannerContract.kt
new file mode 100644
index 0000000..e051c32
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/utils/ScannerContract.kt
@@ -0,0 +1,14 @@
+package com.scrollz.partrecognizer.utils
+
+import android.content.Context
+import android.content.Intent
+import androidx.activity.result.contract.ActivityResultContract
+import com.scrollz.partrecognizer.presentation.scanner.ScannerActivity
+
+class ScannerContract : ActivityResultContract() {
+ override fun createIntent(context: Context, input: Unit?): Intent {
+ return Intent(context, ScannerActivity::class.java)
+ }
+
+ override fun parseResult(resultCode: Int, intent: Intent?) { }
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/utils/SettingsContract.kt b/app/src/main/java/com/scrollz/partrecognizer/utils/SettingsContract.kt
new file mode 100644
index 0000000..e40de29
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/utils/SettingsContract.kt
@@ -0,0 +1,17 @@
+package com.scrollz.partrecognizer.utils
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.provider.Settings
+import androidx.activity.result.contract.ActivityResultContract
+
+class SettingsContract : ActivityResultContract() {
+ override fun createIntent(context: Context, input: Unit?): Intent {
+ val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
+ intent.data = Uri.fromParts("package", context.packageName, null)
+ return intent
+ }
+
+ override fun parseResult(resultCode: Int, intent: Intent?) { }
+}
diff --git a/app/src/main/java/com/scrollz/partrecognizer/utils/UIText.kt b/app/src/main/java/com/scrollz/partrecognizer/utils/UIText.kt
new file mode 100644
index 0000000..2fc0259
--- /dev/null
+++ b/app/src/main/java/com/scrollz/partrecognizer/utils/UIText.kt
@@ -0,0 +1,27 @@
+package com.scrollz.partrecognizer.utils
+
+import android.content.Context
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
+import androidx.compose.ui.res.stringResource
+
+@Immutable
+sealed class UIText {
+ data class DynamicString(val value: String): UIText()
+ data class StringResource(val id: Int): UIText()
+
+ @Composable
+ fun asString(): String {
+ return when (this) {
+ is DynamicString -> value
+ is StringResource -> stringResource(id)
+ }
+ }
+
+ fun asString(context: Context): String {
+ return when (this) {
+ is DynamicString -> value
+ is StringResource -> context.getString(id)
+ }
+ }
+}
diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
new file mode 100644
index 0000000..2b068d1
--- /dev/null
+++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..07d5da9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..6f3b755
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..6f3b755
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/icon.xml b/app/src/main/res/mipmap-anydpi-v26/icon.xml
new file mode 100644
index 0000000..c41948f
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/icon.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/icon_round.xml b/app/src/main/res/mipmap-anydpi-v26/icon_round.xml
new file mode 100644
index 0000000..c41948f
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/icon_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 0000000..c209e78
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..b2dfe3d
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-hdpi/icon.webp b/app/src/main/res/mipmap-hdpi/icon.webp
new file mode 100644
index 0000000..58247ab
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/icon.webp differ
diff --git a/app/src/main/res/mipmap-hdpi/icon_foreground.webp b/app/src/main/res/mipmap-hdpi/icon_foreground.webp
new file mode 100644
index 0000000..f49a17b
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/icon_foreground.webp differ
diff --git a/app/src/main/res/mipmap-hdpi/icon_round.webp b/app/src/main/res/mipmap-hdpi/icon_round.webp
new file mode 100644
index 0000000..d52bdc7
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/icon_round.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 0000000..4f0f1d6
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..62b611d
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/icon.webp b/app/src/main/res/mipmap-mdpi/icon.webp
new file mode 100644
index 0000000..9a9e780
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/icon.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/icon_foreground.webp b/app/src/main/res/mipmap-mdpi/icon_foreground.webp
new file mode 100644
index 0000000..38693e0
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/icon_foreground.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/icon_round.webp b/app/src/main/res/mipmap-mdpi/icon_round.webp
new file mode 100644
index 0000000..73b6e0f
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/icon_round.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 0000000..948a307
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..1b9a695
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/icon.webp b/app/src/main/res/mipmap-xhdpi/icon.webp
new file mode 100644
index 0000000..09ab910
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/icon.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/icon_foreground.webp b/app/src/main/res/mipmap-xhdpi/icon_foreground.webp
new file mode 100644
index 0000000..a5da31e
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/icon_foreground.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/icon_round.webp b/app/src/main/res/mipmap-xhdpi/icon_round.webp
new file mode 100644
index 0000000..d16deae
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/icon_round.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..28d4b77
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..9287f50
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/icon.webp b/app/src/main/res/mipmap-xxhdpi/icon.webp
new file mode 100644
index 0000000..30df248
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/icon_foreground.webp b/app/src/main/res/mipmap-xxhdpi/icon_foreground.webp
new file mode 100644
index 0000000..4dffa39
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_foreground.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/icon_round.webp b/app/src/main/res/mipmap-xxhdpi/icon_round.webp
new file mode 100644
index 0000000..ad3f3df
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_round.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..aa7d642
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..9126ae3
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/icon.webp b/app/src/main/res/mipmap-xxxhdpi/icon.webp
new file mode 100644
index 0000000..c9d6a61
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/icon.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/icon_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/icon_foreground.webp
new file mode 100644
index 0000000..bf6acfd
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/icon_foreground.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/icon_round.webp b/app/src/main/res/mipmap-xxxhdpi/icon_round.webp
new file mode 100644
index 0000000..a4cdff1
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/icon_round.webp differ
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
new file mode 100644
index 0000000..918359b
--- /dev/null
+++ b/app/src/main/res/values-night/themes.xml
@@ -0,0 +1,6 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
new file mode 100644
index 0000000..0d66395
--- /dev/null
+++ b/app/src/main/res/values-ru/strings.xml
@@ -0,0 +1,32 @@
+
+
+ Назад
+ Настройки
+ Вспышка
+ Продолжить
+ Выйти
+ Сканер
+ Сохранить
+ Удалить
+ Закрыть
+ Отмена
+ Вы действительно хотите закрыть сканер?
+ Доступ к камере
+ Приложению требуется доступ к камере. Предоставить доступ можно в настройках.
+ Открыть
+ Образцы для сравнения
+ Возможные варианты
+ Изменения сохранены
+ Не удалось сохранить изменения
+ Некорректный Qr-код
+ Порт
+ Ошибка сети. Проверьте подключение
+ Неизвестная ошибка
+ Ошибка
+ Вы действительно хотите удалить отчет?
+ Удалить выбранные элементы
+ Светлая
+ Темная
+ Система
+ Сохранено
+
\ No newline at end of file
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..49085b7
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,6 @@
+
+
+ #FF121212
+ #FFF0F0F0
+ #FFFFFF
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..f51a953
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,34 @@
+
+ Gear Vision
+ Back
+ Settings
+ Flash
+ Continue
+ quit
+ Scanner
+ QR
+ Save
+ Delete
+ Close
+ Cancel
+ Are you sure you want to close the scanner?
+ Camera access
+ The app requires access to the camera. You can grant access in the settings.
+ Open
+ Samples for comparison
+ Possible options
+ Changes saved
+ Failed to save changes
+ Incorrect QR code
+ IP
+ Port
+ Network error. Check your connection
+ Unknown error
+ Error
+ Do you really want to delete the report?
+ Delete selected items
+ Light
+ Dark
+ Auto
+ Saved
+
\ No newline at end of file
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..e7cb714
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,6 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..fa0f996
--- /dev/null
+++ b/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..6cebe05
--- /dev/null
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/test/java/com/scrollz/partrecognizer/ExampleUnitTest.kt b/app/src/test/java/com/scrollz/partrecognizer/ExampleUnitTest.kt
new file mode 100644
index 0000000..66d63c6
--- /dev/null
+++ b/app/src/test/java/com/scrollz/partrecognizer/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package com.scrollz.partrecognizer
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 0000000..6e7dcc2
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,22 @@
+buildscript {
+ ext {
+ kotlin_version = '1.9.22'
+ compose_version = '2023.10.01'
+ lifecycle_version = '2.7.0'
+ coroutines_version = '1.7.3'
+ retrofit_version = '2.9.0'
+ okHttp_version = '4.11.0'
+ moshi_version = '1.15.0'
+ room_version = '2.6.1'
+ hilt_version = '2.50'
+ camerax_version = '1.3.1'
+ paging_version = '3.2.1'
+ }
+}
+plugins {
+ id 'com.android.application' version '8.1.1' apply false
+ id 'com.android.library' version '8.1.1' apply false
+ id 'org.jetbrains.kotlin.android' version "$kotlin_version" apply false
+ id 'com.google.dagger.hilt.android' version "$hilt_version" apply false
+ id 'com.google.devtools.ksp' version '1.9.10-1.0.13' apply false
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..3c5031e
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,23 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
\ No newline at end of file
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..e708b1c
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..1ee9be6
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Thu Sep 21 15:34:12 MSK 2023
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100644
index 0000000..4f906e0
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,185 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=`expr $i + 1`
+ done
+ case $i in
+ 0) set -- ;;
+ 1) set -- "$args0" ;;
+ 2) set -- "$args0" "$args1" ;;
+ 3) set -- "$args0" "$args1" "$args2" ;;
+ 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=`save "$@"`
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..107acd3
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/settings.gradle b/settings.gradle
new file mode 100644
index 0000000..a5c9f55
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1,17 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "PartRecognizer"
+include ':app'