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'