diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..83be1d5a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,6 @@ +# https://editorconfig.org/ +# This configuration is used by ktlint when spotless invokes it + +[*.{kt,kts}] +ij_kotlin_allow_trailing_comma=true +ij_kotlin_allow_trailing_comma_on_call_site=true diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml deleted file mode 100644 index 5fb25e2b..00000000 --- a/.idea/codeStyles/Project.xml +++ /dev/null @@ -1,139 +0,0 @@ - - - - - - - - - - - -
- - - - xmlns:android - - ^$ - - - -
-
- - - - xmlns:.* - - ^$ - - - BY_NAME - -
-
- - - - .*:id - - http://schemas.android.com/apk/res/android - - - -
-
- - - - .*:name - - http://schemas.android.com/apk/res/android - - - -
-
- - - - name - - ^$ - - - -
-
- - - - style - - ^$ - - - -
-
- - - - .* - - ^$ - - - BY_NAME - -
-
- - - - .* - - http://schemas.android.com/apk/res/android - - - ANDROID_ATTRIBUTE_ORDER - -
-
- - - - .* - - .* - - - BY_NAME - -
-
-
-
- - -
-
\ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml deleted file mode 100644 index 79ee123c..00000000 --- a/.idea/codeStyles/codeStyleConfig.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/icon.png b/.idea/icon.png deleted file mode 100644 index e96783cc..00000000 Binary files a/.idea/icon.png and /dev/null differ diff --git a/.idea/icon_dark.png b/.idea/icon_dark.png deleted file mode 100644 index e96783cc..00000000 Binary files a/.idea/icon_dark.png and /dev/null differ diff --git a/LICENSE b/LICENSE index 1b795d0c..99fb1430 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2020 +Copyright (c) 2020 Andi-IM Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index b5e076cf..0054ba04 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,9 @@ -# kotlin-android-template 🤖 +# PlantScan Android Project Repository 🤖 -[![Use this template](https://img.shields.io/badge/from-kotlin--android--template-brightgreen?logo=dropbox)](https://github.com/cortinico/kotlin-android-template/generate) ![Pre Merge Checks](https://github.com/cortinico/kotlin-android-template/workflows/Pre%20Merge%20Checks/badge.svg) ![License](https://img.shields.io/github/license/cortinico/kotlin-android-template.svg) ![Language](https://img.shields.io/github/languages/top/cortinico/kotlin-android-template?color=blue&logo=kotlin) +[![Use this template](https://img.shields.io/badge/from-kotlin--android--template-brightgreen?logo=dropbox)](https://github.com/cortinico/kotlin-android-template/generate) ![Pre Merge Checks](https://github.com/Andi-IM/PlantScan/workflows/Pre%20Merge%20Checks/badge.svg) ![License](https://img.shields.io/github/license/Andi-IM/PlantScan.svg) ![Language](https://img.shields.io/github/languages/top/Andi-IM/PlantScan?color=blue&logo=kotlin) -A simple Github template that lets you create an **Android/Kotlin** project and be up and running in a **few seconds**. +Lets identify `Orchid` flower around you! ~ A final project by [Andi Irham](https://github.com/Andi-IM) -This template is focused on delivering a project with **static analysis** and **continuous integration** already in place. ## How to use 👣 @@ -19,7 +18,7 @@ Once created don't forget to update the: - **100% Kotlin-only template**. - 4 Sample modules (Android app, Android library, Kotlin library, Jetpack Compose Activity). -- Jetpack Compose setup ready to use. +- Jetpack Compose setup ready to use. - Sample Espresso, Instrumentation & JUnit tests. - 100% Gradle Kotlin DSL setup. - CI Setup with GitHub Actions. @@ -53,45 +52,10 @@ There are currently the following workflows available: - [Publish Snapshot](.github/workflows/publish-snapshot.yaml) - Will publish a `-SNAPSHOT` of the libraries to Sonatype. - [Publish Release](.github/workflows/publish-release.yaml) - Will publish a new release version of the libraries to Maven Central on tag pushes. -## Publishing 🚀 - -The template is setup to be **ready to publish** a library/artifact on a Maven Repository. - -For every module you want to publish you simply have to add the `publish` plugin: - -``` -plugins { - publish -} -``` - -### To Maven Central - -In order to use this template to publish on Maven Central, you need to configure some secrets on your repository: - -| Secret name | Value | -| --- | --- | -| `ORG_GRADLE_PROJECT_NEXUS_USERNAME` | The username you use to access Sonatype's services (such as [Nexus](https://oss.sonatype.org/) and [Jira](https://issues.sonatype.org/)) | -| `ORG_GRADLE_PROJECT_NEXUS_PASSWORD` | The password you use to access Sonatype's services (such as [Nexus](https://oss.sonatype.org/) and [Jira](https://issues.sonatype.org/)) | -| `ORG_GRADLE_PROJECT_SIGNING_KEY` | The GPG Private key to sign your artifacts. You can obtain it with `gpg --armor --export-secret-keys ` or you can create one key online on [pgpkeygen.com](https://pgpkeygen.com). The key starts with a `-----BEGIN PGP PRIVATE KEY BLOCK-----`. | -| `ORG_GRADLE_PROJECT_SIGNING_PWD` | The passphrase to unlock your private key (you picked it when creating the key). | - -The template already attaches `-sources.jar` to your publications via the new AGP publishing DSL. - -Once set up, the following workflows will take care of publishing: - -- [Publish Snapshot](.github/workflows/publish-snapshot.yaml) - To publish `-SNAPSHOT` versions to Sonatype. The workflow is setup to run either manually (with `workflow_dispatch`) or on every merge. -- [Publish Release](.github/workflows/publish-release.yaml) - Will publish a new release version of the libraries to Maven Central on tag pushes. You can trigger the workflow also manually if needed. - -### To Jitpack - -If you're using [JitPack](https://jitpack.io/), you don't need any further configuration and you can just configure the repo on JitPack. - -You probably want to disable the [Publish Snapshot] and [Publish Release](.github/workflows/publish-release.yaml) workflows (delete the files), as Jitpack will take care of that for you. ## Project Structure -The project includes three sub-projects, each in their own subdirectories: +This project structure was inspired from [Now in Android](https://github.com/android/nowinandroid) repository. The project includes three sub-projects, each in their own subdirectories: - **`app`:** The source for the final Android application. - **`library-android`:** The source for an Android library including UI. @@ -111,4 +75,4 @@ Finally, the following hidden top-level directories provide functionality for sp ## Contributing 🤝 -Feel free to open a issue or submit a pull request for any bugs/improvements. +Feel free to open a issue or submit a pull request for any bugs/improvements. \ No newline at end of file diff --git a/app-ps-catalog/.gitignore b/app-ps-catalog/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/app-ps-catalog/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app-ps-catalog/README.md b/app-ps-catalog/README.md new file mode 100644 index 00000000..b1bbc2ee --- /dev/null +++ b/app-ps-catalog/README.md @@ -0,0 +1 @@ +# :app-ps-catalog module \ No newline at end of file diff --git a/app-ps-catalog/build.gradle.kts b/app-ps-catalog/build.gradle.kts new file mode 100644 index 00000000..2826819c --- /dev/null +++ b/app-ps-catalog/build.gradle.kts @@ -0,0 +1,42 @@ +import com.github.andiim.plantscan.app.FlavorDimension +import com.github.andiim.plantscan.app.PsFlavor + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.android.application.compose) +} + +android { + defaultConfig { + applicationId = "com.github.andiim.pscatalog" + versionCode = 1 + versionName = "1.0" + + // The UI catalog does not depend on content from the app, however, it depends on modules + // which do, so we must specify a default value for the contentType dimension. + missingDimensionStrategy(FlavorDimension.contentType.name, PsFlavor.demo.name) + } + + packaging { + resources { + excludes.add("/META-INF/{AL2.0,LGPL2.1}") + } + } + + buildTypes { + release { + // To publish on the Play store a private signing key is required, but to allow anyone + // who clones the code to sign and run the release variant, use the debug signing key. + // TODO: Abstract the signing configuration to a separate file to avoid hardcoding this. + signingConfig = signingConfigs.getByName("debug") + } + } + + namespace = "com.github.andiim.pscatalog" +} + +dependencies { + implementation(project(":core:designsystem")) + implementation(project(":core:ui")) + implementation(libs.compose.activity) +} \ No newline at end of file diff --git a/app-ps-catalog/src/main/AndroidManifest.xml b/app-ps-catalog/src/main/AndroidManifest.xml new file mode 100644 index 00000000..594799fc --- /dev/null +++ b/app-ps-catalog/src/main/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app-ps-catalog/src/main/java/com/github/andiim/pscatalog/MainActivity.kt b/app-ps-catalog/src/main/java/com/github/andiim/pscatalog/MainActivity.kt new file mode 100644 index 00000000..86278e52 --- /dev/null +++ b/app-ps-catalog/src/main/java/com/github/andiim/pscatalog/MainActivity.kt @@ -0,0 +1,15 @@ +package com.github.andiim.pscatalog + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.core.view.WindowCompat +import com.github.andiim.pscatalog.ui.PsCatalog + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + WindowCompat.setDecorFitsSystemWindows(window, false) + setContent { PsCatalog() } + } +} diff --git a/app-ps-catalog/src/main/java/com/github/andiim/pscatalog/ui/Catalog.kt b/app-ps-catalog/src/main/java/com/github/andiim/pscatalog/ui/Catalog.kt new file mode 100644 index 00000000..63c7e3d2 --- /dev/null +++ b/app-ps-catalog/src/main/java/com/github/andiim/pscatalog/ui/Catalog.kt @@ -0,0 +1,143 @@ +package com.github.andiim.pscatalog.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.add +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.lazy.LazyColumn +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.Modifier +import androidx.compose.ui.unit.dp +import com.github.andiim.plantscan.core.designsystem.component.PsButton +import com.github.andiim.plantscan.core.designsystem.component.PsOutlinedButton +import com.github.andiim.plantscan.core.designsystem.component.PsTextButton +import com.github.andiim.plantscan.core.designsystem.icon.PsIcons +import com.github.andiim.plantscan.core.designsystem.theme.PsTheme + +@OptIn(ExperimentalLayoutApi::class) +@Suppress("LongMethod") +@Composable +fun PsCatalog(modifier: Modifier = Modifier) { + PsTheme { + Surface(modifier = modifier.fillMaxSize()) { + val contentPadding = WindowInsets + .systemBars + .add(WindowInsets(16.dp)) + .asPaddingValues() + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = contentPadding, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + Text( + text = "Plantscan Catalog", + style = MaterialTheme.typography.headlineSmall, + ) + } + item { Text("Buttons", Modifier.padding(top = 16.dp)) } + item { + FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + PsButton(onClick = { /* no-op */ }) { + Text(text = "Enabled") + } + PsOutlinedButton(onClick = { /* no-op */ }) { + Text(text = "Enabled") + } + PsTextButton(onClick = { /*no-op*/ }) { + Text(text = "Enabled") + } + } + } + item { Text("Disabled Buttons", Modifier.padding(top = 16.dp)) } + item { + FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + PsButton( + onClick = { /* no-op */ }, + enabled = false, + ) { + Text(text = "Enabled") + } + PsOutlinedButton( + onClick = { /* no-op */ }, + enabled = false, + ) { + Text(text = "Enabled") + } + PsTextButton( + onClick = { /* no-op */ }, + enabled = false, + ) { + Text(text = "Enabled") + } + } + } + item { Text("Buttons with leading icons", Modifier.padding(top = 16.dp)) } + item { + FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + PsButton( + onClick = { /* no-op */ }, + text = { Text(text = "Home") }, + leadingIcon = { + Icon(imageVector = PsIcons.Home, contentDescription = null) + }, + ) + PsOutlinedButton( + onClick = { /* no-op */ }, + text = { Text(text = "Home") }, + leadingIcon = { + Icon(imageVector = PsIcons.Home, contentDescription = null) + }, + ) + PsTextButton( + onClick = { /* no-op */ }, + text = { Text(text = "Home") }, + leadingIcon = { + Icon(imageVector = PsIcons.Home, contentDescription = null) + } + ) + } + } + item { Text("Disabled Buttons with leading icons", Modifier.padding(top = 16.dp)) } + item { + FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + PsButton( + onClick = { /* no-op */ }, + enabled = false, + text = { Text(text = "Home") }, + leadingIcon = { + Icon(imageVector = PsIcons.Home, contentDescription = null) + }, + ) + PsOutlinedButton( + onClick = { /* no-op */ }, + enabled = false, + text = { Text(text = "Home") }, + leadingIcon = { + Icon(imageVector = PsIcons.Home, contentDescription = null) + }, + ) + PsTextButton( + onClick = { /* no-op */ }, + enabled = false, + text = { Text(text = "Home") }, + leadingIcon = { + Icon(imageVector = PsIcons.Home, contentDescription = null) + } + ) + } + } + } + } + } +} diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app-ps-catalog/src/main/res/drawable/ic_launcher_background.xml similarity index 100% rename from app/src/main/res/drawable/ic_launcher_background.xml rename to app-ps-catalog/src/main/res/drawable/ic_launcher_background.xml diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app-ps-catalog/src/main/res/drawable/ic_launcher_foreground.xml similarity index 100% rename from app/src/main/res/drawable-v24/ic_launcher_foreground.xml rename to app-ps-catalog/src/main/res/drawable/ic_launcher_foreground.xml diff --git a/app-ps-catalog/src/main/res/mipmap-anydpi/ic_launcher.xml b/app-ps-catalog/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 00000000..6f3b755b --- /dev/null +++ b/app-ps-catalog/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app-ps-catalog/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app-ps-catalog/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 00000000..6f3b755b --- /dev/null +++ b/app-ps-catalog/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app-ps-catalog/src/main/res/mipmap-hdpi/ic_launcher.webp b/app-ps-catalog/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 00000000..c209e78e Binary files /dev/null and b/app-ps-catalog/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app-ps-catalog/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app-ps-catalog/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 00000000..b2dfe3d1 Binary files /dev/null and b/app-ps-catalog/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app-ps-catalog/src/main/res/mipmap-mdpi/ic_launcher.webp b/app-ps-catalog/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 00000000..4f0f1d64 Binary files /dev/null and b/app-ps-catalog/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app-ps-catalog/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app-ps-catalog/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 00000000..62b611da Binary files /dev/null and b/app-ps-catalog/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app-ps-catalog/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app-ps-catalog/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 00000000..948a3070 Binary files /dev/null and b/app-ps-catalog/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app-ps-catalog/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app-ps-catalog/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..1b9a6956 Binary files /dev/null and b/app-ps-catalog/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app-ps-catalog/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app-ps-catalog/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 00000000..28d4b77f Binary files /dev/null and b/app-ps-catalog/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app-ps-catalog/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app-ps-catalog/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..9287f508 Binary files /dev/null and b/app-ps-catalog/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app-ps-catalog/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app-ps-catalog/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 00000000..aa7d6427 Binary files /dev/null and b/app-ps-catalog/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app-ps-catalog/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app-ps-catalog/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..9126ae37 Binary files /dev/null and b/app-ps-catalog/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app-ps-catalog/src/main/res/values/colors.xml b/app-ps-catalog/src/main/res/values/colors.xml new file mode 100644 index 00000000..f8c6127d --- /dev/null +++ b/app-ps-catalog/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app-ps-catalog/src/main/res/values/strings.xml b/app-ps-catalog/src/main/res/values/strings.xml new file mode 100644 index 00000000..e65c5f71 --- /dev/null +++ b/app-ps-catalog/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + app-ps-catalog + \ No newline at end of file diff --git a/app-ps-catalog/src/main/res/values/themes.xml b/app-ps-catalog/src/main/res/values/themes.xml new file mode 100644 index 00000000..267a6a8a --- /dev/null +++ b/app-ps-catalog/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + + - + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index d628e646..4d079625 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,5 +1,8 @@ + + #FF000000 + #FFFFFFFF #FFAF0095 diff --git a/app/src/main/res/values/launcher_background.xml b/app/src/main/res/values/launcher_background.xml new file mode 100644 index 00000000..0edafdcc --- /dev/null +++ b/app/src/main/res/values/launcher_background.xml @@ -0,0 +1,4 @@ + + + #51988A + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a743a596..187d22d2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,58 +1,4 @@ Plant Scan - - Email - Password - Repeat password - Something wrong happened. Please try again. - Please insert a valid email. - Cancel - Try again - OK - - - Plants - Back - - - Search Plant - Search Icon - Close search bar - Search using camera detection - - - My Garden - Search - Account - - - Sign in - Enter your login details - Forgot password? Click to get recovery email - Check your inbox for the recovery email. - Password cannot be empty. - - - Create account - Your password should have at least six digits and include one digit, one lower case letter and one upper case letter. - Passwords do not match. - - - Sign in / Sign up - Settings - Sign out - Delete my account - - - Sign out? - You will have to sign in again to see your tasks. - Delete account? - You will lose all your tasks and your account will be deleted. This action is irreversible. - - - fcm_default_channel - Request permission - Notification permission - The notification permission is important for this app. Please grant the permission. - The notification permission is important for this app. Please navigate to your device settings and enable notifications for Make it So. + ⚠️ You aren’t connected to the internet diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 4bda1630..0d7e9e32 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,57 +1,20 @@ - - - + + + + + - diff --git a/app/src/main/res/xml/remote_config_defaults.xml b/app/src/main/res/xml/remote_config_defaults.xml new file mode 100644 index 00000000..f1302fdf --- /dev/null +++ b/app/src/main/res/xml/remote_config_defaults.xml @@ -0,0 +1,11 @@ + + + + ml_model + OrchidModel + + + testing + true + + diff --git a/app/src/prod/AndroidManifest.xml b/app/src/prod/AndroidManifest.xml new file mode 100644 index 00000000..6da4fb24 --- /dev/null +++ b/app/src/prod/AndroidManifest.xml @@ -0,0 +1,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/benchmarks/.gitignore b/benchmarks/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/benchmarks/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/benchmarks/build.gradle.kts b/benchmarks/build.gradle.kts new file mode 100644 index 00000000..51220ecc --- /dev/null +++ b/benchmarks/build.gradle.kts @@ -0,0 +1,74 @@ +import com.github.andiim.plantscan.app.PsBuildType +import com.github.andiim.plantscan.app.configureFlavors + +plugins { + alias(libs.plugins.android.test) +} + +android { + namespace = "com.github.andiim.plantscan.benchmarks" + + defaultConfig { + minSdk = 27 + targetSdk = libs.versions.target.sdk.version.get().toInt() + compileSdk = libs.versions.compile.sdk.version.get().toInt() + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + buildConfigField("String", "APP_BUILD_TYPE_SUFFIX", "\"\"") + } + + buildFeatures { + buildConfig = true + } + + compileOptions { + // Up to Java 11 APIs are available through desugaring + // https://developer.android.com/studio/write/java11-minimal-support-table + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildTypes { + // This benchmark buildType is used for benchmarking, and should function like your + // release build (for example, with minification on). It's signed with a debug key + // for easy local/CI testing. + create("benchmark") { + // Keep the build type debuggable so we can attach a debugger if needed + isDebuggable = true + signingConfig = signingConfigs.getByName("debug") + matchingFallbacks.add("release") + buildConfigField( + "String", + "APP_BUILD_TYPE_SUFFIX", + "\"${PsBuildType.BENCHMARK.applicationIdSuffix ?: ""}\"" + ) + } + } + + configureFlavors(this) { flavor -> + buildConfigField( + "String", + "APP_FLAVOR_SUFFIX", + "\"${flavor.applicationIdSuffix ?: ""}\"" + ) + } + + targetProjectPath = ":app" + experimentalProperties["android.experimental.self-instrumenting"] = true +} + +dependencies { + implementation(libs.benchmark.macro.junit4) + implementation(libs.androidx.test.core) + implementation(libs.espresso.core) + implementation(libs.androidx.test.ext.junit) + implementation(libs.androidx.test.rules) + implementation(libs.androidx.test.runner) + implementation(libs.uiautomator) +} + +androidComponents { + beforeVariants { + it.enable = it.buildType == "benchmark" + } +} \ No newline at end of file diff --git a/benchmarks/src/main/AndroidManifest.xml b/benchmarks/src/main/AndroidManifest.xml new file mode 100644 index 00000000..227314ee --- /dev/null +++ b/benchmarks/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/benchmarks/src/main/java/androidx/test/uiautomator/UiAutomatorHelpers.kt b/benchmarks/src/main/java/androidx/test/uiautomator/UiAutomatorHelpers.kt new file mode 100644 index 00000000..7ec08d38 --- /dev/null +++ b/benchmarks/src/main/java/androidx/test/uiautomator/UiAutomatorHelpers.kt @@ -0,0 +1,30 @@ +package androidx.test.uiautomator + +import androidx.test.uiautomator.HasChildrenOp.AT_LEAST +import androidx.test.uiautomator.HasChildrenOp.AT_MOST +import androidx.test.uiautomator.HasChildrenOp.EXACTLY +// These helpers need to be in the androidx.test.uiautomator package, +// because the abstract has package local method that needs to be implemented. +/** + * Condition will be satisfied if given element has specified count of children. + */ +fun untilHasChildren( + childCount: Int = 1, + op: HasChildrenOp = AT_LEAST, +): UiObject2Condition { + return object : UiObject2Condition() { + override fun apply(element: UiObject2): Boolean { + return when (op) { + AT_LEAST -> element.childCount >= childCount + EXACTLY -> element.childCount == childCount + AT_MOST -> element.childCount <= childCount + } + } + } +} + +enum class HasChildrenOp { + AT_LEAST, + EXACTLY, + AT_MOST +} diff --git a/benchmarks/src/main/java/com/github/andiim/plantscan/app/GeneralActions.kt b/benchmarks/src/main/java/com/github/andiim/plantscan/app/GeneralActions.kt new file mode 100644 index 00000000..2478ecde --- /dev/null +++ b/benchmarks/src/main/java/com/github/andiim/plantscan/app/GeneralActions.kt @@ -0,0 +1,28 @@ +package com.github.andiim.plantscan.app + +import android.Manifest +import android.os.Build.VERSION.SDK_INT +import android.os.Build.VERSION_CODES.TIRAMISU +import androidx.benchmark.macro.MacrobenchmarkScope + +/** + * Because the app under test is different from the one running the instrumentation test, + * the permission has to be granted manually by either: + * + * - tapping the Allow button + * ```kotlin + * val obj = By.text("Allow") + * val dialog = device.wait(Until.findObject(obj), TIMEOUT) + * dialog?.let { + * it.click() + * device.wait(Until.gone(obj), 5_000) + * } + * ``` + * - or (preferred) executing the grant command on the target package. + */ +fun MacrobenchmarkScope.allowNotifications() { + if (SDK_INT >= TIRAMISU) { + val command = "pm grant $packageName ${Manifest.permission.POST_NOTIFICATIONS}" + device.executeShellCommand(command) + } +} diff --git a/benchmarks/src/main/java/com/github/andiim/plantscan/app/Utils.kt b/benchmarks/src/main/java/com/github/andiim/plantscan/app/Utils.kt new file mode 100644 index 00000000..38283ef6 --- /dev/null +++ b/benchmarks/src/main/java/com/github/andiim/plantscan/app/Utils.kt @@ -0,0 +1,47 @@ +package com.github.andiim.plantscan.app + +import androidx.test.uiautomator.BySelector +import androidx.test.uiautomator.Direction +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiObject2 +import androidx.test.uiautomator.Until +import com.github.andiim.plantscan.benchmarks.BuildConfig +import java.io.ByteArrayOutputStream + +/** + * Convenience parameter to use proper package name with regards to build type and build flavor. + */ +val PACKAGE_NAME = buildString { + append("com.google.github.andiim.plantscan.app") + append(BuildConfig.APP_FLAVOR_SUFFIX) + append(BuildConfig.APP_BUILD_TYPE_SUFFIX) +} + +fun UiDevice.flingElementDownUp(element: UiObject2) { + // Set some margin from the sides to prevent triggering system navigation. + element.setGestureMargin(displayWidth / 5) + element.fling(Direction.DOWN) + waitForIdle() + element.fling(Direction.UP) +} + +/** + * Waits until an object with [selector] if visible on screen and returns the object. + * If the element is not available in [timeout], throws [AssertionError]. + */ +fun UiDevice.waitAndFindObject(selector: BySelector, timeout: Long): UiObject2 { + if (!wait(Until.hasObject(selector), timeout)) { + throw AssertionError("Element not found on screen in ${timeout}ms (selector=$selector)") + } + + return findObject(selector) +} + +/** + * Helper to dump window hierarchy into a string. + */ +fun UiDevice.dumpWindowHierarchy(): String { + val buffer = ByteArrayOutputStream() + dumpWindowHierarchy(buffer) + return buffer.toString() +} diff --git a/benchmarks/src/main/java/com/github/andiim/plantscan/app/baselineprofile/BaselineProfileGenerator.kt b/benchmarks/src/main/java/com/github/andiim/plantscan/app/baselineprofile/BaselineProfileGenerator.kt new file mode 100644 index 00000000..2aa4da37 --- /dev/null +++ b/benchmarks/src/main/java/com/github/andiim/plantscan/app/baselineprofile/BaselineProfileGenerator.kt @@ -0,0 +1,22 @@ +package com.github.andiim.plantscan.app.baselineprofile + +import androidx.benchmark.macro.junit4.BaselineProfileRule +import com.github.andiim.plantscan.app.PACKAGE_NAME +import org.junit.Rule +import org.junit.Test + +/** + * Generates a baseline profile which can be copied to `app/src/main/baseline-prof.txt`. + */ +class BaselineProfileGenerator { + @get:Rule + val baselineProfileRule = BaselineProfileRule() + + @Test + fun generate() = baselineProfileRule.collect(PACKAGE_NAME) { + // This block defines the app's critical user journey. Here we are interested in + // optimizing for app startup. But you can also navigate and scroll + // through your most important UI. + // TODO: CREATE USER JOURNEY FOR BENCHMARK. + } +} diff --git a/benchmarks/src/main/java/com/github/andiim/plantscan/app/startup/StartupBenchmark.kt b/benchmarks/src/main/java/com/github/andiim/plantscan/app/startup/StartupBenchmark.kt new file mode 100644 index 00000000..482cbbd4 --- /dev/null +++ b/benchmarks/src/main/java/com/github/andiim/plantscan/app/startup/StartupBenchmark.kt @@ -0,0 +1,56 @@ +package com.github.andiim.plantscan.app.startup + +import androidx.benchmark.macro.BaselineProfileMode.Disable +import androidx.benchmark.macro.BaselineProfileMode.Require +import androidx.benchmark.macro.CompilationMode +import androidx.benchmark.macro.StartupMode +import androidx.benchmark.macro.StartupTimingMetric +import androidx.benchmark.macro.junit4.MacrobenchmarkRule +import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner +import com.github.andiim.plantscan.app.PACKAGE_NAME +import com.github.andiim.plantscan.app.allowNotifications +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Enables app startups from various states of baseline profile or [CompilationMode]s. + * Run this benchmark from Studio to see startup measurements, and captured system traces + * for investigating your app's performance from a cold state. + */ +@RunWith(AndroidJUnit4ClassRunner::class) +class StartupBenchmark { + @get:Rule + val benchmarkRule = MacrobenchmarkRule() + + @Test + fun startupNoCompilation() = startup(CompilationMode.None()) + + @Test + fun startupBaseProfileDisabled() = startup( + CompilationMode.Partial(baselineProfileMode = Disable, warmupIterations = 1), + ) + + @Test + fun startupBaselineProfile() = startup(CompilationMode.Partial(baselineProfileMode = Require)) + + @Test + fun startupFullCompilation() = startup(CompilationMode.Full()) + + private fun startup(compilationMode: CompilationMode) = benchmarkRule.measureRepeated( + packageName = PACKAGE_NAME, + metrics = listOf(StartupTimingMetric()), + compilationMode = compilationMode, + iterations = 10, + startupMode = StartupMode.COLD, + setupBlock = { + pressHome() + allowNotifications() + }, + ) { + startActivityAndWait() + allowNotifications() + // Waits until the content is ready to capture Time To Full Display + // forYouWaitForContent() + } +} diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts new file mode 100644 index 00000000..ad08f95c --- /dev/null +++ b/build-logic/convention/build.gradle.kts @@ -0,0 +1,92 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + + +plugins { + `kotlin-dsl` +} + +group = "com.github.andiim.plantscan.buildlogic" + +repositories { + google() + mavenCentral() +} + +// Configure the build-logic plugins to target JDK 17 +// This matches the JDK used to build the project, and is not related to what is running on device. +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +tasks.withType().configureEach { + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } +} + +dependencies { + compileOnly(libs.agp) + compileOnly(libs.kgp) + compileOnly(libs.dhp) + compileOnly(libs.ksp.plugin) + compileOnly(libs.firebase.crashlytics.plugin) +} + +gradlePlugin { + /** Register convention plugins so they are available in the build scripts of the application */ + plugins { + register("androidApplicationCompose") { + id = "plantscan.android.application.compose" + implementationClass = "AndroidApplicationComposeConventionPlugin" + } + register("androidApplication") { + id = "plantscan.android.application" + implementationClass = "AndroidApplicationConventionPlugin" + } + register("androidApplicationJacoco") { + id = "plantscan.android.application.jacoco" + implementationClass = "AndroidApplicationJacocoConventionPlugin" + } + register("androidLibraryCompose") { + id = "plantscan.android.library.compose" + implementationClass = "AndroidLibraryComposeConventionPlugin" + } + register("androidLibrary") { + id = "plantscan.android.library" + implementationClass = "AndroidLibraryConventionPlugin" + } + register("androidFeature") { + id = "plantscan.android.feature" + implementationClass = "AndroidFeatureConventionPlugin" + } + register("androidLibraryJacoco") { + id = "plantscan.android.library.jacoco" + implementationClass = "AndroidLibraryJacocoConventionPlugin" + } + register("androidTest") { + id = "plantscan.android.test" + implementationClass = "AndroidTestConventionPlugin" + } + register("androidHilt") { + id = "plantscan.android.hilt" + implementationClass = "AndroidHiltConventionPlugin" + } + register("androidRoom") { + id = "plantscan.android.room" + implementationClass = "AndroidRoomConventionPlugin" + } + register("androidFirebase") { + id = "plantscan.android.application.firebase" + implementationClass = "AndroidApplicationFirebaseConventionPlugin" + } + register("androidFlavors") { + id = "plantscan.android.application.flavors" + implementationClass = "AndroidApplicationFlavorsConventionPlugin" + } + register("jvmLibrary") { + id = "plantscan.jvm.library" + implementationClass = "JvmLibraryConventionPlugin" + } + } +} diff --git a/build-logic/convention/src/main/java/AndroidApplicationComposeConventionPlugin.kt b/build-logic/convention/src/main/java/AndroidApplicationComposeConventionPlugin.kt new file mode 100644 index 00000000..20972660 --- /dev/null +++ b/build-logic/convention/src/main/java/AndroidApplicationComposeConventionPlugin.kt @@ -0,0 +1,15 @@ +import com.android.build.api.dsl.ApplicationExtension +import com.github.andiim.plantscan.app.configureAndroidCompose +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.getByType + +class AndroidApplicationComposeConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("com.android.application") + val extension = extensions.getByType() + configureAndroidCompose(extension) + } + } +} \ No newline at end of file diff --git a/build-logic/convention/src/main/java/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/java/AndroidApplicationConventionPlugin.kt new file mode 100644 index 00000000..8ca44830 --- /dev/null +++ b/build-logic/convention/src/main/java/AndroidApplicationConventionPlugin.kt @@ -0,0 +1,31 @@ +import com.android.build.api.dsl.ApplicationExtension +import com.android.build.api.variant.ApplicationAndroidComponentsExtension +import com.github.andiim.plantscan.app.configureKotlinAndroid +import com.github.andiim.plantscan.app.configurePrintApksTask +import com.github.andiim.plantscan.app.libs +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure + +class AndroidApplicationConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + with(pluginManager) { + apply("com.android.application") + apply("org.jetbrains.kotlin.android") + apply("kotlin-parcelize") + } + extensions.configure { + configureKotlinAndroid(this) + // defaultConfig.targetSdk = 33 + defaultConfig.targetSdk = + libs.findVersion("target.sdk.version").get().toString().toInt() + } + + extensions.configure { + configurePrintApksTask(this) + } + } + + } +} \ No newline at end of file diff --git a/build-logic/convention/src/main/java/AndroidApplicationFirebaseConventionPlugin.kt b/build-logic/convention/src/main/java/AndroidApplicationFirebaseConventionPlugin.kt new file mode 100644 index 00000000..6c7e3709 --- /dev/null +++ b/build-logic/convention/src/main/java/AndroidApplicationFirebaseConventionPlugin.kt @@ -0,0 +1,39 @@ +import com.android.build.api.dsl.ApplicationExtension +import com.github.andiim.plantscan.app.libs +import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies + +class AndroidApplicationFirebaseConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + with(pluginManager) { + apply("com.google.gms.google-services") + apply("com.google.firebase.firebase-perf") + apply("com.google.firebase.crashlytics") + apply("com.google.firebase.appdistribution") + } + + dependencies { + val bom = libs.findLibrary("firebase.bom").get() + add("implementation", platform(bom)) + "implementation"(libs.findLibrary("firebase.analytics").get()) + "implementation"(libs.findLibrary("firebase.perf").get()) + "implementation"(libs.findLibrary("firebase.crashlytics").get()) + } + + extensions.configure { + buildTypes.configureEach { + // Disable the Crashlytics mapping file upload. This feature should only be + // enabled if a Firebase backend is available and configured in + // google-services.json. + configure { + mappingFileUploadEnabled = false + } + } + } + } + } +} \ No newline at end of file diff --git a/build-logic/convention/src/main/java/AndroidApplicationFlavorsConventionPlugin.kt b/build-logic/convention/src/main/java/AndroidApplicationFlavorsConventionPlugin.kt new file mode 100644 index 00000000..f6df403f --- /dev/null +++ b/build-logic/convention/src/main/java/AndroidApplicationFlavorsConventionPlugin.kt @@ -0,0 +1,15 @@ +import com.android.build.api.dsl.ApplicationExtension +import com.github.andiim.plantscan.app.configureFlavors +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure + +class AndroidApplicationFlavorsConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + extensions.configure { + configureFlavors(this) + } + } + } +} \ No newline at end of file diff --git a/build-logic/convention/src/main/java/AndroidApplicationJacocoConventionPlugin.kt b/build-logic/convention/src/main/java/AndroidApplicationJacocoConventionPlugin.kt new file mode 100644 index 00000000..8ffe5006 --- /dev/null +++ b/build-logic/convention/src/main/java/AndroidApplicationJacocoConventionPlugin.kt @@ -0,0 +1,19 @@ +import com.android.build.api.variant.ApplicationAndroidComponentsExtension +import com.github.andiim.plantscan.app.configureJacoco +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.getByType + +class AndroidApplicationJacocoConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + with(pluginManager) { + apply("org.gradle.jacoco") + apply("com.android.application") + } + val extension = extensions.getByType() + configureJacoco(extension) + } + } + +} \ No newline at end of file diff --git a/build-logic/convention/src/main/java/AndroidFeatureConventionPlugin.kt b/build-logic/convention/src/main/java/AndroidFeatureConventionPlugin.kt new file mode 100644 index 00000000..3008c136 --- /dev/null +++ b/build-logic/convention/src/main/java/AndroidFeatureConventionPlugin.kt @@ -0,0 +1,48 @@ +import com.android.build.gradle.LibraryExtension +import com.github.andiim.plantscan.app.libs +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.kotlin + +class AndroidFeatureConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply { + apply("plantscan.android.library") + apply("plantscan.android.hilt") + } + extensions.configure { + defaultConfig { + testInstrumentationRunner = + "com.github.andiim.plantscan.core.testing.PsAppTestRunner" + } + } + + dependencies { + add("implementation", project(":core:model")) + add("implementation", project(":core:ui")) + add("implementation", project(":core:designsystem")) + add("implementation", project(":core:data")) + add("implementation", project(":core:common")) + add("implementation", project(":core:domain")) + add("implementation", project(":core:analytics")) + + add("testImplementation", kotlin("test")) + add("testImplementation", project(":core:testing")) + add("androidTestImplementation", kotlin("test")) + add("androidTestImplementation", project(":core:testing")) + + add("implementation", libs.findLibrary("coil").get()) + add("implementation", libs.findLibrary("coil.compose").get()) + + add("implementation", libs.findLibrary("dagger.hilt.navigation.compose").get()) + add("implementation", libs.findLibrary("androidx.lifecycle.runtime.compose").get()) + add("implementation", libs.findLibrary("androidx.lifecycle.viewmodel.compose").get()) + + add("implementation", libs.findLibrary("kotlin.coroutines.android").get()) + } + } + } +} diff --git a/build-logic/convention/src/main/java/AndroidHiltConventionPlugin.kt b/build-logic/convention/src/main/java/AndroidHiltConventionPlugin.kt new file mode 100644 index 00000000..21edcb8e --- /dev/null +++ b/build-logic/convention/src/main/java/AndroidHiltConventionPlugin.kt @@ -0,0 +1,29 @@ +import com.github.andiim.plantscan.app.libs +import dagger.hilt.android.plugin.HiltExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies + +class AndroidHiltConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + with(pluginManager) { + apply("dagger.hilt.android.plugin") + apply("com.google.devtools.ksp") + } + + dependencies { + // "implementation"(libs.findLibrary("autofactory").get()) + "implementation"(libs.findLibrary("hilt.android").get()) + "ksp"(libs.findLibrary("hilt.compiler").get()) + "kspAndroidTest"(libs.findLibrary("hilt.compiler").get()) + } + + extensions.configure { + enableAggregatingTask = true + } + + } + } +} \ No newline at end of file diff --git a/build-logic/convention/src/main/java/AndroidLibraryComposeConventionPlugin.kt b/build-logic/convention/src/main/java/AndroidLibraryComposeConventionPlugin.kt new file mode 100644 index 00000000..0d1ba58f --- /dev/null +++ b/build-logic/convention/src/main/java/AndroidLibraryComposeConventionPlugin.kt @@ -0,0 +1,15 @@ +import com.android.build.gradle.LibraryExtension +import com.github.andiim.plantscan.app.configureAndroidCompose +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.getByType + +class AndroidLibraryComposeConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target){ + pluginManager.apply("com.android.library") + val extension = extensions.getByType() + configureAndroidCompose(extension) + } + } +} \ No newline at end of file diff --git a/build-logic/convention/src/main/java/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/java/AndroidLibraryConventionPlugin.kt new file mode 100644 index 00000000..d531bb6e --- /dev/null +++ b/build-logic/convention/src/main/java/AndroidLibraryConventionPlugin.kt @@ -0,0 +1,37 @@ +import com.android.build.api.variant.LibraryAndroidComponentsExtension +import com.android.build.gradle.LibraryExtension +import com.github.andiim.plantscan.app.configureFlavors +import com.github.andiim.plantscan.app.configureKotlinAndroid +import com.github.andiim.plantscan.app.configurePrintApksTask +import com.github.andiim.plantscan.app.libs +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.kotlin + +class AndroidLibraryConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + with(pluginManager) { + apply("com.android.library") + apply("org.jetbrains.kotlin.android") + } + + extensions.configure { + configureKotlinAndroid(this) + defaultConfig.targetSdk = + libs.findVersion("target.sdk.version").get().toString().toInt() + configureFlavors(this) + } + extensions.configure { + configurePrintApksTask(this) + // disableUnnecessaryAndroidTests(target) + } + dependencies { + add("androidTestImplementation", kotlin("test")) + add("testImplementation", kotlin("test")) + } + } + } +} \ No newline at end of file diff --git a/build-logic/convention/src/main/java/AndroidLibraryJacocoConventionPlugin.kt b/build-logic/convention/src/main/java/AndroidLibraryJacocoConventionPlugin.kt new file mode 100644 index 00000000..1e288e01 --- /dev/null +++ b/build-logic/convention/src/main/java/AndroidLibraryJacocoConventionPlugin.kt @@ -0,0 +1,19 @@ +import com.android.build.api.variant.LibraryAndroidComponentsExtension +import com.github.andiim.plantscan.app.configureJacoco +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.getByType + +class AndroidLibraryJacocoConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + with(pluginManager) { + apply("org.gradle.jacoco") + apply("com.android.library") + } + + val extension = extensions.getByType() + configureJacoco(extension) + } + } +} \ No newline at end of file diff --git a/build-logic/convention/src/main/java/AndroidRoomConventionPlugin.kt b/build-logic/convention/src/main/java/AndroidRoomConventionPlugin.kt new file mode 100644 index 00000000..f097903e --- /dev/null +++ b/build-logic/convention/src/main/java/AndroidRoomConventionPlugin.kt @@ -0,0 +1,43 @@ +import com.github.andiim.plantscan.app.libs +import com.google.devtools.ksp.gradle.KspExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies +import org.gradle.process.CommandLineArgumentProvider +import java.io.File + +class AndroidRoomConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("com.google.devtools.ksp") + extensions.configure() { + // The schemas directory contains a schema file for each version of the Room database. + // This is required to enable Room auto migrations. + // See https://developer.android.com/reference/kotlin/androidx/room/AutoMigration. + arg(RoomSchemaArgProvider(File(projectDir, "schemas"))) + } + + dependencies { + add("implementation", libs.findLibrary("room.runtime").get()) + add("implementation", libs.findLibrary("room.ktx").get()) + add("ksp", libs.findLibrary("room.compiler").get()) + } + } + } + + /** + * https://issuetracker.google.com/issues/132245929 + * [Export schemas](https://developer.android.com/training/data-storage/room/migrating-db-versions#export-schemas) + */ + class RoomSchemaArgProvider( + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + val schemaDir: File, + ) : CommandLineArgumentProvider { + override fun asArguments() = listOf("room.schemaLocation=${schemaDir.path}") + } +} \ No newline at end of file diff --git a/build-logic/convention/src/main/java/AndroidTestConventionPlugin.kt b/build-logic/convention/src/main/java/AndroidTestConventionPlugin.kt new file mode 100644 index 00000000..c1ad8ecd --- /dev/null +++ b/build-logic/convention/src/main/java/AndroidTestConventionPlugin.kt @@ -0,0 +1,13 @@ +import org.gradle.api.Plugin +import org.gradle.api.Project + +class AndroidTestConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + with(pluginManager) { + apply("com.android.test") + apply("org.jetbrains.kotlin.android") + } + } + } +} \ No newline at end of file diff --git a/build-logic/convention/src/main/java/Coordinates.kt b/build-logic/convention/src/main/java/Coordinates.kt new file mode 100644 index 00000000..d2157d47 --- /dev/null +++ b/build-logic/convention/src/main/java/Coordinates.kt @@ -0,0 +1,8 @@ +const val PUBLISHING_GROUP = "com.github.andiim.plantscan" + +object AppCoordinates { + const val APP_ID = "com.github.andiim.plantscan.app" + + const val APP_VERSION_NAME = "1.0.1" + const val APP_VERSION_CODE = 2 +} \ No newline at end of file diff --git a/buildSrc/src/main/java/Extensions.kt b/build-logic/convention/src/main/java/Extensions.kt similarity index 100% rename from buildSrc/src/main/java/Extensions.kt rename to build-logic/convention/src/main/java/Extensions.kt diff --git a/build-logic/convention/src/main/java/JvmLibraryConventionPlugin.kt b/build-logic/convention/src/main/java/JvmLibraryConventionPlugin.kt new file mode 100644 index 00000000..d9739fac --- /dev/null +++ b/build-logic/convention/src/main/java/JvmLibraryConventionPlugin.kt @@ -0,0 +1,14 @@ +import com.github.andiim.plantscan.app.configureKotlinJvm +import org.gradle.api.Plugin +import org.gradle.api.Project + +class JvmLibraryConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target){ + with(pluginManager){ + apply("org.jetbrains.kotlin.jvm") + } + configureKotlinJvm() + } + } +} \ No newline at end of file diff --git a/build-logic/convention/src/main/java/com/github/andiim/plantscan/app/AndroidCompose.kt b/build-logic/convention/src/main/java/com/github/andiim/plantscan/app/AndroidCompose.kt new file mode 100644 index 00000000..8feea1ad --- /dev/null +++ b/build-logic/convention/src/main/java/com/github/andiim/plantscan/app/AndroidCompose.kt @@ -0,0 +1,63 @@ +package com.github.andiim.plantscan.app + +import com.android.build.api.dsl.CommonExtension +import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.withType +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +/** + * Configure Compose-specific options + */ +internal fun Project.configureAndroidCompose( + commonExtension: CommonExtension<*, *, *, *, *>, +) { + commonExtension.apply { + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = + libs.findVersion("compose.compilerextension").get().toString() + } + + dependencies { + val bom = libs.findLibrary("compose-bom").get() + add("implementation", platform(bom)) + add("androidTestImplementation", platform(bom)) + } + } + + tasks.withType().configureEach { + kotlinOptions { + freeCompilerArgs = freeCompilerArgs + buildComposeMetricsParameters() + } + } +} + +private fun Project.buildComposeMetricsParameters(): List { + val metricParameters = mutableListOf() + val enableMetricsProvider = project.providers.gradleProperty("enableComposeCompilerMetrics") + val relativePath = projectDir.relativeTo(rootDir) + + val enableMetrics = (enableMetricsProvider.orNull == "true") + if (enableMetrics) { + val metricsFolder = rootProject.buildDir.resolve("compose-metrics").resolve(relativePath) + metricParameters.add("-P") + metricParameters.add( + "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" + metricsFolder.absolutePath + ) + } + + val enableReportsProvider = project.providers.gradleProperty("enableComposeCompilerReports") + val enableReports = (enableReportsProvider.orNull == "true") + if (enableReports) { + val reportsFolder = rootProject.buildDir.resolve("compose-reports").resolve(relativePath) + metricParameters.add("-P") + metricParameters.add( + "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" + reportsFolder.absolutePath + ) + } + return metricParameters.toList() +} diff --git a/build-logic/convention/src/main/java/com/github/andiim/plantscan/app/Jacoco.kt b/build-logic/convention/src/main/java/com/github/andiim/plantscan/app/Jacoco.kt new file mode 100644 index 00000000..31046fba --- /dev/null +++ b/build-logic/convention/src/main/java/com/github/andiim/plantscan/app/Jacoco.kt @@ -0,0 +1,71 @@ +package com.github.andiim.plantscan.app + +import com.android.build.api.variant.AndroidComponentsExtension +import java.util.Locale +import org.gradle.api.Project +import org.gradle.api.tasks.testing.Test +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.register +import org.gradle.kotlin.dsl.withType +import org.gradle.testing.jacoco.plugins.JacocoPluginExtension +import org.gradle.testing.jacoco.plugins.JacocoTaskExtension +import org.gradle.testing.jacoco.tasks.JacocoReport + +private val coverageExclusions = listOf( + // Android + "**/R.class", + "**/R\$*.class", + "**/BuildConfig.*", + "**/Manifest*.*" +) + +private fun String.capitalize() = replaceFirstChar { + if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() +} + +internal fun Project.configureJacoco( + androidComponentsExtension: AndroidComponentsExtension<*, *, *>, +) { + configure { + toolVersion = libs.findVersion("jacoco").get().toString() + } + + val jacocoTestReport = tasks.create("jacocoTestReport") + + androidComponentsExtension.onVariants { variant -> + val testTaskName = "test${variant.name.capitalize()}UnitTest" + + val reportTask = tasks.register("jacoco${testTaskName.capitalize()}Report", JacocoReport::class) { + dependsOn(testTaskName) + + reports { + xml.required.set(true) + html.required.set(true) + } + + classDirectories.setFrom( + fileTree("$buildDir/tmp/kotlin-classes/${variant.name}") { + exclude(coverageExclusions) + } + ) + + sourceDirectories.setFrom(files("$projectDir/src/main/java", "$projectDir/src/main/kotlin")) + executionData.setFrom(file("$buildDir/jacoco/$testTaskName.exec")) + } + + jacocoTestReport.dependsOn(reportTask) + } + + tasks.withType().configureEach { + configure { + // Required for JaCoCo + Robolectric + // https://github.com/robolectric/robolectric/issues/2230 + // Consider removing if not we don't add Robolectric + // isIncludeNoLocationClasses = true + + // Required for JDK 11 with the above + // https://github.com/gradle/gradle/issues/5184#issuecomment-391982009 + excludes = listOf("jdk.internal.*") + } + } +} diff --git a/build-logic/convention/src/main/java/com/github/andiim/plantscan/app/KotlinAndroid.kt b/build-logic/convention/src/main/java/com/github/andiim/plantscan/app/KotlinAndroid.kt new file mode 100644 index 00000000..30ef89bf --- /dev/null +++ b/build-logic/convention/src/main/java/com/github/andiim/plantscan/app/KotlinAndroid.kt @@ -0,0 +1,93 @@ +package com.github.andiim.plantscan.app + +import com.android.build.api.dsl.CommonExtension +import org.gradle.api.JavaVersion +import org.gradle.api.Project +import org.gradle.api.plugins.JavaPluginExtension +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.provideDelegate +import org.gradle.kotlin.dsl.withType +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +/** + * Configure base Kotlin with Android options + */ +internal fun Project.configureKotlinAndroid( + commonExtension: CommonExtension<*, *, *, *, *>, +) { + commonExtension.apply { + // compileSdk = 33 + compileSdk = libs.findVersion("compile.sdk.version").get().toString().toInt() + + defaultConfig { + // minSdk = 21 + minSdk = libs.findVersion("min.sdk.version").get().toString().toInt() + } + + lint { + baseline = file("./lint-baseline.xml") + abortOnError = false + } + + buildFeatures { + viewBinding = true + buildConfig = true + } + + compileOptions { + // Up to Java 11 APIs are available through desugaring + // https://developer.android.com/studio/write/java11-minimal-support-table + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + isCoreLibraryDesugaringEnabled = true + } + } + + configureKotlin() + + dependencies { + add("coreLibraryDesugaring", libs.findLibrary("android.desugarJdkLibs").get()) + } +} + +/** + * Configure base Kotlin options for JVM (non-Android) + */ +internal fun Project.configureKotlinJvm() { + extensions.configure { + // Up to Java 11 APIs are available through desugaring + // https://developer.android.com/studio/write/java11-minimal-support-table + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + + // BEFORE JavaVersion.VERSION_17 + } + + configureKotlin() +} + +/** + * Configure base Kotlin options + */ +private fun Project.configureKotlin() { + // Use withType to workaround https://youtrack.jetbrains.com/issue/KT-55947 + tasks.withType().configureEach { + kotlinOptions { + // Set JVM target to 11 + jvmTarget = JavaVersion.VERSION_11.toString() + // BEFORE JavaVersion.VERSION_17 + + // Treat all Kotlin warnings as errors (disabled by default) + // Override by setting warningsAsErrors=true in your ~/.gradle/gradle.properties + val warningsAsErrors: String? by project + allWarningsAsErrors = warningsAsErrors.toBoolean() + freeCompilerArgs = freeCompilerArgs + listOf( + "-opt-in=kotlin.RequiresOptIn", + // Enable experimental coroutines APIs, including Flow + "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", + "-opt-in=kotlinx.coroutines.FlowPreview", + ) + } + } +} \ No newline at end of file diff --git a/build-logic/convention/src/main/java/com/github/andiim/plantscan/app/PrintTestApks.kt b/build-logic/convention/src/main/java/com/github/andiim/plantscan/app/PrintTestApks.kt new file mode 100644 index 00000000..2f6468d9 --- /dev/null +++ b/build-logic/convention/src/main/java/com/github/andiim/plantscan/app/PrintTestApks.kt @@ -0,0 +1,82 @@ +package com.github.andiim.plantscan.app + +import com.android.build.api.artifact.SingleArtifact +import com.android.build.api.variant.AndroidComponentsExtension +import com.android.build.api.variant.BuiltArtifactsLoader +import com.android.build.api.variant.HasAndroidTest +import java.io.File +import org.gradle.api.DefaultTask +import org.gradle.api.Project +import org.gradle.api.file.Directory +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.TaskAction + +internal fun Project.configurePrintApksTask(extension: AndroidComponentsExtension<*, *, *>) { + extension.onVariants { variant -> + if (variant is HasAndroidTest) { + val loader = variant.artifacts.getBuiltArtifactsLoader() + val artifact = variant.androidTest?.artifacts?.get(SingleArtifact.APK) + val javaSources = variant.androidTest?.sources?.java?.all + val kotlinSources = variant.androidTest?.sources?.kotlin?.all + + val testSources = if (javaSources != null && kotlinSources != null) { + javaSources.zip(kotlinSources) { javaDirs, kotlinDirs -> + javaDirs + kotlinDirs + } + } else javaSources ?: kotlinSources + + if (artifact != null && testSources != null) { + tasks.register( + "${variant.name}PrintTestApk", + PrintApkLocationTask::class.java + ) { + apkFolder.set(artifact) + builtArtifactsLoader.set(loader) + variantName.set(variant.name) + sources.set(testSources) + } + } + } + } +} + +internal abstract class PrintApkLocationTask : DefaultTask() { + @get:InputDirectory + abstract val apkFolder: DirectoryProperty + + @get:InputFiles + abstract val sources: ListProperty + + @get:Internal + abstract val builtArtifactsLoader: Property + + @get:Input + abstract val variantName: Property + + @TaskAction + fun taskAction() { + val hasFiles = sources.orNull?.any { directory -> + directory.asFileTree.files.any { + it.isFile && it.parentFile.path.contains("build${File.separator}generated").not() + } + } ?: throw RuntimeException("Cannot check androidTest sources") + + // Don't print APK location if there are no androidTest source files + if (!hasFiles) { + return + } + + val builtArtifacts = builtArtifactsLoader.get().load(apkFolder.get()) + ?: throw RuntimeException("Cannot load APKs") + if (builtArtifacts.elements.size != 1) + throw RuntimeException("Expected one APK !") + val apk = File(builtArtifacts.elements.single().outputFile).toPath() + println(apk) + } +} \ No newline at end of file diff --git a/build-logic/convention/src/main/java/com/github/andiim/plantscan/app/ProjectExtensions.kt b/build-logic/convention/src/main/java/com/github/andiim/plantscan/app/ProjectExtensions.kt new file mode 100644 index 00000000..bed952b8 --- /dev/null +++ b/build-logic/convention/src/main/java/com/github/andiim/plantscan/app/ProjectExtensions.kt @@ -0,0 +1,9 @@ +package com.github.andiim.plantscan.app + +import org.gradle.api.Project +import org.gradle.api.artifacts.VersionCatalog +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.kotlin.dsl.getByType + +val Project.libs + get(): VersionCatalog = extensions.getByType().named("libs") \ No newline at end of file diff --git a/build-logic/convention/src/main/java/com/github/andiim/plantscan/app/PsBuildType.kt b/build-logic/convention/src/main/java/com/github/andiim/plantscan/app/PsBuildType.kt new file mode 100644 index 00000000..c8e9c8f9 --- /dev/null +++ b/build-logic/convention/src/main/java/com/github/andiim/plantscan/app/PsBuildType.kt @@ -0,0 +1,10 @@ +package com.github.andiim.plantscan.app + +/** + * This is shared between :app and :benchmarks module to provide configurations type safety. + */ +enum class PsBuildType(val applicationIdSuffix: String? = null) { + DEBUG(".debug"), + RELEASE, + BENCHMARK(".benchmark") +} diff --git a/build-logic/convention/src/main/java/com/github/andiim/plantscan/app/PsFlavor.kt b/build-logic/convention/src/main/java/com/github/andiim/plantscan/app/PsFlavor.kt new file mode 100644 index 00000000..ed38b45d --- /dev/null +++ b/build-logic/convention/src/main/java/com/github/andiim/plantscan/app/PsFlavor.kt @@ -0,0 +1,42 @@ +package com.github.andiim.plantscan.app + +import com.android.build.api.dsl.ApplicationExtension +import com.android.build.api.dsl.ApplicationProductFlavor +import com.android.build.api.dsl.CommonExtension +import com.android.build.api.dsl.ProductFlavor + +@Suppress("EnumEntryName") +enum class FlavorDimension { + contentType +} + +// The content for the app can either come from local static data which is useful for demo +// purposes, or from a production backend server which supplies up-to-date, real content. +// These two product flavors reflect this behaviour. +@Suppress("EnumEntryName") +enum class PsFlavor(val dimension: FlavorDimension, val applicationIdSuffix: String? = null) { + demo(FlavorDimension.contentType, applicationIdSuffix = ".demo"), + prod(FlavorDimension.contentType) +} + +fun configureFlavors( + commonExtension: CommonExtension<*, *, *, *, *>, + flavorConfigurationBlock: ProductFlavor.(flavor: PsFlavor) -> Unit = {} +) { + commonExtension.apply { + flavorDimensions += FlavorDimension.contentType.name + productFlavors { + PsFlavor.values().forEach { + create(it.name) { + dimension = it.dimension.name + flavorConfigurationBlock(this, it) + if (this@apply is ApplicationExtension && this is ApplicationProductFlavor) { + if (it.applicationIdSuffix != null) { + applicationIdSuffix = it.applicationIdSuffix + } + } + } + } + } + } +} diff --git a/build-logic/gradle.properties b/build-logic/gradle.properties new file mode 100644 index 00000000..1c9073eb --- /dev/null +++ b/build-logic/gradle.properties @@ -0,0 +1,4 @@ +# Gradle properties are not passed to included builds https://github.com/gradle/gradle/issues/2534 +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.configureondemand=true diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts new file mode 100644 index 00000000..2907fbfb --- /dev/null +++ b/build-logic/settings.gradle.kts @@ -0,0 +1,14 @@ +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} + +rootProject.name = "build-logic" +include(":convention") diff --git a/build.gradle.kts b/build.gradle.kts index 25218b19..857642f1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,16 +1,53 @@ import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask +import io.gitlab.arturbosch.detekt.Detekt + +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath(libs.google.oss.licenses.plugin) { + exclude(group = "com.google.protobuf") + } + classpath(libs.agp) + classpath(libs.kgp) + classpath(libs.dhp) + classpath(libs.ksp.plugin) + classpath(libs.firebase.crashlytics.plugin) + classpath(libs.firebase.perf.plugin) + classpath(libs.firebase.appdistribution.plugin) + } +} + +@Suppress("DSL_SCOPE_VIOLATION") plugins { - id("com.android.application") apply false - id("com.android.library") apply false - kotlin("android") apply false - kotlin("kapt") apply false - id("com.google.gms.google-services") version "4.3.15" apply false - id("com.google.dagger.hilt.android") apply false - id("com.google.firebase.crashlytics") apply false - id("com.google.firebase.firebase-perf") version "1.4.2" apply false + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.application.compose) apply false + alias(libs.plugins.android.application.jacoco) apply false + alias(libs.plugins.android.application.firebase) apply false + alias(libs.plugins.android.application.flavors) apply false + alias(libs.plugins.android.feature) apply false + alias(libs.plugins.android.hilt) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.android.library.compose) apply false + alias(libs.plugins.android.library.jacoco) apply false + alias(libs.plugins.android.room) apply false + alias(libs.plugins.android.test) apply false + alias(libs.plugins.com.android.library) apply false alias(libs.plugins.detekt) + alias(libs.plugins.gms) apply false + alias(libs.plugins.jvm.library) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.modulegraph) + alias(libs.plugins.org.jetbrains.kotlin.android) apply false + alias(libs.plugins.secrets) apply false alias(libs.plugins.versions) + alias(libs.plugins.com.android.test) apply false + id("project-report") + alias(libs.plugins.com.android.application) apply false base } @@ -19,11 +56,29 @@ allprojects { } val detektFormatting: Provider = libs.detekt.formatting +val detektCompose: Provider = libs.detekt.compose subprojects { - apply { plugin("io.gitlab.arturbosch.detekt") } - detekt { config.setFrom(rootProject.files("config/detekt/detekt.yml")) } - dependencies { detektPlugins(detektFormatting) } + apply { + plugin("io.gitlab.arturbosch.detekt") + plugin("dev.iurysouza.modulegraph") + } + detekt { + autoCorrect = true + buildUponDefaultConfig = true + allRules = false + config.setFrom(rootProject.files("config/detekt/detekt.yml")) + } + dependencies { + detektPlugins(detektFormatting) + detektPlugins(detektCompose) + } + + moduleGraphConfig { + readmePath.set("./README.md") + heading.set("### Dependency Diagram") + theme.set(dev.iurysouza.modulegraph.Theme.NEUTRAL) + } } tasks { @@ -32,4 +87,24 @@ tasks { candidate.version.isStableVersion().not() } } + + withType().configureEach { + jvmTarget = "1.8" + + reports { + html.required = true + } + } + + withType().configureEach{ + notCompatibleWithConfigurationCache("https://github.com/gradle/gradle/issues/13470") + } + + named("check").configure { + this.setDependsOn(this.dependsOn.filterNot { + it is TaskProvider<*> && it.name == "detekt" + }) + } } + + diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts deleted file mode 100644 index f640cb85..00000000 --- a/buildSrc/build.gradle.kts +++ /dev/null @@ -1,20 +0,0 @@ -plugins { - `kotlin-dsl` -} -repositories { - google() - mavenCentral() -} - -dependencies { - implementation(libs.kgp) - implementation(libs.agp) - implementation(libs.dhp) - implementation(libs.firebase.crashlytics.plugin) -} - -tasks.withType().configureEach { - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() - } -} \ No newline at end of file diff --git a/buildSrc/settings.gradle.kts b/buildSrc/settings.gradle.kts deleted file mode 100644 index 197979d3..00000000 --- a/buildSrc/settings.gradle.kts +++ /dev/null @@ -1,5 +0,0 @@ -dependencyResolutionManagement { - versionCatalogs { create("libs") { from(files("../gradle/libs.versions.toml")) } } -} - -rootProject.name = ("buildSrc") diff --git a/buildSrc/src/main/java/Coordinates.kt b/buildSrc/src/main/java/Coordinates.kt deleted file mode 100644 index a9efb57c..00000000 --- a/buildSrc/src/main/java/Coordinates.kt +++ /dev/null @@ -1,17 +0,0 @@ -const val PUBLISHING_GROUP = "com.github.andiim.plantscan" - -object AppCoordinates { - const val APP_ID = "com.github.andiim.plantscan.app" - - const val APP_VERSION_NAME = "1.0.0" - const val APP_VERSION_CODE = 1 -} - -object LibraryAndroidCoordinates { - const val LIBRARY_VERSION = "1.0.0" - const val LIBRARY_VERSION_CODE = 1 -} - -object LibraryKotlinCoordinates { - const val LIBRARY_VERSION = "1.0.0" -} diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index f017b123..8b245ebe 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -1,5 +1,5 @@ build: - maxIssues: 0 + maxIssues: 100 excludeCorrectable: false weights: # complexity: 2 @@ -8,7 +8,7 @@ build: # comments: 1 config: - validation: true + validation: false warningsAsErrors: false checkExhaustiveness: false # when writing own rules with new properties, exclude the property path e.g.: 'my_rule_set,.*>.*>[my_property]' @@ -44,7 +44,7 @@ console-reports: output-reports: active: true exclude: - # - 'TxtOutputReport' + # - 'TxtOutputReport' # - 'XmlOutputReport' # - 'HtmlOutputReport' # - 'MdOutputReport' @@ -62,7 +62,7 @@ comments: DeprecatedBlockTag: active: false EndOfSentenceFormat: - active: false + active: true endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)' KDocReferencesNonPublicProperty: active: false @@ -105,7 +105,7 @@ complexity: ignoreOverloaded: false CyclomaticComplexMethod: active: true - threshold: 15 + threshold: 28 ignoreSingleWhenExpression: false ignoreSimpleWhenEntries: false ignoreNestingFunctions: false @@ -130,9 +130,9 @@ complexity: threshold: 60 LongParameterList: active: true - functionThreshold: 6 - constructorThreshold: 7 - ignoreDefaultParameters: false + functionThreshold: 10 + constructorThreshold: 11 + ignoreDefaultParameters: true ignoreDataClasses: true ignoreAnnotatedParameter: [] MethodOverloading: @@ -166,12 +166,12 @@ complexity: TooManyFunctions: active: true excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] - thresholdInFiles: 11 - thresholdInClasses: 11 - thresholdInInterfaces: 11 - thresholdInObjects: 11 - thresholdInEnums: 11 - ignoreDeprecated: false + thresholdInFiles: 20 + thresholdInClasses: 20 + thresholdInInterfaces: 20 + thresholdInObjects: 20 + thresholdInEnums: 220 + ignoreDeprecated: true ignorePrivate: false ignoreOverridden: false @@ -305,7 +305,6 @@ naming: BooleanPropertyNaming: active: false allowedPattern: '^(is|has|are)' - ignoreOverridden: true ClassNaming: active: true classPattern: '[A-Z][a-zA-Z0-9]*' @@ -314,7 +313,6 @@ naming: parameterPattern: '[a-z][A-Za-z0-9]*' privateParameterPattern: '[a-z][A-Za-z0-9]*' excludeClassPattern: '$^' - ignoreOverridden: true EnumNaming: active: true enumEntryPattern: '[A-Z][_a-zA-Z0-9]*' @@ -330,15 +328,13 @@ naming: FunctionNaming: active: true excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] - functionPattern: '[a-z][a-zA-Z0-9]*' + functionPattern: '[a-zA-Z][a-zA-Z0-9]*' excludeClassPattern: '$^' - ignoreOverridden: true - ignoreAnnotated: 'Composable' + ignoreAnnotated: ['Composable'] FunctionParameterNaming: - active: true + active: false parameterPattern: '[a-z][A-Za-z0-9]*' excludeClassPattern: '$^' - ignoreOverridden: true InvalidPackageDeclaration: active: true rootPackage: '' @@ -380,7 +376,6 @@ naming: variablePattern: '[a-z][A-Za-z0-9]*' privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*' excludeClassPattern: '$^' - ignoreOverridden: true performance: active: true @@ -531,12 +526,14 @@ style: includeLineWrapping: false ForbiddenComment: active: true - values: - - 'FIXME:' - - 'STOPSHIP:' - - 'TODO:' - allowedPatterns: '' - customMessage: '' + comments: + # Repeat the default configuration if it's still needed. + - reason: 'Forbidden FIXME todo marker in comment, please fix the problem.' + value: 'FIXME:' + - reason: 'Forbidden STOPSHIP todo marker in comment, please address the problem before shipping the code.' + value: 'STOPSHIP:' + - reason: 'Forbidden TODO todo marker in comment, please do the changes.' + value: 'TODO:' ForbiddenImport: active: false imports: [] @@ -572,8 +569,8 @@ style: - '1' - '2' ignoreHashCodeFunction: true - ignorePropertyDeclaration: false - ignoreLocalVariableDeclaration: false + ignorePropertyDeclaration: true + ignoreLocalVariableDeclaration: true ignoreConstantDeclaration: true ignoreCompanionObjectPropertyDeclaration: true ignoreAnnotation: false @@ -581,8 +578,6 @@ style: ignoreEnums: false ignoreRanges: false ignoreExtensionFunctions: true - MandatoryBracesIfStatements: - active: false MandatoryBracesLoops: active: false MaxChainedCallsOnSameLine: @@ -632,7 +627,7 @@ style: active: false ReturnCount: active: true - max: 2 + max: 3 excludedFunctions: - 'equals' excludeLabeled: false @@ -684,6 +679,7 @@ style: UnusedPrivateMember: active: true allowedNames: '(_|ignored|expected|serialVersionUID)' + ignoreAnnotated: ['Preview'] UseAnyOrNoneInsteadOfFind: active: true UseArrayLiteralsInAnnotations: @@ -731,6 +727,7 @@ formatting: AnnotationOnSeparateLine: active: true autoCorrect: true + indentSize: 4 AnnotationSpacing: active: true autoCorrect: true @@ -739,18 +736,34 @@ formatting: autoCorrect: true indentSize: 4 maxLineLength: 120 - BlockCommentInitialStarAlignment: + BinaryExpressionWrapping: active: false autoCorrect: true + maxLineLength: 120 + indentSize: 4 + BlankLineBeforeDeclaration: + active: false + autoCorrect: true + BlockCommentInitialStarAlignment: + active: true + autoCorrect: true ChainWrapping: active: true autoCorrect: true + indentSize: 4 + ClassName: + active: false CommentSpacing: active: true autoCorrect: true CommentWrapping: + active: true + autoCorrect: true + indentSize: 4 + ContextReceiverMapping: active: false autoCorrect: true + maxLineLength: 120 indentSize: 4 DiscouragedCommentLocation: active: false @@ -758,6 +771,10 @@ formatting: EnumEntryNameCase: active: true autoCorrect: true + EnumWrapping: + active: false + autoCorrect: true + indentSize: 4 Filename: active: true FinalNewline: @@ -765,11 +782,14 @@ formatting: autoCorrect: true insertFinalNewLine: true FunKeywordSpacing: - active: false + active: true autoCorrect: true - FunctionReturnTypeSpacing: + FunctionName: active: false + FunctionReturnTypeSpacing: + active: true autoCorrect: true + maxLineLength: 120 FunctionSignature: active: false autoCorrect: true @@ -778,11 +798,19 @@ formatting: maxLineLength: 120 indentSize: 4 FunctionStartOfBodySpacing: - active: false + active: true autoCorrect: true FunctionTypeReferenceSpacing: + active: true + autoCorrect: true + IfElseBracing: + active: false + autoCorrect: true + indentSize: 4 + IfElseWrapping: active: false autoCorrect: true + indentSize: 4 ImportOrdering: active: true autoCorrect: true @@ -792,7 +820,7 @@ formatting: autoCorrect: true indentSize: 4 KdocWrapping: - active: false + active: true autoCorrect: true indentSize: 4 MaximumLineLength: @@ -800,7 +828,7 @@ formatting: maxLineLength: 120 ignoreBackTickedIdentifier: false ModifierListSpacing: - active: false + active: true autoCorrect: true ModifierOrdering: active: true @@ -808,18 +836,34 @@ formatting: MultiLineIfElse: active: true autoCorrect: true + indentSize: 4 + MultilineExpressionWrapping: + active: false + autoCorrect: true + indentSize: 4 NoBlankLineBeforeRbrace: active: true autoCorrect: true + NoBlankLineInList: + active: false + autoCorrect: true NoBlankLinesInChainedMethodCalls: active: true autoCorrect: true NoConsecutiveBlankLines: active: true autoCorrect: true + NoConsecutiveComments: + active: false NoEmptyClassBody: active: true autoCorrect: true + NoEmptyFile: + active: false + NoEmptyFirstLineInClassBody: + active: false + autoCorrect: true + indentSize: 4 NoEmptyFirstLineInMethodBlock: active: true autoCorrect: true @@ -835,6 +879,10 @@ formatting: NoSemicolons: active: true autoCorrect: true + NoSingleLineBlockComment: + active: false + autoCorrect: true + indentSize: 4 NoTrailingSpaces: active: true autoCorrect: true @@ -848,11 +896,10 @@ formatting: active: true packagesToUseImportOnDemandProperty: 'java.util.*,kotlinx.android.synthetic.**' NullableTypeSpacing: - active: false + active: true autoCorrect: true PackageName: active: true - autoCorrect: true ParameterListSpacing: active: false autoCorrect: true @@ -860,6 +907,19 @@ formatting: active: true autoCorrect: true maxLineLength: 120 + indentSize: 4 + ParameterWrapping: + active: true + autoCorrect: true + indentSize: 4 + maxLineLength: 120 + PropertyName: + active: false + PropertyWrapping: + active: true + autoCorrect: true + indentSize: 4 + maxLineLength: 120 SpacingAroundAngleBrackets: active: true autoCorrect: true @@ -900,11 +960,19 @@ formatting: active: true autoCorrect: true SpacingBetweenFunctionNameAndOpeningParenthesis: + active: true + autoCorrect: true + StatementWrapping: active: false autoCorrect: true + indentSize: 4 StringTemplate: active: true autoCorrect: true + StringTemplateIndent: + active: false + autoCorrect: true + indentSize: 4 TrailingCommaOnCallSite: active: false autoCorrect: true @@ -913,16 +981,69 @@ formatting: active: false autoCorrect: true useTrailingCommaOnDeclarationSite: true + TryCatchFinallySpacing: + active: false + autoCorrect: true + indentSize: 4 TypeArgumentListSpacing: active: false autoCorrect: true + indentSize: 4 TypeParameterListSpacing: active: false autoCorrect: true + indentSize: 4 UnnecessaryParenthesesBeforeTrailingLambda: - active: false + active: true autoCorrect: true Wrapping: active: true autoCorrect: true - indentSize: 4 \ No newline at end of file + indentSize: 4 + maxLineLength: 120 + +# https://github.com/mrmans0n/compose-rules/blob/main/rules/detekt/src/main/resources/config/config.yml +Compose: + CompositionLocalAllowlist: + active: true + allowedCompositionLocals: LocalAnalyticsHelper, LocalTintTheme, LocalBackgroundtheme + ContentEmitterReturningValues: + active: true + DefaultsVisibility: + active: true + ModifierClickableOrder: + active: true + ModifierComposable: + active: true + ModifierMissing: + active: true + ModifierNaming: + active: true + ModifierNotUsedAtRoot: + active: true + ModifierReused: + active: true + ModifierWithoutDefault: + active: true + MultipleEmitters: + active: true + MutableParams: + active: true + ComposableNaming: + active: true + ComposableParamOrder: + active: true + PreviewAnnotationNaming: + active: false + PreviewPublic: + active: false + RememberMissing: + active: true + RememberContentMissing: + active: true + UnstableCollections: + active: true + ViewModelForwarding: + active: true + ViewModelInjection: + active: true \ No newline at end of file diff --git a/core/analytics/.gitignore b/core/analytics/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/analytics/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/analytics/build.gradle.kts b/core/analytics/build.gradle.kts new file mode 100644 index 00000000..eb014ce3 --- /dev/null +++ b/core/analytics/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.android.library.compose) + alias(libs.plugins.android.hilt) +} + +android { + namespace = "com.github.andiim.plantscan.core.analytics" +} + +dependencies { + implementation(platform(libs.firebase.bom)) + implementation(libs.compose.runtime) + implementation(libs.androidx.core.ktx) + implementation(libs.firebase.analytics) + implementation(libs.kotlin.coroutines.android) +} diff --git a/core/analytics/src/demo/java/com/github/andiim/plantscan/core/analytics/AnalyticsModule.kt b/core/analytics/src/demo/java/com/github/andiim/plantscan/core/analytics/AnalyticsModule.kt new file mode 100644 index 00000000..6661b7de --- /dev/null +++ b/core/analytics/src/demo/java/com/github/andiim/plantscan/core/analytics/AnalyticsModule.kt @@ -0,0 +1,13 @@ +package com.github.andiim.plantscan.core.analytics + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +abstract class AnalyticsModule { + @Binds + abstract fun bindsAnalyticsHelper(analyticsHelperImpl: StubAnalyticsHelper): AnalyticsHelper +} diff --git a/core/analytics/src/main/AndroidManifest.xml b/core/analytics/src/main/AndroidManifest.xml new file mode 100644 index 00000000..568741e5 --- /dev/null +++ b/core/analytics/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/core/analytics/src/main/java/com/github/andiim/plantscan/core/analytics/AnalyticsEvent.kt b/core/analytics/src/main/java/com/github/andiim/plantscan/core/analytics/AnalyticsEvent.kt new file mode 100644 index 00000000..fbcbfa97 --- /dev/null +++ b/core/analytics/src/main/java/com/github/andiim/plantscan/core/analytics/AnalyticsEvent.kt @@ -0,0 +1,38 @@ +package com.github.andiim.plantscan.core.analytics + +/** + * Represents an analytics event. + * + * @param type - the event type. Wherever possible use one of the standard + * event `Types`, however, if there is no suitable event type already defined, a custom event can be + * defined as long as it is configured in your backend analytics system (for example, by creating a + * Firebase Analytics custom event). + * + * @param extras - list of parameters which supply additional context to the event. See `Param`. + */ +data class AnalyticsEvent( + val type: String, + val extras: List = emptyList(), +) { + // Standard analytics types. + object Types { + const val SCREEN_VIEW = "screen_view" // (extras: SCREEN_NAME) + } + + /** + * A key-value pair used to supply extra context to an analytics event. + * + * @param key - the parameter key. Wherever possible use one of the standard `ParamKeys`, + * however, if no suitable key is available you can define your own as long as it is configured + * in your backend analytics system (for example, by creating a Firebase Analytics custom + * parameter). + * + * @param value - the parameter value. + */ + data class Param(val key: String, val value: String) + + // Standard parameter keys. + object ParamKeys { + const val SCREEN_NAME = "screen_name" + } +} diff --git a/core/analytics/src/main/java/com/github/andiim/plantscan/core/analytics/AnalyticsHelper.kt b/core/analytics/src/main/java/com/github/andiim/plantscan/core/analytics/AnalyticsHelper.kt new file mode 100644 index 00000000..602846ba --- /dev/null +++ b/core/analytics/src/main/java/com/github/andiim/plantscan/core/analytics/AnalyticsHelper.kt @@ -0,0 +1,9 @@ +package com.github.andiim.plantscan.core.analytics + +/** + * Interface for logging analytics events. See `FirebaseAnalyticsHelper` and + * `StubAnalyticsHelper` for implementations. + */ +interface AnalyticsHelper { + fun logEvent(event: AnalyticsEvent) +} diff --git a/core/analytics/src/main/java/com/github/andiim/plantscan/core/analytics/NoOpAnalyticsHelper.kt b/core/analytics/src/main/java/com/github/andiim/plantscan/core/analytics/NoOpAnalyticsHelper.kt new file mode 100644 index 00000000..e2c6d724 --- /dev/null +++ b/core/analytics/src/main/java/com/github/andiim/plantscan/core/analytics/NoOpAnalyticsHelper.kt @@ -0,0 +1,8 @@ +package com.github.andiim.plantscan.core.analytics + +/** + * Implementation of AnalyticsHelper which does nothing. Useful for tests and previews. + */ +class NoOpAnalyticsHelper : AnalyticsHelper { + override fun logEvent(event: AnalyticsEvent) = Unit +} diff --git a/core/analytics/src/main/java/com/github/andiim/plantscan/core/analytics/StubAnalyticsHelper.kt b/core/analytics/src/main/java/com/github/andiim/plantscan/core/analytics/StubAnalyticsHelper.kt new file mode 100644 index 00000000..82ce3f29 --- /dev/null +++ b/core/analytics/src/main/java/com/github/andiim/plantscan/core/analytics/StubAnalyticsHelper.kt @@ -0,0 +1,18 @@ +package com.github.andiim.plantscan.core.analytics + +import android.util.Log +import javax.inject.Inject +import javax.inject.Singleton + +private const val TAG = "StubAnalyticsHelper" + +/** + * An implementation of AnalyticsHelper just writes the events to logcat. Used in builds where no + * analytics events should be sent to a backend. + */ +@Singleton +class StubAnalyticsHelper @Inject constructor() : AnalyticsHelper { + override fun logEvent(event: AnalyticsEvent) { + Log.d(TAG, "Received analytics event: $event") + } +} diff --git a/core/analytics/src/main/java/com/github/andiim/plantscan/core/analytics/UiHelpers.kt b/core/analytics/src/main/java/com/github/andiim/plantscan/core/analytics/UiHelpers.kt new file mode 100644 index 00000000..273a6798 --- /dev/null +++ b/core/analytics/src/main/java/com/github/andiim/plantscan/core/analytics/UiHelpers.kt @@ -0,0 +1,12 @@ +package com.github.andiim.plantscan.core.analytics + +import androidx.compose.runtime.staticCompositionLocalOf + +/** + * Global key used to obtain access to the AnalyticsHelper through a CompositionLocal. + */ +val LocalAnalyticsHelper = staticCompositionLocalOf { + // Provide a default AnalyticsHelper which does nothing. This is so that tests and previews + // do not have to provide one. For real app builds provide a different implementation. + NoOpAnalyticsHelper() +} diff --git a/core/analytics/src/prod/java/com/github/andiim/plantscan/core/analytics/AnalyticsModule.kt b/core/analytics/src/prod/java/com/github/andiim/plantscan/core/analytics/AnalyticsModule.kt new file mode 100644 index 00000000..4af9630f --- /dev/null +++ b/core/analytics/src/prod/java/com/github/andiim/plantscan/core/analytics/AnalyticsModule.kt @@ -0,0 +1,24 @@ +package com.github.andiim.plantscan.core.analytics + +import com.google.firebase.analytics.FirebaseAnalytics +import com.google.firebase.analytics.ktx.analytics +import com.google.firebase.ktx.Firebase +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class AnalyticsModule { + @Binds + abstract fun bindsAnalyticsHelper(analyticsHelperImpl: FirebaseAnalyticsHelper): AnalyticsHelper + + companion object { + @Provides + @Singleton + fun provideFirebaseAnalytics(): FirebaseAnalytics { return Firebase.analytics } + } +} diff --git a/core/analytics/src/prod/java/com/github/andiim/plantscan/core/analytics/FirebaseAnalyticsHelper.kt b/core/analytics/src/prod/java/com/github/andiim/plantscan/core/analytics/FirebaseAnalyticsHelper.kt new file mode 100644 index 00000000..91b302b2 --- /dev/null +++ b/core/analytics/src/prod/java/com/github/andiim/plantscan/core/analytics/FirebaseAnalyticsHelper.kt @@ -0,0 +1,26 @@ + +package com.github.andiim.plantscan.core.analytics + +import com.google.firebase.analytics.FirebaseAnalytics +import com.google.firebase.analytics.ktx.logEvent +import javax.inject.Inject + +/** + * Implementation of `AnalyticsHelper` which logs events to a Firebase backend. + */ +class FirebaseAnalyticsHelper @Inject constructor( + private val firebaseAnalytics: FirebaseAnalytics, +) : AnalyticsHelper { + + override fun logEvent(event: AnalyticsEvent) { + firebaseAnalytics.logEvent(event.type) { + for (extra in event.extras) { + // Truncate parameter keys and values according to firebase maximum length values. + param( + key = extra.key.take(40), + value = extra.value.take(100), + ) + } + } + } +} diff --git a/core/auth/.gitignore b/core/auth/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/auth/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/auth/build.gradle.kts b/core/auth/build.gradle.kts new file mode 100644 index 00000000..170b0c17 --- /dev/null +++ b/core/auth/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.android.library.jacoco) + alias(libs.plugins.android.hilt) + alias(libs.plugins.secrets) +} + +android { + buildFeatures { + buildConfig = true + } + namespace = "com.github.andiim.plantscan.core.auth" +} + +secrets { + defaultPropertiesFileName = "secrets.defaults.properties" +} + +dependencies { + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.auth) + implementation(libs.kotlin.coroutines.android) +} diff --git a/core/auth/src/demo/java/com/github/andiim/plantscan/core/auth/AuthModule.kt b/core/auth/src/demo/java/com/github/andiim/plantscan/core/auth/AuthModule.kt new file mode 100644 index 00000000..2a611cb1 --- /dev/null +++ b/core/auth/src/demo/java/com/github/andiim/plantscan/core/auth/AuthModule.kt @@ -0,0 +1,13 @@ +package com.github.andiim.plantscan.core.auth + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +abstract class AuthModule { + @Binds + abstract fun bindsAuthHelper(authHelper: StubAuthHelper): AuthHelper +} diff --git a/core/auth/src/main/AndroidManifest.xml b/core/auth/src/main/AndroidManifest.xml new file mode 100644 index 00000000..67d9f942 --- /dev/null +++ b/core/auth/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/core/auth/src/main/java/com/github/andiim/plantscan/core/auth/AuthHelper.kt b/core/auth/src/main/java/com/github/andiim/plantscan/core/auth/AuthHelper.kt new file mode 100644 index 00000000..51d72255 --- /dev/null +++ b/core/auth/src/main/java/com/github/andiim/plantscan/core/auth/AuthHelper.kt @@ -0,0 +1,16 @@ +package com.github.andiim.plantscan.core.auth + +import com.github.andiim.plantscan.core.auth.model.User +import kotlinx.coroutines.flow.Flow + +interface AuthHelper { + val currentUserId: String + val hasUser: Boolean + val currentUser: Flow + fun authenticate(email: String, password: String): Flow + fun sendRecoveryEmail(email: String): Flow + fun createAnonymousAccount(): Flow + fun linkAccount(email: String, password: String): Flow + fun deleteAccount(): Flow + fun signOut(): Flow +} diff --git a/core/auth/src/main/java/com/github/andiim/plantscan/core/auth/StubAuthHelper.kt b/core/auth/src/main/java/com/github/andiim/plantscan/core/auth/StubAuthHelper.kt new file mode 100644 index 00000000..f3724642 --- /dev/null +++ b/core/auth/src/main/java/com/github/andiim/plantscan/core/auth/StubAuthHelper.kt @@ -0,0 +1,45 @@ +package com.github.andiim.plantscan.core.auth + +import com.github.andiim.plantscan.core.auth.model.User +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import javax.inject.Inject +import javax.inject.Singleton + +/** + * An implementation of [AuthHelper] that passes an user to app. No data just sent to a backend. + */ +@Singleton +class StubAuthHelper @Inject constructor() : AuthHelper { + private val _currentUser = MutableStateFlow(User()) + override val currentUser: Flow = _currentUser.asStateFlow() + override val currentUserId: String + get() = _currentUser.value.id + + override val hasUser: Boolean + get() = true + + override fun authenticate(email: String, password: String): Flow = flow { + _currentUser.value = _currentUser.value.copy(isAnonymous = false) + } + + override fun sendRecoveryEmail(email: String): Flow = flowOf() + override fun createAnonymousAccount(): Flow = flow { + _currentUser.value = _currentUser.value.copy(id = "demo", isAnonymous = true) + } + + override fun linkAccount(email: String, password: String): Flow = flow { + _currentUser.value = _currentUser.value.copy(isAnonymous = false) + } + + override fun deleteAccount(): Flow = flow { + _currentUser.value = _currentUser.value.copy(isAnonymous = true) + } + + override fun signOut(): Flow = flow { + _currentUser.value = _currentUser.value.copy(isAnonymous = true) + } +} diff --git a/app/src/main/java/com/github/andiim/plantscan/app/data/model/User.kt b/core/auth/src/main/java/com/github/andiim/plantscan/core/auth/model/User.kt similarity index 57% rename from app/src/main/java/com/github/andiim/plantscan/app/data/model/User.kt rename to core/auth/src/main/java/com/github/andiim/plantscan/core/auth/model/User.kt index 26846451..5619f705 100644 --- a/app/src/main/java/com/github/andiim/plantscan/app/data/model/User.kt +++ b/core/auth/src/main/java/com/github/andiim/plantscan/core/auth/model/User.kt @@ -1,3 +1,3 @@ -package com.github.andiim.plantscan.app.data.model +package com.github.andiim.plantscan.core.auth.model data class User(val id: String = "", val isAnonymous: Boolean = true) diff --git a/core/auth/src/prod/java/com/github/andiim/plantscan/core/auth/AuthModule.kt b/core/auth/src/prod/java/com/github/andiim/plantscan/core/auth/AuthModule.kt new file mode 100644 index 00000000..94f56698 --- /dev/null +++ b/core/auth/src/prod/java/com/github/andiim/plantscan/core/auth/AuthModule.kt @@ -0,0 +1,35 @@ +package com.github.andiim.plantscan.core.auth + +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.ktx.auth +import com.google.firebase.ktx.Firebase +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class AuthModule { + @Binds + abstract fun bindsAuthHelper( + authHelperImpl: FirebaseAuthHelper, + ): AuthHelper + + companion object { + private const val HOST = "10.0.2.2" + private const val PORT = 9099 + + @Provides + @Singleton + fun provideFirebaseAuth(): FirebaseAuth { + return Firebase.auth.also { + if (BuildConfig.USE_EMULTAOR.toBoolean()) { + it.useEmulator(HOST, PORT) + } + } + } + } +} diff --git a/core/auth/src/prod/java/com/github/andiim/plantscan/core/auth/FirebaseAuthHelper.kt b/core/auth/src/prod/java/com/github/andiim/plantscan/core/auth/FirebaseAuthHelper.kt new file mode 100644 index 00000000..354eab8f --- /dev/null +++ b/core/auth/src/prod/java/com/github/andiim/plantscan/core/auth/FirebaseAuthHelper.kt @@ -0,0 +1,65 @@ +package com.github.andiim.plantscan.core.auth + +import com.github.andiim.plantscan.core.auth.model.User +import com.google.firebase.auth.EmailAuthProvider +import com.google.firebase.auth.FirebaseAuth +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.tasks.await +import javax.inject.Inject + +/** + * Implementation of `AuthHelper` which logs events to a Firebase backend. + */ +class FirebaseAuthHelper @Inject constructor( + private val auth: FirebaseAuth, +) : AuthHelper { + override val currentUserId: String + get() = auth.currentUser?.uid.orEmpty() + + override val hasUser: Boolean + get() = auth.currentUser != null + + override val currentUser: Flow = callbackFlow { + val listener = + FirebaseAuth.AuthStateListener { auth -> + trySend( + auth.currentUser?.let { User(it.uid, it.isAnonymous) } ?: User(), + ) + } + auth.addAuthStateListener(listener) + awaitClose { auth.removeAuthStateListener(listener) } + } + + override fun authenticate(email: String, password: String): Flow = flow { + auth.signInWithEmailAndPassword(email, password).await() + } + + override fun sendRecoveryEmail(email: String): Flow = flow { + auth.sendPasswordResetEmail(email).await() + } + + override fun createAnonymousAccount(): Flow = flow { + auth.signInAnonymously().await() + } + + override fun linkAccount(email: String, password: String): Flow = flow { + val credential = EmailAuthProvider.getCredential(email, password) + auth.currentUser!!.linkWithCredential(credential).await() + } + + override fun deleteAccount(): Flow = flow { + auth.currentUser!!.delete().await() + } + + override fun signOut(): Flow = flow { + if (auth.currentUser!!.isAnonymous) { + auth.currentUser!!.delete() + createAnonymousAccount() + } else { + auth.signOut() + } + } +} diff --git a/core/common/.gitignore b/core/common/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/common/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/common/README.md b/core/common/README.md new file mode 100644 index 00000000..4351dbf7 --- /dev/null +++ b/core/common/README.md @@ -0,0 +1 @@ +# :core:common module \ No newline at end of file diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts new file mode 100644 index 00000000..42bea4d9 --- /dev/null +++ b/core/common/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.android.library.jacoco) + alias(libs.plugins.android.hilt) +} + +android { + namespace = "com.github.andiim.plantscan.core.common" +} + +dependencies { + implementation(libs.kotlin.coroutines.android) + implementation(libs.kotlinx.serialization.json) + testImplementation(project(":core:testing")) +} diff --git a/core/common/src/main/AndroidManifest.xml b/core/common/src/main/AndroidManifest.xml new file mode 100644 index 00000000..568741e5 --- /dev/null +++ b/core/common/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/core/common/src/main/java/com/github/andiim/plantscan/core/bitmap/BitmapExtension.kt b/core/common/src/main/java/com/github/andiim/plantscan/core/bitmap/BitmapExtension.kt new file mode 100644 index 00000000..bc2440cd --- /dev/null +++ b/core/common/src/main/java/com/github/andiim/plantscan/core/bitmap/BitmapExtension.kt @@ -0,0 +1,39 @@ +package com.github.andiim.plantscan.core.bitmap + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.ImageDecoder +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import java.io.ByteArrayOutputStream +import java.util.Base64 + +fun Context.getBitmap(uri: Uri): Bitmap { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + ImageDecoder.decodeBitmap( + ImageDecoder.createSource( + contentResolver, + uri, + ), + ) { decoder, _, _ -> + decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE + decoder.isMutableRequired = true + } + } else { + @Suppress("DEPRECATION") + MediaStore.Images.Media.getBitmap(contentResolver, uri) + } +} + +fun Bitmap.asBase64(compressionLevel: Int = 20): String { + val outputStream = ByteArrayOutputStream() + compress(Bitmap.CompressFormat.JPEG, compressionLevel, outputStream) + return Base64.getEncoder().encodeToString(outputStream.toByteArray()) +} + +fun String.asImageFromBase64(): Bitmap { + val byteArray = Base64.getDecoder().decode(this) + return BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size) +} diff --git a/core/common/src/main/java/com/github/andiim/plantscan/core/json/JsonModule.kt b/core/common/src/main/java/com/github/andiim/plantscan/core/json/JsonModule.kt new file mode 100644 index 00000000..50daa45c --- /dev/null +++ b/core/common/src/main/java/com/github/andiim/plantscan/core/json/JsonModule.kt @@ -0,0 +1,18 @@ +package com.github.andiim.plantscan.core.json + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.json.Json +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object JsonModule { + @Provides + @Singleton + fun providesJson(): Json = Json { + ignoreUnknownKeys = true + } +} diff --git a/core/common/src/main/java/com/github/andiim/plantscan/core/network/AppDispatchers.kt b/core/common/src/main/java/com/github/andiim/plantscan/core/network/AppDispatchers.kt new file mode 100644 index 00000000..402d33bb --- /dev/null +++ b/core/common/src/main/java/com/github/andiim/plantscan/core/network/AppDispatchers.kt @@ -0,0 +1,13 @@ +package com.github.andiim.plantscan.core.network + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class Dispatcher(val dispatcher: AppDispatchers) + +enum class AppDispatchers { + Default, + IO, + Main +} diff --git a/core/common/src/main/java/com/github/andiim/plantscan/core/network/di/CoroutinesScopesModule.kt b/core/common/src/main/java/com/github/andiim/plantscan/core/network/di/CoroutinesScopesModule.kt new file mode 100644 index 00000000..56783821 --- /dev/null +++ b/core/common/src/main/java/com/github/andiim/plantscan/core/network/di/CoroutinesScopesModule.kt @@ -0,0 +1,28 @@ +package com.github.andiim.plantscan.core.network.di + +import com.github.andiim.plantscan.core.network.AppDispatchers.Default +import com.github.andiim.plantscan.core.network.Dispatcher +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import javax.inject.Qualifier +import javax.inject.Singleton + +@Retention(AnnotationRetention.RUNTIME) +@Qualifier +annotation class ApplicationScope + +@Module +@InstallIn(SingletonComponent::class) +object CoroutineScopesModule { + @Provides + @Singleton + @ApplicationScope + fun providesCoroutineScope( + @Dispatcher(Default) dispatcher: CoroutineDispatcher, + ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) +} diff --git a/core/common/src/main/java/com/github/andiim/plantscan/core/network/di/DispatchersModule.kt b/core/common/src/main/java/com/github/andiim/plantscan/core/network/di/DispatchersModule.kt new file mode 100644 index 00000000..d0e0aa33 --- /dev/null +++ b/core/common/src/main/java/com/github/andiim/plantscan/core/network/di/DispatchersModule.kt @@ -0,0 +1,23 @@ +package com.github.andiim.plantscan.core.network.di + +import com.github.andiim.plantscan.core.network.AppDispatchers.Default +import com.github.andiim.plantscan.core.network.AppDispatchers.IO +import com.github.andiim.plantscan.core.network.Dispatcher +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +@Module +@InstallIn(SingletonComponent::class) +object DispatchersModule { + @Provides + @Dispatcher(IO) + fun providesIODispatcher(): CoroutineDispatcher = Dispatchers.IO + + @Provides + @Dispatcher(Default) + fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default +} diff --git a/core/common/src/main/java/com/github/andiim/plantscan/core/resource/Resource.kt b/core/common/src/main/java/com/github/andiim/plantscan/core/resource/Resource.kt new file mode 100644 index 00000000..a77ad49c --- /dev/null +++ b/core/common/src/main/java/com/github/andiim/plantscan/core/resource/Resource.kt @@ -0,0 +1,7 @@ +package com.github.andiim.plantscan.core.resource + +sealed class Resource(val data: T? = null, val message: String? = null) { + class Success(data: T) : Resource(data) + class Loading(data: T? = null) : Resource(data) + class Error(message: String, data: T? = null) : Resource(data, message) +} diff --git a/core/common/src/main/java/com/github/andiim/plantscan/core/result/Result.kt b/core/common/src/main/java/com/github/andiim/plantscan/core/result/Result.kt new file mode 100644 index 00000000..cb75e62c --- /dev/null +++ b/core/common/src/main/java/com/github/andiim/plantscan/core/result/Result.kt @@ -0,0 +1,21 @@ +package com.github.andiim.plantscan.core.result + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart + +sealed interface Result { + data class Success(val data: T) : Result + data class Error(val exception: Throwable? = null) : Result + data object Loading : Result +} + +fun Flow.asResult(): Flow> { + return this + .map> { + Result.Success(it) + } + .onStart { emit(Result.Loading) } + .catch { emit(Result.Error(it)) } +} diff --git a/core/common/src/main/res/drawable/icon_base.xml b/core/common/src/main/res/drawable/icon_base.xml new file mode 100644 index 00000000..67b975ab --- /dev/null +++ b/core/common/src/main/res/drawable/icon_base.xml @@ -0,0 +1,1193 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/core/common/src/test/java/com/github/andiim/plantscan/core/result/ResultKtTest.kt b/core/common/src/test/java/com/github/andiim/plantscan/core/result/ResultKtTest.kt new file mode 100644 index 00000000..08b6cf5f --- /dev/null +++ b/core/common/src/test/java/com/github/andiim/plantscan/core/result/ResultKtTest.kt @@ -0,0 +1,37 @@ +package com.github.andiim.plantscan.core.result + +import app.cash.turbine.test +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import java.lang.IllegalStateException + +class ResultKtTest { + @Suppress("detekt:TooGenericExceptionThrown", "detekt:UseCheckOrError") + @Test + fun result_catches_errors() = runTest { + flow { + emit(1) + throw Exception("Test Done") + }.asResult() + .test { + assertEquals(Result.Loading, awaitItem()) + assertEquals(Result.Success(1), awaitItem()) + + when (val errorResult = awaitItem()) { + is Result.Error -> assertEquals( + "Test Done", + errorResult.exception?.message, + ) + + Result.Loading, + is Result.Success, + -> throw IllegalStateException( + "The flow should hve emitted an Error Result", + ) + } + awaitComplete() + } + } +} diff --git a/core/data-test/.gitignore b/core/data-test/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/data-test/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/data-test/README.md b/core/data-test/README.md new file mode 100644 index 00000000..bcf180c7 --- /dev/null +++ b/core/data-test/README.md @@ -0,0 +1 @@ +# :core:data-test module diff --git a/core/data-test/build.gradle.kts b/core/data-test/build.gradle.kts new file mode 100644 index 00000000..c32e2a74 --- /dev/null +++ b/core/data-test/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.android.hilt) +} + +android { + namespace = "com.github.andiim.plantscan.core.data.test" +} + +dependencies { + api(project(":core:data")) + implementation(project(":core:testing")) + implementation(project(":core:common")) +} \ No newline at end of file diff --git a/core/data-test/src/main/AndroidManifest.xml b/core/data-test/src/main/AndroidManifest.xml new file mode 100644 index 00000000..568741e5 --- /dev/null +++ b/core/data-test/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/core/data-test/src/main/java/com/github/andiim/plantscan/core/data/test/AlwaysOnlineNetworkMonitor.kt b/core/data-test/src/main/java/com/github/andiim/plantscan/core/data/test/AlwaysOnlineNetworkMonitor.kt new file mode 100644 index 00000000..98259dfa --- /dev/null +++ b/core/data-test/src/main/java/com/github/andiim/plantscan/core/data/test/AlwaysOnlineNetworkMonitor.kt @@ -0,0 +1,10 @@ +package com.github.andiim.plantscan.core.data.test + +import com.github.andiim.plantscan.core.data.util.NetworkMonitor +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import javax.inject.Inject + +class AlwaysOnlineNetworkMonitor @Inject constructor() : NetworkMonitor { + override val isOnline: Flow = flowOf(true) +} diff --git a/core/data-test/src/main/java/com/github/andiim/plantscan/core/data/test/TestDataModule.kt b/core/data-test/src/main/java/com/github/andiim/plantscan/core/data/test/TestDataModule.kt new file mode 100644 index 00000000..8f805450 --- /dev/null +++ b/core/data-test/src/main/java/com/github/andiim/plantscan/core/data/test/TestDataModule.kt @@ -0,0 +1,63 @@ +package com.github.andiim.plantscan.core.data.test + +import com.github.andiim.plantscan.core.data.di.DataModule +import com.github.andiim.plantscan.core.data.repository.DetectHistoryRepo +import com.github.andiim.plantscan.core.data.repository.DetectRepository +import com.github.andiim.plantscan.core.data.repository.PlantRepository +import com.github.andiim.plantscan.core.data.repository.RecentSearchRepository +import com.github.andiim.plantscan.core.data.repository.SearchContentsRepository +import com.github.andiim.plantscan.core.data.repository.UserDataRepository +import com.github.andiim.plantscan.core.data.repository.fake.FakeDetectHistoryRepo +import com.github.andiim.plantscan.core.data.repository.fake.FakeDetectRepository +import com.github.andiim.plantscan.core.data.repository.fake.FakePlantRepository +import com.github.andiim.plantscan.core.data.repository.fake.FakeRecentSearchRepository +import com.github.andiim.plantscan.core.data.repository.fake.FakeSearchContentsRepository +import com.github.andiim.plantscan.core.data.repository.fake.FakeUserDataRepository +import com.github.andiim.plantscan.core.data.util.NetworkMonitor +import dagger.Binds +import dagger.Module +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [DataModule::class], +) +interface TestDataModule { + + @Binds + fun bindsDetectRepository( + cameraRepository: FakeDetectRepository, + ): DetectRepository + + @Binds + fun bindsDetectHistoryRepository( + detectHistoryRepo: FakeDetectHistoryRepo, + ): DetectHistoryRepo + + @Binds + fun bindPlantRepository( + plantRepository: FakePlantRepository, + ): PlantRepository + + @Binds + fun bindRecentSearchRepository( + recentSearchRepository: FakeRecentSearchRepository, + ): RecentSearchRepository + + @Binds + fun bindsSearchContentsRepository( + searchContentsRepository: FakeSearchContentsRepository, + ): SearchContentsRepository + + @Binds + fun bindsUserDataRepository( + userDataRepository: FakeUserDataRepository, + ): UserDataRepository + + @Binds + fun bindsNetworkMonitor( + networkMonitor: AlwaysOnlineNetworkMonitor, + ): NetworkMonitor +} diff --git a/core/data/.gitignore b/core/data/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/data/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts new file mode 100644 index 00000000..eddf5f4c --- /dev/null +++ b/core/data/build.gradle.kts @@ -0,0 +1,37 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.android.library.jacoco) + alias(libs.plugins.android.hilt) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "com.github.andiim.plantscan.core.data" + testOptions { + unitTests { + isIncludeAndroidResources = true + isReturnDefaultValues = true + } + } +} + +dependencies { + implementation(project(":core:auth")) + implementation(project(":core:analytics")) + implementation(project(":core:common")) + implementation(project(":core:database")) + implementation(project(":core:datastore")) + implementation(project(":core:model")) + implementation(project(":core:network")) + implementation(project(":core:firestore")) + implementation(project(":core:storage-upload")) + implementation(project(":core:notifications")) + implementation(libs.androidx.core.ktx) + implementation(libs.kotlin.coroutines.android) + implementation(libs.kotlinx.datetime) + implementation(libs.kotlinx.serialization.json) + implementation(libs.androidx.lifecycle.livedata) + implementation(libs.androidx.work.runtime.ktx) + testImplementation(project(":core:datastore-test")) + testImplementation(project(":core:testing")) +} diff --git a/core/data/src/main/AndroidManifest.xml b/core/data/src/main/AndroidManifest.xml new file mode 100644 index 00000000..f0f34af3 --- /dev/null +++ b/core/data/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/core/data/src/main/java/com/github/andiim/plantscan/core/data/di/DataModule.kt b/core/data/src/main/java/com/github/andiim/plantscan/core/data/di/DataModule.kt new file mode 100644 index 00000000..8952fa93 --- /dev/null +++ b/core/data/src/main/java/com/github/andiim/plantscan/core/data/di/DataModule.kt @@ -0,0 +1,60 @@ +package com.github.andiim.plantscan.core.data.di + +import com.github.andiim.plantscan.core.data.repository.DefaultDetectHistoryRepo +import com.github.andiim.plantscan.core.data.repository.DefaultPlantRepository +import com.github.andiim.plantscan.core.data.repository.DefaultRecentSearchRepository +import com.github.andiim.plantscan.core.data.repository.DefaultSearchContentsRepository +import com.github.andiim.plantscan.core.data.repository.DefaultUserDataRepository +import com.github.andiim.plantscan.core.data.repository.DetectHistoryRepo +import com.github.andiim.plantscan.core.data.repository.DetectRepository +import com.github.andiim.plantscan.core.data.repository.JustOnlineDetectRepository +import com.github.andiim.plantscan.core.data.repository.PlantRepository +import com.github.andiim.plantscan.core.data.repository.RecentSearchRepository +import com.github.andiim.plantscan.core.data.repository.SearchContentsRepository +import com.github.andiim.plantscan.core.data.repository.UserDataRepository +import com.github.andiim.plantscan.core.data.util.ConnectivityManagerNetworkMonitor +import com.github.andiim.plantscan.core.data.util.NetworkMonitor +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface DataModule { + + @Binds + fun bindDetectHistoryRepository( + detectHistoryRepo: DefaultDetectHistoryRepo, + ): DetectHistoryRepo + + @Binds + fun bindDetectRepository( + detectRepository: JustOnlineDetectRepository, + ): DetectRepository + + @Binds + fun bindPlantRepository( + plantRepository: DefaultPlantRepository, + ): PlantRepository + + @Binds + fun bindsRecentSearchRepository( + recentSearchRepository: DefaultRecentSearchRepository, + ): RecentSearchRepository + + @Binds + fun bindsSearchContentsRepository( + searchContentsRepository: DefaultSearchContentsRepository, + ): SearchContentsRepository + + @Binds + fun bindsUserDataRepository( + userDataRepository: DefaultUserDataRepository, + ): UserDataRepository + + @Binds + fun bindsNetworkModule( + networkMonitor: ConnectivityManagerNetworkMonitor, + ): NetworkMonitor +} diff --git a/core/data/src/main/java/com/github/andiim/plantscan/core/data/model/Detection.kt b/core/data/src/main/java/com/github/andiim/plantscan/core/data/model/Detection.kt new file mode 100644 index 00000000..05a02dda --- /dev/null +++ b/core/data/src/main/java/com/github/andiim/plantscan/core/data/model/Detection.kt @@ -0,0 +1,28 @@ +package com.github.andiim.plantscan.core.data.model + +import com.github.andiim.plantscan.core.model.data.Imgz +import com.github.andiim.plantscan.core.model.data.ObjectDetection +import com.github.andiim.plantscan.core.model.data.Prediction +import com.github.andiim.plantscan.core.network.model.DetectionResponse +import com.github.andiim.plantscan.core.network.model.ImgzResponse +import com.github.andiim.plantscan.core.network.model.PredictionResponse + +fun DetectionResponse.asExternalModel() = ObjectDetection( + time = time, + image = image.asExternalModel(), + predictions = predictions.map { it.asExternalModel() }, +) + +fun ImgzResponse.asExternalModel() = Imgz( + width = width, + height = height +) + +fun PredictionResponse.asExternalModel() = Prediction( + confidence = confidence, + x = x, + y = y, + width = width, + height = height, + jsonMemberClass = jsonMemberClass, +) diff --git a/core/data/src/main/java/com/github/andiim/plantscan/core/data/model/DetectionHistory.kt b/core/data/src/main/java/com/github/andiim/plantscan/core/data/model/DetectionHistory.kt new file mode 100644 index 00000000..c7115321 --- /dev/null +++ b/core/data/src/main/java/com/github/andiim/plantscan/core/data/model/DetectionHistory.kt @@ -0,0 +1,54 @@ +package com.github.andiim.plantscan.core.data.model + +import com.github.andiim.plantscan.core.firestore.model.HistoryDocument +import com.github.andiim.plantscan.core.firestore.model.LabelPredictDocument +import com.github.andiim.plantscan.core.model.data.DetectionHistory +import com.github.andiim.plantscan.core.model.data.LabelPredict +import kotlinx.datetime.Instant +import kotlinx.datetime.toJavaInstant +import java.util.Date + +/** + * Converts the document as Model. + * + * note: ID dan Timestamp must be exist! + */ +fun HistoryDocument.asExternalModel() = DetectionHistory( + id = id, + plantRef = plantRef, + userId = userId, + accuracy = accuracy, + timestamp = timestamp?.toInstantKtx()!!, + image = image, + detections = detections.map(LabelPredictDocument::asExternalModel), +) + +fun LabelPredictDocument.asExternalModel() = LabelPredict( + objectClass = objectClass, + confidence = confidence, +) + +fun Date.toInstantKtx(): Instant { + val millisecond: Long = this.toInstant().toEpochMilli() + return Instant.fromEpochMilliseconds(epochMilliseconds = millisecond) +} + +/** + * Converts history as document. + * + * ID and Timestamp may be null. + */ +fun DetectionHistory.asDocument() = HistoryDocument( + id = id, + userId = userId, + plantRef = plantRef, + accuracy = accuracy, + image = image, + detections = detections.map(LabelPredict::asDocument), + timestamp = Date.from(timestamp.toJavaInstant()) +) + +fun LabelPredict.asDocument() = LabelPredictDocument( + objectClass = objectClass, + confidence = confidence, +) diff --git a/core/data/src/main/java/com/github/andiim/plantscan/core/data/model/Plant.kt b/core/data/src/main/java/com/github/andiim/plantscan/core/data/model/Plant.kt new file mode 100644 index 00000000..0135a611 --- /dev/null +++ b/core/data/src/main/java/com/github/andiim/plantscan/core/data/model/Plant.kt @@ -0,0 +1,23 @@ +package com.github.andiim.plantscan.core.data.model + +import com.github.andiim.plantscan.core.firestore.model.ImageDocument +import com.github.andiim.plantscan.core.firestore.model.PlantDocument +import com.github.andiim.plantscan.core.model.data.Plant +import com.github.andiim.plantscan.core.model.data.PlantImage + +fun PlantDocument.asExternalModel() = Plant( + id = id, + name = name, + species = species, + description = description, + thumbnail = thumbnail, + commonName = commonName.map { it.name }, + images = images.map { it.asExternalModel() } +) + +fun ImageDocument.asExternalModel() = PlantImage( + id = id.toString(), + attribution = attribution ?: "", + url = url, + description = description ?: desc ?: "" +) diff --git a/core/data/src/main/java/com/github/andiim/plantscan/core/data/model/RecentSearchQuery.kt b/core/data/src/main/java/com/github/andiim/plantscan/core/data/model/RecentSearchQuery.kt new file mode 100644 index 00000000..99da1ff7 --- /dev/null +++ b/core/data/src/main/java/com/github/andiim/plantscan/core/data/model/RecentSearchQuery.kt @@ -0,0 +1,15 @@ +package com.github.andiim.plantscan.core.data.model + +import com.github.andiim.plantscan.core.database.model.RecentSearchQueryEntity +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +data class RecentSearchQuery( + val query: String, + val queriedDate: Instant = Clock.System.now(), +) + +fun RecentSearchQueryEntity.asExternalModel() = RecentSearchQuery( + query = query, + queriedDate = queriedDate, +) diff --git a/core/data/src/main/java/com/github/andiim/plantscan/core/data/model/Suggestion.kt b/core/data/src/main/java/com/github/andiim/plantscan/core/data/model/Suggestion.kt new file mode 100644 index 00000000..20a806fa --- /dev/null +++ b/core/data/src/main/java/com/github/andiim/plantscan/core/data/model/Suggestion.kt @@ -0,0 +1,12 @@ +package com.github.andiim.plantscan.core.data.model + +import com.github.andiim.plantscan.core.firestore.model.SuggestionDocument +import com.github.andiim.plantscan.core.model.data.Suggestion + +fun Suggestion.asDocument() = SuggestionDocument( + id = id, + userId = userId, + date = date, + description = description, + images = images, +) diff --git a/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/AnalyticsExtensions.kt b/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/AnalyticsExtensions.kt new file mode 100644 index 00000000..e5e631d5 --- /dev/null +++ b/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/AnalyticsExtensions.kt @@ -0,0 +1,43 @@ +package com.github.andiim.plantscan.core.data.repository + +import com.github.andiim.plantscan.core.analytics.AnalyticsEvent +import com.github.andiim.plantscan.core.analytics.AnalyticsEvent.Param +import com.github.andiim.plantscan.core.analytics.AnalyticsHelper + +fun AnalyticsHelper.logOnboardingStateChanged(shouldHideOnboarding: Boolean) { + val eventType = if (shouldHideOnboarding) "onboarding_complete" else "onboarding_reset" + logEvent( + AnalyticsEvent(type = eventType), + ) +} + +fun AnalyticsHelper.logDarkThemeConfigChanged(darkThemeConfigName: String) = + logEvent( + AnalyticsEvent( + type = "dark_theme_config_changed", + extras = listOf( + Param(key = "dark_theme_config", value = darkThemeConfigName), + ), + ), + ) + +fun AnalyticsHelper.logDynamicColorPreferenceChanged(useDynamicColor: Boolean) = + logEvent( + AnalyticsEvent( + type = "dynamic_color_preference_changed", + extras = listOf( + Param(key = "dynamic_color_preference", value = useDynamicColor.toString()), + ), + ), + ) + +fun AnalyticsHelper.logLoginInfo(userId: String, isAnonymous: Boolean) = + logEvent( + AnalyticsEvent( + type = "login_info_preference_changed", + extras = listOf( + Param(key = "userId", value = userId), + Param(key = "asAnonymous", value = isAnonymous.toString()) + ) + ) + ) diff --git a/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/DetectHistoryRepo.kt b/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/DetectHistoryRepo.kt new file mode 100644 index 00000000..e7874f34 --- /dev/null +++ b/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/DetectHistoryRepo.kt @@ -0,0 +1,33 @@ +package com.github.andiim.plantscan.core.data.repository + +import com.github.andiim.plantscan.core.data.model.asDocument +import com.github.andiim.plantscan.core.data.model.asExternalModel +import com.github.andiim.plantscan.core.firestore.PsFirebaseDataSource +import com.github.andiim.plantscan.core.firestore.model.HistoryDocument +import com.github.andiim.plantscan.core.model.data.DetectionHistory +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import javax.inject.Inject + +interface DetectHistoryRepo { + fun recordDetection(detection: DetectionHistory): Flow + fun getDetectionHistories(userId: String): Flow> + + fun getDetectionDetail(historyId: String): Flow +} + +class DefaultDetectHistoryRepo @Inject constructor( + private val firebase: PsFirebaseDataSource, +) : DetectHistoryRepo { + override fun recordDetection(detection: DetectionHistory): Flow = flow { + emit(firebase.recordDetection(detection.asDocument())) + } + + override fun getDetectionHistories(userId: String): Flow> = flow { + emit(firebase.getDetectionHistories(userId).map(HistoryDocument::asExternalModel)) + } + + override fun getDetectionDetail(historyId: String): Flow = flow { + emit(firebase.getDetectionDetail(historyId).asExternalModel()) + } +} diff --git a/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/DetectRepository.kt b/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/DetectRepository.kt new file mode 100644 index 00000000..c2e313ff --- /dev/null +++ b/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/DetectRepository.kt @@ -0,0 +1,38 @@ +package com.github.andiim.plantscan.core.data.repository + +import com.github.andiim.plantscan.core.data.model.asDocument +import com.github.andiim.plantscan.core.data.model.asExternalModel +import com.github.andiim.plantscan.core.firestore.PsFirebaseDataSource +import com.github.andiim.plantscan.core.model.data.ObjectDetection +import com.github.andiim.plantscan.core.model.data.Suggestion +import com.github.andiim.plantscan.core.network.PsNetworkDataSource +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import javax.inject.Inject + +interface DetectRepository { + fun detect( + base64ImageData: String, + confidence: Int = 40, + overlap: Int = 30, + ): Flow + + fun sendSuggestion(suggestion: Suggestion): Flow +} + +class JustOnlineDetectRepository @Inject constructor( + private val network: PsNetworkDataSource, + private val firebase: PsFirebaseDataSource, +) : DetectRepository { + override fun detect( + base64ImageData: String, + confidence: Int, + overlap: Int, + ): Flow = flow { + emit(network.detect(base64ImageData, confidence, overlap).asExternalModel()) + } + + override fun sendSuggestion(suggestion: Suggestion): Flow = flow { + emit(firebase.sendSuggestion(suggestion.asDocument())) + } +} diff --git a/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/PlantRepository.kt b/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/PlantRepository.kt new file mode 100644 index 00000000..b0901a23 --- /dev/null +++ b/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/PlantRepository.kt @@ -0,0 +1,33 @@ +package com.github.andiim.plantscan.core.data.repository + +import com.github.andiim.plantscan.core.data.model.asExternalModel +import com.github.andiim.plantscan.core.firestore.PsFirebaseDataSource +import com.github.andiim.plantscan.core.firestore.model.PlantDocument +import com.github.andiim.plantscan.core.model.data.Plant +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import javax.inject.Inject + +interface PlantRepository { + /** + * Gets the available plants as a stream. + */ + fun getPlants(): Flow> + + /** + * Gets data for a specific plant. + */ + fun getPlantById(id: String): Flow +} + +class DefaultPlantRepository @Inject constructor( + private val firebase: PsFirebaseDataSource, +) : PlantRepository { + override fun getPlants(): Flow> = flow { + emit(firebase.getPlants().map(PlantDocument::asExternalModel)) + } + + override fun getPlantById(id: String): Flow = flow { + emit(firebase.getPlantById(id).asExternalModel()) + } +} diff --git a/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/RecentSearchRepository.kt b/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/RecentSearchRepository.kt new file mode 100644 index 00000000..d45a875c --- /dev/null +++ b/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/RecentSearchRepository.kt @@ -0,0 +1,59 @@ +package com.github.andiim.plantscan.core.data.repository + +import com.github.andiim.plantscan.core.data.model.RecentSearchQuery +import com.github.andiim.plantscan.core.data.model.asExternalModel +import com.github.andiim.plantscan.core.database.dao.RecentSearchQueryDao +import com.github.andiim.plantscan.core.database.model.RecentSearchQueryEntity +import com.github.andiim.plantscan.core.network.AppDispatchers.IO +import com.github.andiim.plantscan.core.network.Dispatcher +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock +import javax.inject.Inject + +/** + * Data layer interface for the recent searches. + */ +interface RecentSearchRepository { + /** + * Get the recent search queries up to the number of queries specified as [limit]. + */ + fun getRecentSearchQueries(limit: Int): Flow> + + /** + * Insert or replace the [searchQuery] as part of the recent search. + */ + suspend fun insertOrReplaceRecentSearch(searchQuery: String) + + /** + * Clear the recent searches. + */ + suspend fun clearRecentSearches() +} + +class DefaultRecentSearchRepository @Inject constructor( + private val recentSearchQueryDao: RecentSearchQueryDao, + @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher +) : RecentSearchRepository { + override fun getRecentSearchQueries(limit: Int): Flow> = + recentSearchQueryDao.getRecentSearchQueryEntities(limit).map { searchQueries -> + searchQueries.map { + it.asExternalModel() + } + } + + override suspend fun insertOrReplaceRecentSearch(searchQuery: String) { + withContext(ioDispatcher) { + recentSearchQueryDao.insertOrReplaceRecentSearchQuery( + RecentSearchQueryEntity( + query = searchQuery, + queriedDate = Clock.System.now() + ) + ) + } + } + + override suspend fun clearRecentSearches() = recentSearchQueryDao.clearRecentSearchQueries() +} diff --git a/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/SearchContentsRepository.kt b/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/SearchContentsRepository.kt new file mode 100644 index 00000000..47e0371b --- /dev/null +++ b/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/SearchContentsRepository.kt @@ -0,0 +1,65 @@ +package com.github.andiim.plantscan.core.data.repository + +import com.github.andiim.plantscan.core.database.dao.PlantDao +import com.github.andiim.plantscan.core.database.dao.PlantFtsDao +import com.github.andiim.plantscan.core.database.model.PlantAndImages +import com.github.andiim.plantscan.core.database.model.asExternalModel +import com.github.andiim.plantscan.core.database.model.asFtsEntity +import com.github.andiim.plantscan.core.model.data.Plant +import com.github.andiim.plantscan.core.model.data.SearchResult +import com.github.andiim.plantscan.core.network.AppDispatchers.IO +import com.github.andiim.plantscan.core.network.Dispatcher +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.withContext +import javax.inject.Inject + +/** + * Data layer interface for the search feature. + */ +interface SearchContentsRepository { + /** + * Populate the fts tables for the search contents. + */ + suspend fun populateFtsData() + + /** + * Query the contents matched the [searchQuery] and returns it as a [Flow] of [SearchResult]. + */ + fun searchContents(searchQuery: String): Flow + + fun getSearchContentsCount(): Flow +} + +class DefaultSearchContentsRepository @Inject constructor( + private val plantRepository: PlantRepository, + private val plantDao: PlantDao, + private val plantFtsDao: PlantFtsDao, + @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, +) : SearchContentsRepository { + override suspend fun populateFtsData() { + withContext(ioDispatcher) { + val plants = plantRepository.getPlants().map { it.map(Plant::asFtsEntity) }.first() + plantFtsDao.insertAll(plants) + } + } + + override fun searchContents(searchQuery: String): Flow { + val plantIds = plantFtsDao.searchAllPlants("*$searchQuery") + val plantsFlow = plantIds + .mapLatest { it.toSet() } + .distinctUntilChanged() + .flatMapLatest(plantDao::getPlantEntities) + return plantsFlow.map { plants -> + SearchResult(plants = plants.map(PlantAndImages::asExternalModel)) + } + } + + override fun getSearchContentsCount(): Flow = + plantFtsDao.getCount() +} diff --git a/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/UserDataRepository.kt b/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/UserDataRepository.kt new file mode 100644 index 00000000..cb635633 --- /dev/null +++ b/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/UserDataRepository.kt @@ -0,0 +1,62 @@ +package com.github.andiim.plantscan.core.data.repository + +import com.github.andiim.plantscan.core.analytics.AnalyticsHelper +import com.github.andiim.plantscan.core.datastore.AppPreferencesDataSource +import com.github.andiim.plantscan.core.model.data.DarkThemeConfig +import com.github.andiim.plantscan.core.model.data.UserData +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +interface UserDataRepository { + /** + * Stream of [UserData]. + */ + val userData: Flow + + /** + * Sets whether the user has completed the onboarding process. + */ + suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) + + /** + * Sets the desired dark theme config. + */ + suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) + + /** + * Sets the preferred dynamic color config. + */ + suspend fun setDynamicColorPreference(useDynamicColor: Boolean) + + /** + * Set login information to local store to prevent unlimited request to backend. + */ + suspend fun setLoginInfo(userId: String = "", isAnonymous: Boolean = true) +} + +class DefaultUserDataRepository @Inject constructor( + private val appPreferencesDataSource: AppPreferencesDataSource, + private val analyticsHelper: AnalyticsHelper, +) : UserDataRepository { + override val userData: Flow = appPreferencesDataSource.userData + + override suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) { + appPreferencesDataSource.setShouldHideOnboarding(shouldHideOnboarding) + analyticsHelper.logOnboardingStateChanged(shouldHideOnboarding) + } + + override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) { + appPreferencesDataSource.setDarkThemeConfig(darkThemeConfig) + analyticsHelper.logDarkThemeConfigChanged(darkThemeConfig.name) + } + + override suspend fun setDynamicColorPreference(useDynamicColor: Boolean) { + appPreferencesDataSource.setDynamicColorPreference(useDynamicColor) + analyticsHelper.logDynamicColorPreferenceChanged(useDynamicColor) + } + + override suspend fun setLoginInfo(userId: String, isAnonymous: Boolean) { + appPreferencesDataSource.setUserData(userId, isAnonymous) + analyticsHelper.logLoginInfo(userId, isAnonymous) + } +} diff --git a/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/fake/FakeDetectHistoryRepo.kt b/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/fake/FakeDetectHistoryRepo.kt new file mode 100644 index 00000000..ff79344e --- /dev/null +++ b/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/fake/FakeDetectHistoryRepo.kt @@ -0,0 +1,30 @@ +package com.github.andiim.plantscan.core.data.repository.fake + +import com.github.andiim.plantscan.core.data.model.asExternalModel +import com.github.andiim.plantscan.core.data.repository.DetectHistoryRepo +import com.github.andiim.plantscan.core.data.repository.DetectRepository +import com.github.andiim.plantscan.core.firestore.fake.FakePsFirebaseDataSource +import com.github.andiim.plantscan.core.firestore.model.HistoryDocument +import com.github.andiim.plantscan.core.model.data.DetectionHistory +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import javax.inject.Inject + +/** + * Fake implementation of the [DetectRepository]. + */ +class FakeDetectHistoryRepo @Inject constructor( + private val dataSource: FakePsFirebaseDataSource, +) : DetectHistoryRepo { + override fun recordDetection(detection: DetectionHistory): Flow = + flowOf("Success") + + override fun getDetectionHistories(userId: String): Flow> = flow { + emit(dataSource.getDetectionHistories(userId).map(HistoryDocument::asExternalModel)) + } + + override fun getDetectionDetail(historyId: String): Flow = flow { + emit(dataSource.getDetectionDetail(historyId).asExternalModel()) + } +} diff --git a/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/fake/FakeDetectRepository.kt b/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/fake/FakeDetectRepository.kt new file mode 100644 index 00000000..9f7cdc92 --- /dev/null +++ b/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/fake/FakeDetectRepository.kt @@ -0,0 +1,37 @@ +package com.github.andiim.plantscan.core.data.repository.fake + +import com.github.andiim.plantscan.core.data.model.asDocument +import com.github.andiim.plantscan.core.data.model.asExternalModel +import com.github.andiim.plantscan.core.data.repository.DetectRepository +import com.github.andiim.plantscan.core.firestore.fake.FakePsFirebaseDataSource +import com.github.andiim.plantscan.core.model.data.ObjectDetection +import com.github.andiim.plantscan.core.model.data.Suggestion +import com.github.andiim.plantscan.core.network.AppDispatchers.IO +import com.github.andiim.plantscan.core.network.Dispatcher +import com.github.andiim.plantscan.core.network.fake.FakePsNetworkDataSource +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import javax.inject.Inject + +/** + * Fake implementation of the [DetectRepository]. + */ +class FakeDetectRepository @Inject constructor( + @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, + private val dataSource: FakePsNetworkDataSource, + private val firebase: FakePsFirebaseDataSource, +) : DetectRepository { + override fun detect( + base64ImageData: String, + confidence: Int, + overlap: Int, + ): Flow = flow { + emit(dataSource.detect(base64ImageData, confidence, overlap).asExternalModel()) + }.flowOn(ioDispatcher) + + override fun sendSuggestion(suggestion: Suggestion): Flow = flow { + emit(firebase.sendSuggestion(suggestion.asDocument())) + } +} diff --git a/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/fake/FakePlantRepository.kt b/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/fake/FakePlantRepository.kt new file mode 100644 index 00000000..bb337729 --- /dev/null +++ b/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/fake/FakePlantRepository.kt @@ -0,0 +1,27 @@ +package com.github.andiim.plantscan.core.data.repository.fake + +import com.github.andiim.plantscan.core.data.model.asExternalModel +import com.github.andiim.plantscan.core.data.repository.PlantRepository +import com.github.andiim.plantscan.core.firestore.fake.FakePsFirebaseDataSource +import com.github.andiim.plantscan.core.firestore.model.PlantDocument +import com.github.andiim.plantscan.core.model.data.Plant +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +/** + * Fake implementation of the [PlantRepository]. + */ +class FakePlantRepository @Inject constructor( + private val dataSource: FakePsFirebaseDataSource, +) : PlantRepository { + override fun getPlants(): Flow> = + flow { + dataSource.getPlants().map(PlantDocument::asExternalModel) + } + + override fun getPlantById(id: String): Flow { + return getPlants().map { it.first { plant -> plant.id == id } } + } +} diff --git a/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/fake/FakeRecentSearchRepository.kt b/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/fake/FakeRecentSearchRepository.kt new file mode 100644 index 00000000..7ace930b --- /dev/null +++ b/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/fake/FakeRecentSearchRepository.kt @@ -0,0 +1,19 @@ +package com.github.andiim.plantscan.core.data.repository.fake + +import com.github.andiim.plantscan.core.data.model.RecentSearchQuery +import com.github.andiim.plantscan.core.data.repository.RecentSearchRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import javax.inject.Inject + +/** + * Fake implementation of the [RecentSearchRepository]. + */ +class FakeRecentSearchRepository @Inject constructor() : RecentSearchRepository { + override fun getRecentSearchQueries(limit: Int): Flow> = + flowOf(emptyList()) + + override suspend fun insertOrReplaceRecentSearch(searchQuery: String) = Unit + + override suspend fun clearRecentSearches() = Unit +} diff --git a/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/fake/FakeSearchContentsRepository.kt b/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/fake/FakeSearchContentsRepository.kt new file mode 100644 index 00000000..d63483ab --- /dev/null +++ b/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/fake/FakeSearchContentsRepository.kt @@ -0,0 +1,16 @@ +package com.github.andiim.plantscan.core.data.repository.fake + +import com.github.andiim.plantscan.core.data.repository.SearchContentsRepository +import com.github.andiim.plantscan.core.model.data.SearchResult +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import javax.inject.Inject + +/** + * Fake implementation of the [SearchContentsRepository]. + */ +class FakeSearchContentsRepository @Inject constructor() : SearchContentsRepository { + override suspend fun populateFtsData() = Unit + override fun searchContents(searchQuery: String): Flow = flowOf() + override fun getSearchContentsCount(): Flow = flowOf(1) +} diff --git a/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/fake/FakeUserDataRepository.kt b/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/fake/FakeUserDataRepository.kt new file mode 100644 index 00000000..1e8a6cba --- /dev/null +++ b/core/data/src/main/java/com/github/andiim/plantscan/core/data/repository/fake/FakeUserDataRepository.kt @@ -0,0 +1,36 @@ +package com.github.andiim.plantscan.core.data.repository.fake + +import com.github.andiim.plantscan.core.data.repository.UserDataRepository +import com.github.andiim.plantscan.core.datastore.AppPreferencesDataSource +import com.github.andiim.plantscan.core.model.data.DarkThemeConfig +import com.github.andiim.plantscan.core.model.data.UserData +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +/** + * Fake implementation of the [UserDataRepository] that returns hardcoded user data. + * + * This allows us to run the app with fake data, without needing an internet connection or working + * backend. + */ +class FakeUserDataRepository @Inject constructor( + private val appPreferencesDataSource: AppPreferencesDataSource, +) : UserDataRepository { + override val userData: Flow = appPreferencesDataSource.userData + + override suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) { + appPreferencesDataSource.setShouldHideOnboarding(shouldHideOnboarding) + } + + override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) { + appPreferencesDataSource.setDarkThemeConfig(darkThemeConfig) + } + + override suspend fun setDynamicColorPreference(useDynamicColor: Boolean) { + appPreferencesDataSource.setDynamicColorPreference(useDynamicColor) + } + + override suspend fun setLoginInfo(userId: String, isAnonymous: Boolean) { + appPreferencesDataSource.setUserData(userId, isAnonymous) + } +} diff --git a/core/data/src/main/java/com/github/andiim/plantscan/core/data/util/ConnectivityManagerNetworkMonitor.kt b/core/data/src/main/java/com/github/andiim/plantscan/core/data/util/ConnectivityManagerNetworkMonitor.kt new file mode 100644 index 00000000..8e6a2e05 --- /dev/null +++ b/core/data/src/main/java/com/github/andiim/plantscan/core/data/util/ConnectivityManagerNetworkMonitor.kt @@ -0,0 +1,64 @@ +package com.github.andiim.plantscan.core.data.util + +import android.content.Context +import android.net.ConnectivityManager +import android.net.ConnectivityManager.NetworkCallback +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.net.NetworkRequest.Builder +import androidx.core.content.getSystemService +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.conflate +import javax.inject.Inject + +class ConnectivityManagerNetworkMonitor @Inject constructor( + @ApplicationContext private val context: Context, +) : NetworkMonitor { + override val isOnline: Flow = callbackFlow { + val connectivityManager = context.getSystemService() + if (connectivityManager == null) { + channel.trySend(false) + channel.close() + return@callbackFlow + } + + /** + * The callback's methods are invoked on changes to *any* network matching the [NetworkRequest], + * not just the active network. So we can simply track the presence (or absence) of such [Network]. + */ + val callback = object : NetworkCallback() { + + private val networks = mutableSetOf() + + override fun onAvailable(network: Network) { + networks += network + channel.trySend(true) + } + + override fun onLost(network: Network) { + networks -= network + channel.trySend(networks.isNotEmpty()) + } + } + + val request = Builder().addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET).build() + connectivityManager.registerNetworkCallback(request, callback) + + /** + * Sends the latest connectivity status to the underlying channel. + */ + channel.trySend(connectivityManager.isCurrentlyConnected()) + + awaitClose { + connectivityManager.unregisterNetworkCallback(callback) + } + }.conflate() + + private fun ConnectivityManager.isCurrentlyConnected() = + activeNetwork?.let(::getNetworkCapabilities) + ?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) ?: false +} diff --git a/core/data/src/main/java/com/github/andiim/plantscan/core/data/util/NetworkMonitor.kt b/core/data/src/main/java/com/github/andiim/plantscan/core/data/util/NetworkMonitor.kt new file mode 100644 index 00000000..f568d4cc --- /dev/null +++ b/core/data/src/main/java/com/github/andiim/plantscan/core/data/util/NetworkMonitor.kt @@ -0,0 +1,10 @@ +package com.github.andiim.plantscan.core.data.util + +import kotlinx.coroutines.flow.Flow + +/** + * Utility for reporting app connectivity status. + */ +interface NetworkMonitor { + val isOnline: Flow +} diff --git a/core/data/src/test/java/com/github/andiim/plantscan/core/data/model/FirebaseDocumentKtTest.kt b/core/data/src/test/java/com/github/andiim/plantscan/core/data/model/FirebaseDocumentKtTest.kt new file mode 100644 index 00000000..02309920 --- /dev/null +++ b/core/data/src/test/java/com/github/andiim/plantscan/core/data/model/FirebaseDocumentKtTest.kt @@ -0,0 +1,152 @@ +package com.github.andiim.plantscan.core.data.model + +import com.github.andiim.plantscan.core.firestore.model.HistoryDocument +import com.github.andiim.plantscan.core.firestore.model.ImageDocument +import com.github.andiim.plantscan.core.firestore.model.LabelPredictDocument +import com.github.andiim.plantscan.core.firestore.model.PlantDocument +import com.github.andiim.plantscan.core.model.data.DetectionHistory +import com.github.andiim.plantscan.core.model.data.LabelPredict +import com.github.andiim.plantscan.core.model.data.Suggestion +import kotlinx.datetime.Clock +import kotlinx.datetime.toJavaInstant +import org.junit.Test +import java.util.Date +import kotlin.test.assertEquals + +class FirebaseDocumentKtTest { + private val labelPredictDocument = LabelPredictDocument( + objectClass = "class", + confidence = 0.1f, + ) + private val historyDocument = HistoryDocument( + id = "id", + plantRef = "plantRef", + userId = "userId", + accuracy = 100.0f, + timestamp = Date.from(Clock.System.now().toJavaInstant()), + image = "image", + detections = listOf(labelPredictDocument), + ) + + @Test + fun `historyDocument can be mapped to History`() { + val model = historyDocument.asExternalModel() + assertEquals(historyDocument.id, model.id) + assertEquals(historyDocument.plantRef, model.plantRef) + assertEquals(historyDocument.userId, model.userId) + assertEquals(historyDocument.accuracy, model.accuracy) + assertEquals( + historyDocument.timestamp.toString(), + // Date from kotlinx-datetime, so map it first to java.date. + Date.from(model.timestamp.toJavaInstant()).toString(), + ) + assertEquals(historyDocument.image, model.image) + } + + @Test + fun `labelPredictDocument can be mapped to LabelPredict`() { + val model = labelPredictDocument.asExternalModel() + assertEquals(labelPredictDocument.confidence, model.confidence) + assertEquals(labelPredictDocument.objectClass, model.objectClass) + } + + @Test + fun `javaDate can be mapped to kotlinx datetime Instant`() { + val actual = Clock.System.now() + val expected = Date.from(actual.toJavaInstant()).toInstantKtx() + // Note: assertion will be error because of different string long. + assertEquals( + expected.toEpochMilliseconds(), + actual.toEpochMilliseconds(), + ) + } + + private val labelPredict = LabelPredict( + objectClass = "class", + confidence = 0.1f, + ) + + private val detectionHistory = DetectionHistory( + id = "id", + plantRef = "plantRef", + userId = "userId", + accuracy = 100.0f, + timestamp = Clock.System.now(), + image = "image", + detections = listOf(labelPredict), + ) + + @Test + fun `DetectionHistory can be mapped to HistoryDocument`() { + val document = detectionHistory.asDocument() + assertEquals(detectionHistory.id, document.id) + assertEquals(detectionHistory.plantRef, document.plantRef) + assertEquals(detectionHistory.userId, document.userId) + assertEquals(detectionHistory.accuracy, document.accuracy) + + // Note: assertion will be error because of different string long between java instant and + // instant-ktx. + assertEquals( + detectionHistory.timestamp.toEpochMilliseconds().toString(), + // Date from kotlinx-datetime, so map it first to java.date. + document.timestamp?.toInstantKtx()?.toEpochMilliseconds().toString(), + ) + assertEquals(detectionHistory.image, document.image) + } + + private val commonName = PlantDocument.CommonName(name = "commonName") + private val imageDocument = ImageDocument( + id = 1_1, + url = "url", + date = Date.from(Clock.System.now().toJavaInstant()), + attribution = "", + ) + private val plantDocument = PlantDocument( + id = "id", + name = "name", + species = "species", + description = "description", + thumbnail = "thumbnail", + commonName = listOf(commonName), + images = listOf(imageDocument), + ) + + @Test + fun `PlantDocument can be mapped to Plant`() { + val model = plantDocument.asExternalModel() + val listOfCommonName = plantDocument.commonName.map(PlantDocument.CommonName::name) + + assertEquals(plantDocument.id, model.id) + assertEquals(plantDocument.name, model.name) + assertEquals(plantDocument.species, model.species) + assertEquals(plantDocument.description, model.description) + assertEquals(plantDocument.thumbnail, model.thumbnail) + assertEquals(listOfCommonName, model.commonName) + } + + @Test + fun `ImageDocument can be mapped to PlantImage`() { + val model = imageDocument.asExternalModel() + assertEquals(imageDocument.id.toString(), model.id) + assertEquals(imageDocument.description, model.description) + assertEquals(imageDocument.attribution, model.attribution) + assertEquals(imageDocument.url, model.url) + } + + private val suggestion = Suggestion( + id = "id", + userId = "userId", + date = Date.from(Clock.System.now().toJavaInstant()), + description = "description", + images = listOf("string") + ) + + @Test + fun `Suggestion can be mapped to SuggestionDocument`() { + val document = suggestion.asDocument() + assertEquals(suggestion.id, document.id) + assertEquals(suggestion.userId, document.userId) + assertEquals(suggestion.description, document.description) + assertEquals(suggestion.images, document.images) + } +} diff --git a/core/data/src/test/java/com/github/andiim/plantscan/core/data/model/NetworkEntityKtTest.kt b/core/data/src/test/java/com/github/andiim/plantscan/core/data/model/NetworkEntityKtTest.kt new file mode 100644 index 00000000..1dc4a38a --- /dev/null +++ b/core/data/src/test/java/com/github/andiim/plantscan/core/data/model/NetworkEntityKtTest.kt @@ -0,0 +1,53 @@ +package com.github.andiim.plantscan.core.data.model + +import com.github.andiim.plantscan.core.network.model.DetectionResponse +import com.github.andiim.plantscan.core.network.model.ImgzResponse +import com.github.andiim.plantscan.core.network.model.PredictionResponse +import org.junit.Test +import kotlin.test.assertEquals + +class NetworkEntityKtTest { + private val imgResponse = ImgzResponse( + width = 400f, + height = 400f, + ) + + private val predictionModel = PredictionResponse( + confidence = 0.4f, + x = 100f, + y = 200f, + jsonMemberClass = "test", + width = 400f, + height = 400f, + ) + + private val networkModel = DetectionResponse( + time = 30f, + image = imgResponse, + predictions = listOf(predictionModel), + ) + + @Test + fun `DetectionResponse can be mapped to ObjectDetection`() { + val model = networkModel.asExternalModel() + assertEquals(networkModel.time, model.time) + } + + @Test + fun `ImgzResponse can be mapped to Imgz`() { + val model = imgResponse.asExternalModel() + assertEquals(imgResponse.height, model.height) + assertEquals(imgResponse.width, model.width) + } + + @Test + fun `PredictionResponse can be mappped to Prediction`() { + val model = predictionModel.asExternalModel() + assertEquals(predictionModel.height, model.height) + assertEquals(predictionModel.width, model.width) + assertEquals(predictionModel.x, model.x) + assertEquals(predictionModel.y, model.y) + assertEquals(predictionModel.jsonMemberClass, model.jsonMemberClass) + assertEquals(predictionModel.confidence, model.confidence) + } +} diff --git a/core/data/src/test/java/com/github/andiim/plantscan/core/data/repository/DefaultDetectHistoryRepositoryTest.kt b/core/data/src/test/java/com/github/andiim/plantscan/core/data/repository/DefaultDetectHistoryRepositoryTest.kt new file mode 100644 index 00000000..1e133a69 --- /dev/null +++ b/core/data/src/test/java/com/github/andiim/plantscan/core/data/repository/DefaultDetectHistoryRepositoryTest.kt @@ -0,0 +1,82 @@ +package com.github.andiim.plantscan.core.data.repository + +import com.github.andiim.plantscan.core.data.model.asExternalModel +import com.github.andiim.plantscan.core.data.testdoubles.TestPsFirebaseDataSource +import com.github.andiim.plantscan.core.firestore.model.HistoryDocument +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Clock +import kotlinx.datetime.toJavaInstant +import org.junit.Before +import org.junit.Test +import java.util.Date +import java.util.UUID +import kotlin.test.assertEquals + +class DefaultDetectHistoryRepositoryTest { + private val testScope = TestScope(UnconfinedTestDispatcher()) + + private lateinit var subject: DefaultDetectHistoryRepo + private lateinit var firebase: TestPsFirebaseDataSource + + @Before + fun setup() { + firebase = TestPsFirebaseDataSource() + subject = DefaultDetectHistoryRepo( + firebase = firebase, + ) + } + + @Test + fun `defaultDetectHistoryRepo when recordDetection is returned an id`() = testScope.runTest { + val id = UUID.randomUUID().toString() + val testInput = HistoryDocument( + id = id, + timestamp = Date.from(Clock.System.now().toJavaInstant()), + userId = "Userid", + plantRef = "plantRef", + accuracy = 0.1f, + image = "image", + detections = listOf(), + ) + + assertEquals( + expected = firebase.recordDetection(testInput), + actual = subject.recordDetection(testInput.asExternalModel()).first(), + ) + } + + @Test + fun `defaultDetectHistoryRepo when getDetectionHistories is returned a List of DetectionHistory`() = + testScope.runTest { + assertEquals( + expected = firebase.getDetectionHistories("userId") + .map(HistoryDocument::asExternalModel), + actual = subject.getDetectionHistories("userId").first(), + ) + } + + @Test + fun `defaultDetectHistoryRepo when getDetectionDetail is returned detail of DetectionHistories`() = + testScope.runTest { + val id = UUID.randomUUID().toString() + val testInput = HistoryDocument( + id = id, + timestamp = Date.from(Clock.System.now().toJavaInstant()), + userId = "Userid", + plantRef = "plantRef", + accuracy = 0.1f, + image = "image", + detections = listOf(), + ) + subject.recordDetection(testInput.asExternalModel()).collect() + + assertEquals( + expected = firebase.getDetectionDetail(id).asExternalModel(), + actual = subject.getDetectionDetail(id).first(), + ) + } +} diff --git a/core/data/src/test/java/com/github/andiim/plantscan/core/data/repository/DefaultPlantRepositoryTest.kt b/core/data/src/test/java/com/github/andiim/plantscan/core/data/repository/DefaultPlantRepositoryTest.kt new file mode 100644 index 00000000..3778f664 --- /dev/null +++ b/core/data/src/test/java/com/github/andiim/plantscan/core/data/repository/DefaultPlantRepositoryTest.kt @@ -0,0 +1,43 @@ +package com.github.andiim.plantscan.core.data.repository + +import com.github.andiim.plantscan.core.data.model.asExternalModel +import com.github.andiim.plantscan.core.data.testdoubles.TestPsFirebaseDataSource +import com.github.andiim.plantscan.core.firestore.model.PlantDocument +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import kotlin.test.assertEquals + +class DefaultPlantRepositoryTest { + private val testScope = TestScope(UnconfinedTestDispatcher()) + + private lateinit var subject: DefaultPlantRepository + private lateinit var firebase: TestPsFirebaseDataSource + + @Before + fun setup() { + firebase = TestPsFirebaseDataSource() + subject = DefaultPlantRepository( + firebase = firebase, + ) + } + + @Test + fun `defaultPlantRepositoryTest when getPlants return Plants`() = testScope.runTest { + assertEquals( + expected = firebase.getPlants().map(PlantDocument::asExternalModel), + actual = subject.getPlants().first(), + ) + } + + @Test + fun `defaultPlantRepositoryTest when getPlantById return Plant`() = testScope.runTest { + assertEquals( + expected = firebase.getPlantById("plantId").asExternalModel(), + actual = subject.getPlantById("plantId").first(), + ) + } +} diff --git a/core/data/src/test/java/com/github/andiim/plantscan/core/data/repository/JustOnlineDetectRepositoryTest.kt b/core/data/src/test/java/com/github/andiim/plantscan/core/data/repository/JustOnlineDetectRepositoryTest.kt new file mode 100644 index 00000000..cd7f8f27 --- /dev/null +++ b/core/data/src/test/java/com/github/andiim/plantscan/core/data/repository/JustOnlineDetectRepositoryTest.kt @@ -0,0 +1,49 @@ +package com.github.andiim.plantscan.core.data.repository + +import com.github.andiim.plantscan.core.data.model.asDocument +import com.github.andiim.plantscan.core.data.model.asExternalModel +import com.github.andiim.plantscan.core.data.testdoubles.TestPsFirebaseDataSource +import com.github.andiim.plantscan.core.data.testdoubles.TestPsNetworkDataSource +import com.github.andiim.plantscan.core.model.data.Suggestion +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import kotlin.test.assertEquals + +class JustOnlineDetectRepositoryTest { + private val testScope = TestScope(UnconfinedTestDispatcher()) + + private lateinit var subject: JustOnlineDetectRepository + private lateinit var network: TestPsNetworkDataSource + private lateinit var firebase: TestPsFirebaseDataSource + + @Before + fun setup() { + network = TestPsNetworkDataSource() + firebase = TestPsFirebaseDataSource() + subject = JustOnlineDetectRepository( + network = network, + firebase = firebase, + ) + } + + @Test + fun `justOnlineDetectRepository when detect returns detection result`() = testScope.runTest { + assertEquals( + expected = network.detect("image", 1, 1).asExternalModel(), + actual = subject.detect("image", 1, 1).first(), + ) + } + + @Test + fun `justOnlineDetectRepository when sendSuggestion return result`() = testScope.runTest { + val suggestion = Suggestion() + assertEquals( + expected = firebase.sendSuggestion(suggestion.asDocument()), + actual = subject.sendSuggestion(suggestion = suggestion).first(), + ) + } +} diff --git a/core/data/src/test/java/com/github/andiim/plantscan/core/data/testdoubles/TestPsFirebaseDataSource.kt b/core/data/src/test/java/com/github/andiim/plantscan/core/data/testdoubles/TestPsFirebaseDataSource.kt new file mode 100644 index 00000000..cf525931 --- /dev/null +++ b/core/data/src/test/java/com/github/andiim/plantscan/core/data/testdoubles/TestPsFirebaseDataSource.kt @@ -0,0 +1,46 @@ +package com.github.andiim.plantscan.core.data.testdoubles + +import com.github.andiim.plantscan.core.firestore.PsFirebaseDataSource +import com.github.andiim.plantscan.core.firestore.fake.FakePsFirebaseDataSource +import com.github.andiim.plantscan.core.firestore.model.HistoryDocument +import com.github.andiim.plantscan.core.firestore.model.PlantDocument +import com.github.andiim.plantscan.core.firestore.model.SuggestionDocument +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.serialization.json.Json + +/** + * Test double for [PsFirebaseDataSource]. + */ +class TestPsFirebaseDataSource : PsFirebaseDataSource { + private val source = FakePsFirebaseDataSource( + UnconfinedTestDispatcher(), + Json { ignoreUnknownKeys = true }, + ) + + private val allPlants = runBlocking { source.getPlants() } + + override suspend fun getPlants(): List = allPlants + + override suspend fun getPlantById(id: String): PlantDocument = runBlocking { + source.getPlantById(id) + } + + override suspend fun recordDetection(detection: HistoryDocument): String = runBlocking { + source.recordDetection(detection) + } + + override suspend fun getDetectionHistories(userId: String): List = + runBlocking { + source.getDetectionHistories(userId) + } + + override suspend fun getDetectionDetail(historyId: String): HistoryDocument = runBlocking { + source.getDetectionDetail(historyId) + } + + override suspend fun sendSuggestion(suggestionDocument: SuggestionDocument): String = + runBlocking { + source.sendSuggestion(suggestionDocument) + } +} diff --git a/core/data/src/test/java/com/github/andiim/plantscan/core/data/testdoubles/TestPsNetworkDataSource.kt b/core/data/src/test/java/com/github/andiim/plantscan/core/data/testdoubles/TestPsNetworkDataSource.kt new file mode 100644 index 00000000..42a327dc --- /dev/null +++ b/core/data/src/test/java/com/github/andiim/plantscan/core/data/testdoubles/TestPsNetworkDataSource.kt @@ -0,0 +1,20 @@ +package com.github.andiim.plantscan.core.data.testdoubles + +import com.github.andiim.plantscan.core.network.PsNetworkDataSource +import com.github.andiim.plantscan.core.network.fake.FakePsNetworkDataSource +import com.github.andiim.plantscan.core.network.model.DetectionResponse +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.serialization.json.Json + +class TestPsNetworkDataSource : PsNetworkDataSource { + private val source = FakePsNetworkDataSource( + UnconfinedTestDispatcher(), + Json { ignoreUnknownKeys = true }, + ) + + override suspend fun detect(image: String, confidence: Int, overlap: Int): DetectionResponse = + runBlocking { + source.detect(image, confidence, overlap) + } +} diff --git a/core/database/.gitignore b/core/database/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/database/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/database/README.md b/core/database/README.md new file mode 100644 index 00000000..8b576a6e --- /dev/null +++ b/core/database/README.md @@ -0,0 +1 @@ +# :core:database module \ No newline at end of file diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts new file mode 100644 index 00000000..8d5f7755 --- /dev/null +++ b/core/database/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.android.library.jacoco) + alias(libs.plugins.android.hilt) + alias(libs.plugins.android.room) +} + +android { + defaultConfig { + testInstrumentationRunner = "com.github.andiim.plantscan.core.testing.AppTestRunner" + } + namespace = "com.github.andiim.plantscan.core.database" +} + +dependencies { + implementation(project(":core:model")) + + implementation(libs.kotlin.coroutines.android) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.datetime) + implementation(libs.google.gson) + + androidTestImplementation(project(":core:testing")) +} \ No newline at end of file diff --git a/core/database/schemas/com.github.andiim.plantscan.core.database.PsDatabase/1.json b/core/database/schemas/com.github.andiim.plantscan.core.database.PsDatabase/1.json new file mode 100644 index 00000000..73a9597d --- /dev/null +++ b/core/database/schemas/com.github.andiim.plantscan.core.database.PsDatabase/1.json @@ -0,0 +1,40 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "e097772fefa8187f7608ddeee7fd5775", + "entities": [ + { + "tableName": "recentSearchQueries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query` TEXT NOT NULL, `queriedDate` INTEGER NOT NULL, PRIMARY KEY(`query`))", + "fields": [ + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "queriedDate", + "columnName": "queriedDate", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "query" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e097772fefa8187f7608ddeee7fd5775')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/main/AndroidManifest.xml b/core/database/src/main/AndroidManifest.xml new file mode 100644 index 00000000..568741e5 --- /dev/null +++ b/core/database/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/core/database/src/main/java/com/github/andiim/plantscan/core/database/DaosModule.kt b/core/database/src/main/java/com/github/andiim/plantscan/core/database/DaosModule.kt new file mode 100644 index 00000000..4070c079 --- /dev/null +++ b/core/database/src/main/java/com/github/andiim/plantscan/core/database/DaosModule.kt @@ -0,0 +1,28 @@ +package com.github.andiim.plantscan.core.database + +import com.github.andiim.plantscan.core.database.dao.PlantDao +import com.github.andiim.plantscan.core.database.dao.PlantFtsDao +import com.github.andiim.plantscan.core.database.dao.RecentSearchQueryDao +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +object DaosModule { + @Provides + fun providesRecentSearchQueryDao( + database: PsDatabase, + ): RecentSearchQueryDao = database.recentSearchQueryDao() + + @Provides + fun providesPlantDao( + database: PsDatabase, + ): PlantDao = database.plantDao() + + @Provides + fun providesPlantFtsDao( + database: PsDatabase, + ): PlantFtsDao = database.plantFtsDao() +} diff --git a/core/database/src/main/java/com/github/andiim/plantscan/core/database/DatabaseMigrations.kt b/core/database/src/main/java/com/github/andiim/plantscan/core/database/DatabaseMigrations.kt new file mode 100644 index 00000000..5e553aa5 --- /dev/null +++ b/core/database/src/main/java/com/github/andiim/plantscan/core/database/DatabaseMigrations.kt @@ -0,0 +1,22 @@ +package com.github.andiim.plantscan.core.database + +/** + * Automatic schema migrations sometimes require extra instructions to perform the migration, for + * example, when a column is renamed. These extra instructions are placed here by creating a class + * using the following naming convention `SchemaXtoY` where X is the schema version you're migrating + * from and Y is the schema version you're migrating to. The class should implement + * `AutoMigrationSpec`. + */ +@Suppress("EmptyClassBlock") +object DatabaseMigrations { + /** + * Example + * + * @RenameColumn( + * tableName = "topics", + * fromColumnName = "description", + * toColumnName = "shortDescription", + * ) + * class Schema2to3 : AutoMigrationSpec + */ +} diff --git a/core/database/src/main/java/com/github/andiim/plantscan/core/database/DatabaseModule.kt b/core/database/src/main/java/com/github/andiim/plantscan/core/database/DatabaseModule.kt new file mode 100644 index 00000000..33c224b3 --- /dev/null +++ b/core/database/src/main/java/com/github/andiim/plantscan/core/database/DatabaseModule.kt @@ -0,0 +1,24 @@ +package com.github.andiim.plantscan.core.database + +import android.content.Context +import androidx.room.Room +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 DatabaseModule { + @Provides + @Singleton + fun providesAppDatabase( + @ApplicationContext context: Context, + ): PsDatabase = Room.databaseBuilder( + context, + PsDatabase::class.java, + "ps-database", + ).build() +} diff --git a/core/database/src/main/java/com/github/andiim/plantscan/core/database/PsDatabase.kt b/core/database/src/main/java/com/github/andiim/plantscan/core/database/PsDatabase.kt new file mode 100644 index 00000000..45f9590d --- /dev/null +++ b/core/database/src/main/java/com/github/andiim/plantscan/core/database/PsDatabase.kt @@ -0,0 +1,34 @@ +package com.github.andiim.plantscan.core.database + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import com.github.andiim.plantscan.core.database.dao.PlantDao +import com.github.andiim.plantscan.core.database.dao.PlantFtsDao +import com.github.andiim.plantscan.core.database.dao.RecentSearchQueryDao +import com.github.andiim.plantscan.core.database.model.PlantEntity +import com.github.andiim.plantscan.core.database.model.PlantFtsEntity +import com.github.andiim.plantscan.core.database.model.PlantImageEntity +import com.github.andiim.plantscan.core.database.model.RecentSearchQueryEntity +import com.github.andiim.plantscan.core.database.util.InstantConverter +import com.github.andiim.plantscan.core.database.util.ListConverter + +@Database( + entities = [ + RecentSearchQueryEntity::class, + PlantEntity::class, + PlantImageEntity::class, + PlantFtsEntity::class, + ], + version = 2, + exportSchema = true, +) +@TypeConverters( + InstantConverter::class, + ListConverter::class, +) +abstract class PsDatabase : RoomDatabase() { + abstract fun recentSearchQueryDao(): RecentSearchQueryDao + abstract fun plantDao(): PlantDao + abstract fun plantFtsDao(): PlantFtsDao +} diff --git a/core/database/src/main/java/com/github/andiim/plantscan/core/database/dao/PlantDao.kt b/core/database/src/main/java/com/github/andiim/plantscan/core/database/dao/PlantDao.kt new file mode 100644 index 00000000..c4498536 --- /dev/null +++ b/core/database/src/main/java/com/github/andiim/plantscan/core/database/dao/PlantDao.kt @@ -0,0 +1,42 @@ +package com.github.andiim.plantscan.core.database.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Upsert +import com.github.andiim.plantscan.core.database.model.PlantAndImages +import com.github.andiim.plantscan.core.database.model.PlantEntity +import com.github.andiim.plantscan.core.database.model.PlantImageEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface PlantDao { + /** + * Inserts or updates [plants] into the db. + */ + @Upsert + fun insertPlant(plants: List) + + /** + * Inserts or updates [plantImage] into the db. + */ + @Upsert + fun insertPlantImage(plantImage: List) + + @Transaction + @Query(value = "SELECT * FROM plants") + fun getFlowPlantWithImage(): Flow> + + @Transaction + @Query(value = "SELECT * FROM plants") + fun getPlantWithImage(): List + + @Transaction + @Query( + value = """ + SELECT * FROM plants + WHERE id IN (:ids) + """, + ) + fun getPlantEntities(ids: Set): Flow> +} diff --git a/core/database/src/main/java/com/github/andiim/plantscan/core/database/dao/PlantFtsDao.kt b/core/database/src/main/java/com/github/andiim/plantscan/core/database/dao/PlantFtsDao.kt new file mode 100644 index 00000000..64afd402 --- /dev/null +++ b/core/database/src/main/java/com/github/andiim/plantscan/core/database/dao/PlantFtsDao.kt @@ -0,0 +1,23 @@ +package com.github.andiim.plantscan.core.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.github.andiim.plantscan.core.database.model.PlantFtsEntity +import kotlinx.coroutines.flow.Flow + +/** + * DAO for [String] access. + */ +@Dao +interface PlantFtsDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(plants: List) + + @Query("SELECT plantId FROM plantFts WHERE plantFts MATCH :query") + fun searchAllPlants(query: String): Flow> + + @Query("SELECT count(*) FROM plantFts") + fun getCount(): Flow +} diff --git a/core/database/src/main/java/com/github/andiim/plantscan/core/database/dao/RecentSearchQueryDao.kt b/core/database/src/main/java/com/github/andiim/plantscan/core/database/dao/RecentSearchQueryDao.kt new file mode 100644 index 00000000..c556df8c --- /dev/null +++ b/core/database/src/main/java/com/github/andiim/plantscan/core/database/dao/RecentSearchQueryDao.kt @@ -0,0 +1,19 @@ +package com.github.andiim.plantscan.core.database.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import com.github.andiim.plantscan.core.database.model.RecentSearchQueryEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface RecentSearchQueryDao { + @Query(value = "SELECT * FROM recentSearchQueries ORDER BY queriedDate DESC LIMIT :limit") + fun getRecentSearchQueryEntities(limit: Int): Flow> + + @Upsert + suspend fun insertOrReplaceRecentSearchQuery(recentSearchQuery: RecentSearchQueryEntity) + + @Query(value = "DELETE FROM recentSearchQueries") + suspend fun clearRecentSearchQueries() +} diff --git a/core/database/src/main/java/com/github/andiim/plantscan/core/database/model/PlantEntity.kt b/core/database/src/main/java/com/github/andiim/plantscan/core/database/model/PlantEntity.kt new file mode 100644 index 00000000..4a66fe17 --- /dev/null +++ b/core/database/src/main/java/com/github/andiim/plantscan/core/database/model/PlantEntity.kt @@ -0,0 +1,45 @@ +package com.github.andiim.plantscan.core.database.model + +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.PrimaryKey +import androidx.room.Relation +import com.github.andiim.plantscan.core.model.data.Plant + +/** + * Defines a plant data. + * It has one to many relationship with [PlantImageEntity]. + */ +@Entity( + tableName = "plants", +) +data class PlantEntity( + @PrimaryKey + val id: String, + val name: String, + val species: String, + val description: String, + val thumbnail: String, + val commonName: List, +) + +data class PlantAndImages( + @Embedded + val plant: PlantEntity, + + @Relation( + parentColumn = "id", + entityColumn = "plantImageId", + ) + val images: List, +) + +fun PlantAndImages.asExternalModel() = Plant( + id = plant.id, + name = plant.name, + species = plant.species, + description = plant.description, + thumbnail = plant.thumbnail, + commonName = plant.commonName, + images = images.map(PlantImageEntity::asExternalModel), +) diff --git a/core/database/src/main/java/com/github/andiim/plantscan/core/database/model/PlantFtsEntity.kt b/core/database/src/main/java/com/github/andiim/plantscan/core/database/model/PlantFtsEntity.kt new file mode 100644 index 00000000..9ce88ecc --- /dev/null +++ b/core/database/src/main/java/com/github/andiim/plantscan/core/database/model/PlantFtsEntity.kt @@ -0,0 +1,33 @@ +package com.github.andiim.plantscan.core.database.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Fts4 +import com.github.andiim.plantscan.core.model.data.Plant + +/** + * Fts entity for the plant. @see https://developer.android.com/reference/androidx/room/Fts4. + */ +@Entity(tableName = "plantFts") +@Fts4 +data class PlantFtsEntity( + @ColumnInfo(name = "plantId") + val plantId: String, + val name: String, + val species: String, + val description: String, +) + +fun PlantAndImages.asFtsEntity() = PlantFtsEntity( + plantId = plant.id, + name = plant.name, + species = plant.species, + description = plant.description, +) + +fun Plant.asFtsEntity() = PlantFtsEntity( + plantId = id, + name = name, + species = species, + description = description, +) diff --git a/core/database/src/main/java/com/github/andiim/plantscan/core/database/model/PlantImageEntity.kt b/core/database/src/main/java/com/github/andiim/plantscan/core/database/model/PlantImageEntity.kt new file mode 100644 index 00000000..a3f8a75b --- /dev/null +++ b/core/database/src/main/java/com/github/andiim/plantscan/core/database/model/PlantImageEntity.kt @@ -0,0 +1,24 @@ +package com.github.andiim.plantscan.core.database.model + +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.github.andiim.plantscan.core.model.data.PlantImage + +@Entity( + tableName = "plantImages", +) +data class PlantImageEntity( + @PrimaryKey + val plantImageId: String, + val plantRefId: String, + val url: String, + val attribution: String, + val description: String, +) + +fun PlantImageEntity.asExternalModel() = PlantImage( + id = plantImageId, + url = url, + attribution = attribution, + description = description, +) diff --git a/core/database/src/main/java/com/github/andiim/plantscan/core/database/model/RecentSearchQueryEntity.kt b/core/database/src/main/java/com/github/andiim/plantscan/core/database/model/RecentSearchQueryEntity.kt new file mode 100644 index 00000000..7ea17af5 --- /dev/null +++ b/core/database/src/main/java/com/github/andiim/plantscan/core/database/model/RecentSearchQueryEntity.kt @@ -0,0 +1,19 @@ +package com.github.andiim.plantscan.core.database.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import kotlinx.datetime.Instant + +/** + * Defines an database entity that stored recent search queries. + */ +@Entity( + tableName = "recentSearchQueries" +) +data class RecentSearchQueryEntity( + @PrimaryKey + val query: String, + @ColumnInfo + val queriedDate: Instant +) diff --git a/core/database/src/main/java/com/github/andiim/plantscan/core/database/util/InstantConverter.kt b/core/database/src/main/java/com/github/andiim/plantscan/core/database/util/InstantConverter.kt new file mode 100644 index 00000000..5452d798 --- /dev/null +++ b/core/database/src/main/java/com/github/andiim/plantscan/core/database/util/InstantConverter.kt @@ -0,0 +1,14 @@ +package com.github.andiim.plantscan.core.database.util + +import androidx.room.TypeConverter +import kotlinx.datetime.Instant + +class InstantConverter { + @TypeConverter + fun longToInstant(value: Long?): Instant? = + value?.let(Instant::fromEpochMilliseconds) + + @TypeConverter + fun instantToLong(instant: Instant?): Long? = + instant?.toEpochMilliseconds() +} diff --git a/core/database/src/main/java/com/github/andiim/plantscan/core/database/util/ListConverter.kt b/core/database/src/main/java/com/github/andiim/plantscan/core/database/util/ListConverter.kt new file mode 100644 index 00000000..a10945e0 --- /dev/null +++ b/core/database/src/main/java/com/github/andiim/plantscan/core/database/util/ListConverter.kt @@ -0,0 +1,19 @@ +package com.github.andiim.plantscan.core.database.util + +import androidx.room.TypeConverter +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import java.lang.reflect.Type + +class ListConverter { + @TypeConverter + fun stringToList(value: String?): List? = + value.let { + val listType: Type = object : TypeToken?>() {}.type + return Gson().fromJson(it, listType) + } + + @TypeConverter + fun listToString(value: List?): String? = + value?.let(Gson()::toJson) +} diff --git a/core/datastore-test/.gitignore b/core/datastore-test/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/datastore-test/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/datastore-test/README.md b/core/datastore-test/README.md new file mode 100644 index 00000000..64c19145 --- /dev/null +++ b/core/datastore-test/README.md @@ -0,0 +1 @@ +# :core:datastore-test module diff --git a/core/datastore-test/build.gradle.kts b/core/datastore-test/build.gradle.kts new file mode 100644 index 00000000..7c3b40db --- /dev/null +++ b/core/datastore-test/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.android.hilt) +} + +android { + namespace = "com.github.andiim.plantscan.datastore.test" +} + +dependencies { + api(project(":core:datastore")) + api(libs.androidx.dataStore.core) + + implementation(libs.protobuf.kotlin.lite) + implementation(project(":core:common")) + implementation(project(":core:testing")) +} \ No newline at end of file diff --git a/core/datastore-test/src/main/AndroidManifest.xml b/core/datastore-test/src/main/AndroidManifest.xml new file mode 100644 index 00000000..568741e5 --- /dev/null +++ b/core/datastore-test/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/core/datastore-test/src/main/java/com/github/andiim/plantscan/core/datastore/test/TestDataStoreModule.kt b/core/datastore-test/src/main/java/com/github/andiim/plantscan/core/datastore/test/TestDataStoreModule.kt new file mode 100644 index 00000000..c8c79571 --- /dev/null +++ b/core/datastore-test/src/main/java/com/github/andiim/plantscan/core/datastore/test/TestDataStoreModule.kt @@ -0,0 +1,44 @@ +package com.github.andiim.plantscan.core.datastore.test + +import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import com.github.andiim.plantscan.core.datastore.UserPreferences +import com.github.andiim.plantscan.core.datastore.UserPreferencesSerializer +import com.github.andiim.plantscan.core.datastore.di.DataStoreModule +import com.github.andiim.plantscan.core.network.di.ApplicationScope +import dagger.Module +import dagger.Provides +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import kotlinx.coroutines.CoroutineScope +import org.junit.rules.TemporaryFolder +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [DataStoreModule::class], +) +object TestDataStoreModule { + @Provides + @Singleton + fun providesUserPreferencesDataStore( + @ApplicationScope scope: CoroutineScope, + userPreferencesSerializer: UserPreferencesSerializer, + tmpFolder: TemporaryFolder, + ): DataStore = + tmpFolder.testUserPreferencesDataStore( + coroutineScope = scope, + userPreferencesSerializer = userPreferencesSerializer, + ) +} + +fun TemporaryFolder.testUserPreferencesDataStore( + coroutineScope: CoroutineScope, + userPreferencesSerializer: UserPreferencesSerializer = UserPreferencesSerializer(), +) = DataStoreFactory.create( + serializer = userPreferencesSerializer, + scope = coroutineScope, +) { + newFile("plantscan_user_preferences_test.pb") +} diff --git a/core/datastore/.gitignore b/core/datastore/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/datastore/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/datastore/README.md b/core/datastore/README.md new file mode 100644 index 00000000..4e5709bc --- /dev/null +++ b/core/datastore/README.md @@ -0,0 +1 @@ +# :core:datastore module diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts new file mode 100644 index 00000000..35b3829a --- /dev/null +++ b/core/datastore/build.gradle.kts @@ -0,0 +1,64 @@ +import com.android.aaptcompiler.shouldIgnoreElement + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.android.library.jacoco) + alias(libs.plugins.protobuf) + id("dagger.hilt.android.plugin") + kotlin("kapt") +} + +android { + defaultConfig { + consumerProguardFiles("consumer-proguard-rules.pro") + } + namespace = "com.github.andiim.plantscan.core.datastore" + testOptions { + unitTests { + isReturnDefaultValues = true + } + } +} + +/*tasks.named("protobuf").configure { + +}*/ + +// Setup protobuf configuration, generating lite Java and Kotlin classes +protobuf { + protoc { + artifact = libs.protobuf.protoc.get().toString() + } + generateProtoTasks { + all().forEach { task -> + task.builtins { + register("java") { + option("lite") + } + register("kotlin") { + option("lite") + } + } + } + } +} + +androidComponents.beforeVariants { + android.sourceSets.register(it.name) { + java.srcDir(buildDir.resolve("generated/source/proto/${it.name}/java")) + kotlin.srcDir(buildDir.resolve("generated/source/proto/${it.name}/kotlin")) + } +} + +dependencies { + implementation(project(":core:common")) + implementation(project(":core:model")) + implementation(libs.hilt.android) + kapt(libs.hilt.compiler) + implementation(libs.androidx.dataStore.core) + implementation(libs.protobuf.kotlin.lite) + implementation(libs.kotlin.coroutines.android) + + testImplementation(project(":core:datastore-test")) + testImplementation(project(":core:testing")) +} \ No newline at end of file diff --git a/core/datastore/consumer-proguard-rules.pro b/core/datastore/consumer-proguard-rules.pro new file mode 100644 index 00000000..17327391 --- /dev/null +++ b/core/datastore/consumer-proguard-rules.pro @@ -0,0 +1,4 @@ +# Keep DataStore fields +-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite* { + ; +} \ No newline at end of file diff --git a/core/datastore/src/main/AndroidManifest.xml b/core/datastore/src/main/AndroidManifest.xml new file mode 100644 index 00000000..568741e5 --- /dev/null +++ b/core/datastore/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/core/datastore/src/main/java/com/github/andiim/plantscan/core/datastore/AppPreferencesDataSource.kt b/core/datastore/src/main/java/com/github/andiim/plantscan/core/datastore/AppPreferencesDataSource.kt new file mode 100644 index 00000000..85fd7dcf --- /dev/null +++ b/core/datastore/src/main/java/com/github/andiim/plantscan/core/datastore/AppPreferencesDataSource.kt @@ -0,0 +1,73 @@ +package com.github.andiim.plantscan.core.datastore + +import androidx.datastore.core.DataStore +import com.github.andiim.plantscan.core.model.data.DarkThemeConfig +import com.github.andiim.plantscan.core.model.data.UserData +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class AppPreferencesDataSource @Inject constructor( + private val userPreferences: DataStore, +) { + val userData = userPreferences.data + .map { + UserData( + isLogin = it.isLogin, + userId = it.loginData, + darkThemeConfig = when (it.darkThemeConfig) { + null, + DarkThemeConfigProto.DARK_THEME_CONFIG_UNSPECIFIED, + DarkThemeConfigProto.UNRECOGNIZED, + DarkThemeConfigProto.DARK_THEME_CONFIG_FOLLOW_SYSTEM, + -> + DarkThemeConfig.FOLLOW_SYSTEM + + DarkThemeConfigProto.DARK_THEME_CONFIG_LIGHT -> + DarkThemeConfig.LIGHT + + DarkThemeConfigProto.DARK_THEME_CONFIG_DARK -> DarkThemeConfig.DARK + }, + shouldHideOnboarding = it.shouldHideOnboarding, + useDynamicColor = it.useDynamicColor, + ) + } + + suspend fun setUserData(userId: String, isAnonymous: Boolean) { + userPreferences.updateData { + it.copy { + this.isLogin = !isAnonymous + this.loginData = userId + } + } + } + + suspend fun setDynamicColorPreference(useDynamicColor: Boolean) { + userPreferences.updateData { + it.copy { + this.useDynamicColor = useDynamicColor + } + } + } + + suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) { + userPreferences.updateData { + it.copy { + this.darkThemeConfig = when (darkThemeConfig) { + DarkThemeConfig.FOLLOW_SYSTEM -> + DarkThemeConfigProto.DARK_THEME_CONFIG_FOLLOW_SYSTEM + + DarkThemeConfig.LIGHT -> DarkThemeConfigProto.DARK_THEME_CONFIG_LIGHT + DarkThemeConfig.DARK -> DarkThemeConfigProto.DARK_THEME_CONFIG_DARK + } + } + } + } + + suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) { + userPreferences.updateData { + it.copy { + this.shouldHideOnboarding = shouldHideOnboarding + } + } + } +} diff --git a/core/datastore/src/main/java/com/github/andiim/plantscan/core/datastore/ChangeListVersions.kt b/core/datastore/src/main/java/com/github/andiim/plantscan/core/datastore/ChangeListVersions.kt new file mode 100644 index 00000000..38802d7f --- /dev/null +++ b/core/datastore/src/main/java/com/github/andiim/plantscan/core/datastore/ChangeListVersions.kt @@ -0,0 +1,8 @@ +package com.github.andiim.plantscan.core.datastore + +/** + * Class summarizing the local version of each model for sync. + */ +data class ChangeListVersions( + val plantVersion: Int = -1, +) diff --git a/core/datastore/src/main/java/com/github/andiim/plantscan/core/datastore/UserPreferencesSerializer.kt b/core/datastore/src/main/java/com/github/andiim/plantscan/core/datastore/UserPreferencesSerializer.kt new file mode 100644 index 00000000..627651ee --- /dev/null +++ b/core/datastore/src/main/java/com/github/andiim/plantscan/core/datastore/UserPreferencesSerializer.kt @@ -0,0 +1,26 @@ +package com.github.andiim.plantscan.core.datastore + +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.Serializer +import com.google.protobuf.InvalidProtocolBufferException +import java.io.InputStream +import java.io.OutputStream +import javax.inject.Inject + +/** + * An [androidx.datastore.core.Serializer] for the [UserPreferences] proto. + */ +class UserPreferencesSerializer @Inject constructor() : Serializer { + override val defaultValue: UserPreferences = UserPreferences.getDefaultInstance() + + override suspend fun readFrom(input: InputStream): UserPreferences = + try { + UserPreferences.parseFrom(input) + } catch (exception: InvalidProtocolBufferException) { + throw CorruptionException("Cannot read proto.", exception) + } + + override suspend fun writeTo(t: UserPreferences, output: OutputStream) { + t.writeTo(output) + } +} diff --git a/core/datastore/src/main/java/com/github/andiim/plantscan/core/datastore/di/DataStoreModule.kt b/core/datastore/src/main/java/com/github/andiim/plantscan/core/datastore/di/DataStoreModule.kt new file mode 100644 index 00000000..ad948721 --- /dev/null +++ b/core/datastore/src/main/java/com/github/andiim/plantscan/core/datastore/di/DataStoreModule.kt @@ -0,0 +1,39 @@ +package com.github.andiim.plantscan.core.datastore.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.dataStoreFile +import com.github.andiim.plantscan.core.datastore.UserPreferences +import com.github.andiim.plantscan.core.datastore.UserPreferencesSerializer +import com.github.andiim.plantscan.core.network.AppDispatchers.IO +import com.github.andiim.plantscan.core.network.Dispatcher +import com.github.andiim.plantscan.core.network.di.ApplicationScope +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DataStoreModule { + @Provides + @Singleton + fun providesPreferencesDataStore( + @ApplicationContext context: Context, + @Dispatcher(IO) ioDispatcher: CoroutineDispatcher, + @ApplicationScope scope: CoroutineScope, + userPreferencesSerializer: UserPreferencesSerializer, + ): DataStore = + DataStoreFactory.create( + serializer = userPreferencesSerializer, + scope = CoroutineScope(scope.coroutineContext + ioDispatcher), + migrations = listOf(), + ) { + context.dataStoreFile("user_preferences.pb") + } +} diff --git a/core/datastore/src/main/proto/com/github/andiim/plantscan/data/dark_theme_config.proto b/core/datastore/src/main/proto/com/github/andiim/plantscan/data/dark_theme_config.proto new file mode 100644 index 00000000..3ecebe20 --- /dev/null +++ b/core/datastore/src/main/proto/com/github/andiim/plantscan/data/dark_theme_config.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +option java_package = "com.github.andiim.plantscan.core.datastore"; +option java_multiple_files = true; + +enum DarkThemeConfigProto { + DARK_THEME_CONFIG_UNSPECIFIED = 0; + DARK_THEME_CONFIG_FOLLOW_SYSTEM = 1; + DARK_THEME_CONFIG_LIGHT = 2; + DARK_THEME_CONFIG_DARK = 3; +} \ No newline at end of file diff --git a/core/datastore/src/main/proto/com/github/andiim/plantscan/data/user_preferences.proto b/core/datastore/src/main/proto/com/github/andiim/plantscan/data/user_preferences.proto new file mode 100644 index 00000000..a87d99d1 --- /dev/null +++ b/core/datastore/src/main/proto/com/github/andiim/plantscan/data/user_preferences.proto @@ -0,0 +1,15 @@ +syntax = "proto3"; + +import "com/github/andiim/plantscan/data/dark_theme_config.proto"; + +option java_package = "com.github.andiim.plantscan.core.datastore"; +option java_multiple_files = true; + +message UserPreferences { + reserved 2; + bool is_login = 1; + string loginData = 3; + DarkThemeConfigProto dark_theme_config = 17; + bool should_hide_onboarding = 18; + bool use_dynamic_color = 19; +} \ No newline at end of file diff --git a/core/designsystem/.gitignore b/core/designsystem/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/designsystem/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/designsystem/README.md b/core/designsystem/README.md new file mode 100644 index 00000000..643cd3da --- /dev/null +++ b/core/designsystem/README.md @@ -0,0 +1 @@ +# :core:designsystem module \ No newline at end of file diff --git a/core/designsystem/build.gradle.kts b/core/designsystem/build.gradle.kts new file mode 100644 index 00000000..ae32bd07 --- /dev/null +++ b/core/designsystem/build.gradle.kts @@ -0,0 +1,34 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.android.library.compose) + alias(libs.plugins.android.library.jacoco) +} + +android { + defaultConfig { + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + lint { + checkDependencies = true + } + namespace = "com.github.andiim.plantscan.core.designsystem" +} + +dependencies { + lintPublish(project(":lint")) + + api(libs.compose.foundation) + api(libs.compose.foundation.layout) + api(libs.compose.materialIcons) + api(libs.compose.material) + api(libs.compose.runtime) + api(libs.compose.ui.tooling.preview) + api(libs.compose.ui.util) + + debugApi(libs.compose.ui.tooling) + + implementation(libs.androidx.core.ktx) + implementation(libs.coil.compose) + + androidTestImplementation(project(":core:testing")) +} \ No newline at end of file diff --git a/core/designsystem/src/androidTest/java/com/github/andiim/plantscan/core/designsystem/ThemeTest.kt b/core/designsystem/src/androidTest/java/com/github/andiim/plantscan/core/designsystem/ThemeTest.kt new file mode 100644 index 00000000..4e3ba356 --- /dev/null +++ b/core/designsystem/src/androidTest/java/com/github/andiim/plantscan/core/designsystem/ThemeTest.kt @@ -0,0 +1,73 @@ +package com.github.andiim.plantscan.core.designsystem + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.junit4.createComposeRule +import com.github.andiim.plantscan.core.designsystem.theme.LocalBackgroundTheme +import com.github.andiim.plantscan.core.designsystem.theme.PsTheme +import com.github.andiim.plantscan.core.designsystem.theme.lightColorScheme +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals + +/** + * Tests [PsTheme] using different combinations of the theme mode parameters: + * darkTheme, disableDynamicTheming + * + * It verifies that the various composition locals - [MaterialTheme], [LocalBackgroundTheme] + * have the expected values for a given theme mode, as specific by the design system. + */ +class ThemeTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun darkThemeFalse_dynamicColorFalse() { + composeTestRule.setContent { + PsTheme( + darkTheme = false, + disableDynamicTheming = true, + ) { + val colorScheme = lightColorScheme + assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme) + } + } + } + + /** + * Workaround for the fact that the NiA design system specify all color scheme values. + */ + private fun assertColorSchemesEqual( + expectedColorScheme: ColorScheme, + actualColorScheme: ColorScheme, + ) { + assertEquals(expectedColorScheme.primary, actualColorScheme.primary) + assertEquals(expectedColorScheme.onPrimary, actualColorScheme.onPrimary) + assertEquals(expectedColorScheme.primaryContainer, actualColorScheme.primaryContainer) + assertEquals(expectedColorScheme.onPrimaryContainer, actualColorScheme.onPrimaryContainer) + assertEquals(expectedColorScheme.secondary, actualColorScheme.secondary) + assertEquals(expectedColorScheme.onSecondary, actualColorScheme.onSecondary) + assertEquals(expectedColorScheme.secondaryContainer, actualColorScheme.secondaryContainer) + assertEquals( + expectedColorScheme.onSecondaryContainer, + actualColorScheme.onSecondaryContainer, + ) + assertEquals(expectedColorScheme.tertiary, actualColorScheme.tertiary) + assertEquals(expectedColorScheme.onTertiary, actualColorScheme.onTertiary) + assertEquals(expectedColorScheme.tertiaryContainer, actualColorScheme.tertiaryContainer) + assertEquals(expectedColorScheme.onTertiaryContainer, actualColorScheme.onTertiaryContainer) + assertEquals(expectedColorScheme.error, actualColorScheme.error) + assertEquals(expectedColorScheme.onError, actualColorScheme.onError) + assertEquals(expectedColorScheme.errorContainer, actualColorScheme.errorContainer) + assertEquals(expectedColorScheme.onErrorContainer, actualColorScheme.onErrorContainer) + assertEquals(expectedColorScheme.background, actualColorScheme.background) + assertEquals(expectedColorScheme.onBackground, actualColorScheme.onBackground) + assertEquals(expectedColorScheme.surface, actualColorScheme.surface) + assertEquals(expectedColorScheme.onSurface, actualColorScheme.onSurface) + assertEquals(expectedColorScheme.surfaceVariant, actualColorScheme.surfaceVariant) + assertEquals(expectedColorScheme.onSurfaceVariant, actualColorScheme.onSurfaceVariant) + assertEquals(expectedColorScheme.inverseSurface, actualColorScheme.inverseSurface) + assertEquals(expectedColorScheme.inverseOnSurface, actualColorScheme.inverseOnSurface) + assertEquals(expectedColorScheme.outline, actualColorScheme.outline) + } +} diff --git a/core/designsystem/src/main/AndroidManifest.xml b/core/designsystem/src/main/AndroidManifest.xml new file mode 100644 index 00000000..568741e5 --- /dev/null +++ b/core/designsystem/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/component/AnimatedVisibility.kt b/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/component/AnimatedVisibility.kt new file mode 100644 index 00000000..64b3a68d --- /dev/null +++ b/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/component/AnimatedVisibility.kt @@ -0,0 +1,46 @@ +package com.github.andiim.plantscan.core.designsystem.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.expandIn +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.runtime.Composable + +@Composable +fun PsAnimatedVisibility( + visible: Boolean, + animatedData: PsAnimatedVisibilityData = PsAnimatedVisibilityData.Default, + content: @Composable AnimatedVisibilityScope.() -> Unit, +) { + val enter = when (animatedData) { + PsAnimatedVisibilityData.BottomBar -> animatedData.enter + PsAnimatedVisibilityData.Default -> animatedData.enter + } + val exit = when (animatedData) { + PsAnimatedVisibilityData.BottomBar -> animatedData.exit + PsAnimatedVisibilityData.Default -> animatedData.exit + } + AnimatedVisibility( + visible = visible, + enter = enter, + exit = exit, + content = content, + ) +} + +enum class PsAnimatedVisibilityData( + val enter: EnterTransition = fadeIn() + expandIn(), + val exit: ExitTransition = shrinkOut() + fadeOut(), +) { + BottomBar( + enter = slideInVertically(initialOffsetY = { it }), + exit = slideOutVertically(targetOffsetY = { it }), + ), + Default(), +} diff --git a/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/component/Background.kt b/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/component/Background.kt new file mode 100644 index 00000000..1fb56e34 --- /dev/null +++ b/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/component/Background.kt @@ -0,0 +1,65 @@ +package com.github.andiim.plantscan.core.designsystem.component + +import android.content.res.Configuration +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.LocalAbsoluteTonalElevation +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.github.andiim.plantscan.core.designsystem.theme.LocalBackgroundTheme +import com.github.andiim.plantscan.core.designsystem.theme.PsTheme + +/** + * The main background for the app. + * Uses [LocalBackgroundTheme] to set the color and tonal elevation of a [Surface]. + * + * @param modifier Modifier to be applied to the background. + * @param content The background content. + */ +@Composable +fun PsBackground( + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val color = LocalBackgroundTheme.current.color + val tonalElevation = LocalBackgroundTheme.current.tonalElevation + Surface( + color = if (color == Color.Unspecified) Color.Transparent else color, + tonalElevation = if (tonalElevation == Dp.Unspecified) 0.dp else tonalElevation, + modifier = modifier.fillMaxSize(), + ) { + CompositionLocalProvider(LocalAbsoluteTonalElevation provides 0.dp) { + content() + } + } +} + +/** + * Multipreview annotation that represents light and dark themes. Add this annotation to a + * composable to render the both themes. + */ +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, name = "Light theme") +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark theme") +annotation class ThemePreviews + +@ThemePreviews +@Composable +fun BackgroundDefault() { + PsTheme(disableDynamicTheming = true) { + PsBackground(Modifier.size(100.dp), content = {}) + } +} + +@ThemePreviews +@Composable +fun BackgroundDynamic() { + PsTheme(disableDynamicTheming = false) { + PsBackground(Modifier.size(100.dp), content = {}) + } +} diff --git a/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/component/Button.kt b/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/component/Button.kt new file mode 100644 index 00000000..5dab8414 --- /dev/null +++ b/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/component/Button.kt @@ -0,0 +1,299 @@ +package com.github.andiim.plantscan.core.designsystem.component + +import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * Plantscan filled button with generic content slot. Wraps Material 3 [Button] + * + * @param onClick Will be called when the user clicks the button. + * @param modifier Modifier to be applied to the button. + * @param enabled Controls the enabled state of the button. When `false`, this button will not be + * clickable and will appear disabled to accessibility services. + * @param contentPadding The spacing values to apply internally between the container and the + * content. + * @param content The button content. + */ +@Composable +fun PsButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + contentPadding: PaddingValues = ButtonDefaults.ContentPadding, + content: @Composable RowScope.() -> Unit, +) { + Button( + onClick = onClick, + modifier = modifier, + enabled = enabled, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.onBackground, + ), + contentPadding = contentPadding, + content = content, + ) +} + +@Composable +fun PsButton( + @StringRes text: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + contentPadding: PaddingValues = ButtonDefaults.ContentPadding, +) { + PsButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + contentPadding = contentPadding, + ) { + AnimatedContent( + targetState = text, + transitionSpec = { + fadeIn() togetherWith fadeOut() + }, + label = "PsButton_anim", + ) { target -> + Text(text = stringResource(target), fontSize = 16.sp) + } + } +} + +/** + * Plantscan text button with text and icon content slots. + * + * @param onClick Will be called when the user clicks the button. + * @param modifier Modifier to be applied to the button. + * @param enabled Controls the enabled state of the button. When `false`, this button will not be + * clickable and will appear disabled to accessibility services. + * @param text The button text label content. + * @param leadingIcon The button leading icon content. Pass `null` here for no leading icon + */ +@Composable +fun PsButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + text: @Composable () -> Unit, + leadingIcon: @Composable (() -> Unit)? = null, +) { + PsButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + contentPadding = if (leadingIcon != null) { + ButtonDefaults.ButtonWithIconContentPadding + } else { + ButtonDefaults.ContentPadding + }, + ) { + PsButtonContent( + text = text, + leadingIcon = leadingIcon, + ) + } +} + +/** + * Plantscan outlined button with generic content slot. Wraps Material 3 [OutlinedButton]. + * + * @param onClick Will be called when the user clicks the button. + * @param modifier Modifier to be applied to the button. + * @param enabled Controls the enabled state of the button. When `false`, this button will not be + * clickable and will appear disabled to accessibility services. + * @param contentPadding The spacing values to apply internally between the container and the + * content. + * @param content The button content. + */ +@Composable +fun PsOutlinedButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + contentPadding: PaddingValues = ButtonDefaults.ContentPadding, + content: @Composable RowScope.() -> Unit, +) { + OutlinedButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.onBackground, + ), + border = BorderStroke( + width = PsButtonDefaults.OutlinedButtonBorderWidth, + color = if (enabled) { + MaterialTheme.colorScheme.outline + } else { + MaterialTheme.colorScheme.onSurface.copy( + alpha = PsButtonDefaults.DisabledOutlinedButtonBorderAlpha, + ) + }, + ), + contentPadding = contentPadding, + content = content, + ) +} + +/** + * Plantscan outlined button with text and icon content slots. + * + * @param onClick Will be called when the user clicks the button. + * @param modifier Modifier to be applied to the button. + * @param enabled Controls the enabled state of the button. When `false`, this button will not be + * clickable and will appear disabled to accessibility services. + * @param text The button text label content. + * @param leadingIcon The button leading icon content. Pass `null` here for no leading icon. + */ +@Composable +fun PsOutlinedButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + text: @Composable () -> Unit, + leadingIcon: @Composable (() -> Unit)? = null, +) { + OutlinedButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.onBackground, + ), + border = BorderStroke( + width = PsButtonDefaults.OutlinedButtonBorderWidth, + color = if (enabled) { + MaterialTheme.colorScheme.outline + } else { + MaterialTheme.colorScheme.onSurface.copy( + alpha = PsButtonDefaults.DisabledOutlinedButtonBorderAlpha, + ) + }, + ), + contentPadding = if (leadingIcon != null) { + ButtonDefaults.ButtonWithIconContentPadding + } else { + ButtonDefaults.ContentPadding + }, + ) { + PsButtonContent( + text = text, + leadingIcon = leadingIcon, + ) + } +} + +/** + * PlantScan text button with generic content slot. Wraps Material 3 [TextButton]. + * + * @param onClick Will be called when the user clicks the button. + * @param modifier Modifier to be applied to the button. + * @param enabled Controls the enabled state of the button. When `false`, this button will not be + * clickable and will appear disabled to accessibility services. + * @param content The button content. + */ +@Composable +fun PsTextButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + content: @Composable RowScope.() -> Unit, +) { + TextButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.onBackground, + ), + content = content, + ) +} + +/** + * Plantscan text button with text and icon content slots. + * + * @param onClick Will be called when the user clicks the button. + * @param modifier Modifier to be applied to the button. + * @param enabled Controls the enabled state of the button. When `false`, this button will not be + * clickable and will appear disabled to accessibility services. + * @param text The button text label content. + * @param leadingIcon The button leading icon content. Pass `null` here for no leading icon. + */ +@Composable +fun PsTextButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + text: @Composable () -> Unit, + leadingIcon: @Composable (() -> Unit)? = null, +) { + TextButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + ) { + PsButtonContent( + text = text, + leadingIcon = leadingIcon, + ) + } +} + +/** + * Internal Plantscan button content layout for arranging the text label and leading icon. + * + * @param text The button text label content. + * @param leadingIcon The button leading icon content. Default is `null` for no leading icon. + */ +@Composable +private fun PsButtonContent( + text: @Composable () -> Unit, + leadingIcon: @Composable (() -> Unit)? = null, +) { + if (leadingIcon != null) { + Box(modifier = Modifier.sizeIn(maxHeight = ButtonDefaults.IconSize)) { + leadingIcon() + } + } + Box( + modifier = Modifier.padding( + start = if (leadingIcon != null) { + ButtonDefaults.IconSpacing + } else { + 0.dp + }, + ), + ) { + text() + } +} + +/** + * Plantscan button default values. + */ +object PsButtonDefaults { + const val DisabledOutlinedButtonBorderAlpha = 0.12f + val OutlinedButtonBorderWidth = 1.dp +} diff --git a/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/component/CircleButton.kt b/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/component/CircleButton.kt new file mode 100644 index 00000000..3cb5df95 --- /dev/null +++ b/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/component/CircleButton.kt @@ -0,0 +1,63 @@ +package com.github.andiim.plantscan.core.designsystem.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import com.github.andiim.plantscan.core.designsystem.icon.PsIcons +import com.github.andiim.plantscan.core.designsystem.theme.PsTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RoundIconButton( + imageVector: ImageVector, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + onClick = onClick, + shape = CircleShape, + elevation = CardDefaults.elevatedCardElevation( + defaultElevation = 4.dp, + ), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer + ), + ) { + Icon( + imageVector, + contentDescription = contentDescription, + modifier = modifier + .padding(30.dp) + .shadow(8.dp, shape = CircleShape), + ) + } +} + +@ThemePreviews +@Composable +fun ButtonPreview() { + PsTheme { + Surface { + Box(modifier = Modifier.padding(16.dp)) { + RoundIconButton( + imageVector = PsIcons.Camera, + contentDescription = "Testing", + onClick = {}, + ) + } + } + } +} diff --git a/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/component/Navigation.kt b/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/component/Navigation.kt new file mode 100644 index 00000000..b72f6fcb --- /dev/null +++ b/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/component/Navigation.kt @@ -0,0 +1,154 @@ +package com.github.andiim.plantscan.core.designsystem.component + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.NavigationRailItemDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * Plantscan navigation bar item with icon and label content slots. Wraps Material 3 + * [NavigationBarItem]. + * + * @param selected Whether this item is selected. + * @param onClick the callback to be invoked when this item is selected. + * @param icon The item icon content. + * @param modifier Modifier to be applied to this item. + * @param selectedIcon The item icon content when selected. + * @param enabled controls the enabled stated of this item. When `false`, this item will not be + * clickable and will appear disabled to accessibility services. + * @param label The item text label content. + * @param alwaysShowLabel Whether to always show the label for this item. If false, the label will + * only be shown when this item is selected. + */ +@Composable +fun RowScope.PsNavigationBarItem( + selected: Boolean, + onClick: () -> Unit, + icon: @Composable () -> Unit, + modifier: Modifier = Modifier, + selectedIcon: @Composable () -> Unit = icon, + enabled: Boolean = true, + label: @Composable (() -> Unit)? = null, + alwaysShowLabel: Boolean = true, +) { + NavigationBarItem( + selected = selected, + onClick = onClick, + icon = if (selected) selectedIcon else icon, + modifier = modifier, + enabled = enabled, + label = label, + alwaysShowLabel = alwaysShowLabel, + colors = NavigationBarItemDefaults.colors(), + ) +} + +/** + * Plantscan navigation bar with content slot. Wraps Material 3 [NavigationBar]. + * + * @param modifier Modifier to be applied to the navigation bar. + * @param content Destinations inside the navigation bar. This should contain multiple + * [NavigationBarItem]s. + */ +@Composable +fun PsNavigationBar( + modifier: Modifier = Modifier, + content: @Composable RowScope.() -> Unit, +) { + NavigationBar( + modifier = modifier, + contentColor = PsNavigationDefaults.navigationContentColor(), + tonalElevation = 0.dp, + content = content, + ) +} + +/** + * Plantscan rail item with icon and label content slots. Wraps Material 3 + * [NavigationRailItem]. + * + * @param selected Whether this item is selected. + * @param onClick The callback to be invoked when this item is selected. + * @param icon The item icon content. + * @param modifier Modifier to be applied to this item. + * @param selectedIcon The item icon content when selected. + * @param enabled controls the enabled state of this item. When `false`, this item will not be + * clickable and will appear disabled to accessibility services. + * @param label The item text label content. + * @param alwaysShowLabel Whether to always show the label for this item. If false, the label will + * only be shown when this item is selected. + */ +@Composable +fun PsNavigationRailItem( + selected: Boolean, + onClick: () -> Unit, + icon: @Composable () -> Unit, + modifier: Modifier = Modifier, + selectedIcon: @Composable () -> Unit, + enabled: Boolean = true, + label: @Composable (() -> Unit)? = null, + alwaysShowLabel: Boolean = true, +) { + NavigationRailItem( + selected = selected, + onClick = onClick, + icon = if (selected) selectedIcon else icon, + modifier = modifier, + enabled = enabled, + label = label, + alwaysShowLabel = alwaysShowLabel, + colors = NavigationRailItemDefaults.colors( + selectedIconColor = PsNavigationDefaults.navigationSelectedItemColor(), + unselectedIconColor = PsNavigationDefaults.navigationContentColor(), + selectedTextColor = PsNavigationDefaults.navigationSelectedItemColor(), + unselectedTextColor = PsNavigationDefaults.navigationContentColor(), + indicatorColor = PsNavigationDefaults.navigationIndicatorColor(), + ), + ) +} + +/** + * Plantscan navigation rail with header and content slots. Wraps Material 3 [NavigationRail]. + * + * @param modifier Modifier to be applied to the navigation rail. + * @param header Optional header may be hold a floating action button or a logo. + * @param content Destination inside the navigation rail. This should contain multiple + * [NavigationRailItem]s. + */ +@Composable +fun PsNavigationRail( + modifier: Modifier = Modifier, + header: @Composable (ColumnScope.() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit, +) { + NavigationRail( + modifier = modifier, + containerColor = Color.Transparent, + contentColor = PsNavigationDefaults.navigationContentColor(), + header = header, + content = content, + ) +} + +/** + * Plantscan navigation default values. + */ +object PsNavigationDefaults { + @Composable + fun navigationContentColor() = MaterialTheme.colorScheme.onSurfaceVariant + + @Composable + fun navigationSelectedItemColor() = MaterialTheme.colorScheme.onPrimaryContainer + + @Composable + fun navigationIndicatorColor() = MaterialTheme.colorScheme.primaryContainer +} diff --git a/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/component/TopAppBar.kt b/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/component/TopAppBar.kt new file mode 100644 index 00000000..5a64550e --- /dev/null +++ b/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/component/TopAppBar.kt @@ -0,0 +1,35 @@ +package com.github.andiim.plantscan.core.designsystem.component + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarColors +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PsTopAppBar( + @StringRes titleRes: Int, + modifier: Modifier = Modifier, + navigationIcon: @Composable () -> Unit = {}, + actionIcon: @Composable (RowScope.() -> Unit) = {}, + colors: TopAppBarColors = TopAppBarDefaults.centerAlignedTopAppBarColors(), +) { + CenterAlignedTopAppBar( + title = { Text(text = stringResource(id = titleRes)) }, + navigationIcon = navigationIcon, + actions = actionIcon, + colors = colors, + modifier = modifier + .padding(bottom = 8.dp) + .testTag("psTopAppBar"), + ) +} diff --git a/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/component/scrollbar/AppScrollbars.kt b/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/component/scrollbar/AppScrollbars.kt new file mode 100644 index 00000000..8736e196 --- /dev/null +++ b/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/component/scrollbar/AppScrollbars.kt @@ -0,0 +1,191 @@ +package com.github.andiim.plantscan.core.designsystem.component.scrollbar + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.SpringSpec +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsDraggedAsState +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.github.andiim.plantscan.core.designsystem.component.scrollbar.ThumbState.Active +import com.github.andiim.plantscan.core.designsystem.component.scrollbar.ThumbState.Dormant +import com.github.andiim.plantscan.core.designsystem.component.scrollbar.ThumbState.Inactive +import kotlinx.coroutines.delay + +/** + * The time period for showing the scrollbar thumb after interacting with it, before it fades away. + */ +private const val SCROLLBAR_INACTIVE_TO_DORMANT_TIME_IN_MS = 2_000L + +/** + * A [Scrollbar] that allows for fast scrolling of content by dragging its thumb. + * Its thumb disappears when the scrolling container is dormant. + * @param modifier a [Modifier] for the [Scrollbar] + * @param state the driving state for the [Scrollbar] + * @param orientation the orientation of the scrollbar + * @param onThumbMoved the fast scroll implementation + */ +@Composable +fun ScrollableState.DraggableScrollbar( + modifier: Modifier = Modifier, + state: ScrollbarState, + orientation: Orientation, + onThumbMoved: (Float) -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + Scrollbar( + modifier = modifier, + orientation = orientation, + interactionSource = interactionSource, + state = state, + thumb = { + DraggableScrollbarThumb( + interactionSource = interactionSource, + orientation = orientation, + ) + }, + onThumbMoved = onThumbMoved, + ) +} + +/** + * A simple [Scrollbar]. + * Its thumb disappears when the scrolling container is dormant. + * @param modifier a [Modifier] for the [Scrollbar] + * @param state the driving state for the [Scrollbar] + * @param orientation the orientation of the scrollbar + */ +@Composable +fun ScrollableState.DecorativeScrollbar( + modifier: Modifier = Modifier, + state: ScrollbarState, + orientation: Orientation, +) { + val interactionSource = remember { MutableInteractionSource() } + Scrollbar( + modifier = modifier, + orientation = orientation, + interactionSource = interactionSource, + state = state, + thumb = { + DecorativeScrollbarThumb( + interactionSource = interactionSource, + orientation = orientation, + ) + }, + ) +} + +/** + * A scrollbar thumb that is intended to also be a touch target for fast scrolling. + */ +@Composable +private fun ScrollableState.DraggableScrollbarThumb( + interactionSource: InteractionSource, + orientation: Orientation, +) { + Box( + modifier = Modifier + .run { + when (orientation) { + Orientation.Vertical -> width(12.dp).fillMaxHeight() + Orientation.Horizontal -> height(12.dp).fillMaxWidth() + } + } + .background( + color = scrollbarThumbColor( + interactionSource = interactionSource, + ), + shape = RoundedCornerShape(16.dp), + ), + ) +} + +/** + * A decorative scrollbar thumb used solely for communicating a user's position in a list. + */ +@Composable +private fun ScrollableState.DecorativeScrollbarThumb( + interactionSource: InteractionSource, + orientation: Orientation, +) { + Box( + modifier = Modifier + .run { + when (orientation) { + Orientation.Vertical -> width(2.dp).fillMaxHeight() + Orientation.Horizontal -> height(2.dp).fillMaxWidth() + } + } + .background( + color = scrollbarThumbColor( + interactionSource = interactionSource, + ), + shape = RoundedCornerShape(16.dp), + ), + ) +} + +/** + * The color of the scrollbar thumb as a function of its interaction state. + * @param interactionSource source of interactions in the scrolling container + */ +@Composable +private fun ScrollableState.scrollbarThumbColor( + interactionSource: InteractionSource, +): Color { + var state by remember { mutableStateOf(Dormant) } + val pressed by interactionSource.collectIsPressedAsState() + val hovered by interactionSource.collectIsHoveredAsState() + val dragged by interactionSource.collectIsDraggedAsState() + val active = (canScrollForward || canScrollForward) && + (pressed || hovered || dragged || isScrollInProgress) + + val color by animateColorAsState( + targetValue = when (state) { + Active -> MaterialTheme.colorScheme.onSurface.copy(0.5f) + Inactive -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f) + Dormant -> Color.Transparent + }, + animationSpec = SpringSpec( + stiffness = Spring.StiffnessLow, + ), + label = "Scrollbar thumb color", + ) + LaunchedEffect(active) { + when (active) { + true -> state = Active + false -> if (state == Active) { + state = Inactive + delay(SCROLLBAR_INACTIVE_TO_DORMANT_TIME_IN_MS) + state = Dormant + } + } + } + + return color +} + +private enum class ThumbState { + Active, Inactive, Dormant +} diff --git a/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/component/scrollbar/LazyScrollbarUtilities.kt b/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/component/scrollbar/LazyScrollbarUtilities.kt new file mode 100644 index 00000000..64d86277 --- /dev/null +++ b/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/component/scrollbar/LazyScrollbarUtilities.kt @@ -0,0 +1,160 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * 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. + */ + +package com.github.andiim.plantscan.core.designsystem.component.scrollbar + +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlin.math.abs +import kotlin.math.min + +/** + * Calculates the [ScrollbarState] for lazy layouts. + * @param itemsAvailable the total amount of items available to scroll in the layout. + * @param visibleItems a list of items currently visible in the layout. + * @param firstVisibleItemIndex a function for interpolating the first visible index in the lazy layout + * as scrolling progresses for smooth and linear scrollbar thumb progression. + * [itemsAvailable]. + * @param reverseLayout if the items in the backing lazy layout are laid out in reverse order. + * */ +@Composable +internal inline fun LazyState.scrollbarState( + itemsAvailable: Int, + crossinline visibleItems: LazyState.() -> List, + crossinline firstVisibleItemIndex: LazyState.(List) -> Float, + crossinline itemPercentVisible: LazyState.(LazyStateItem) -> Float, + crossinline reverseLayout: LazyState.() -> Boolean, +): ScrollbarState { + var state by remember { mutableStateOf(ScrollbarState.FULL) } + + LaunchedEffect( + key1 = this, + key2 = itemsAvailable, + ) { + snapshotFlow { + if (itemsAvailable == 0) return@snapshotFlow null + + val visibleItemsInfo = visibleItems(this@scrollbarState) + if (visibleItemsInfo.isEmpty()) return@snapshotFlow null + + val firstIndex = min( + a = firstVisibleItemIndex(visibleItemsInfo), + b = itemsAvailable.toFloat(), + ) + if (firstIndex.isNaN()) return@snapshotFlow null + + val itemsVisible = visibleItemsInfo.sumOf { + itemPercentVisible(it).toDouble() + }.toFloat() + + val thumbTravelPercent = min( + a = firstIndex / itemsAvailable, + b = 1f, + ) + val thumbSizePercent = min( + a = itemsVisible / itemsAvailable, + b = 1f, + ) + ScrollbarState( + thumbSizePercent = thumbSizePercent, + thumbMovedPercent = when { + reverseLayout() -> 1f - thumbTravelPercent + else -> thumbTravelPercent + }, + ) + } + .filterNotNull() + .distinctUntilChanged() + .collect { state = it } + } + return state +} + +/** + * Linearly interpolates the index for the first item in [visibleItems] for smooth scrollbar + * progression. + * @param visibleItems a list of items currently visible in the layout. + * @param itemSize a lookup function for the size of an item in the layout. + * @param offset a lookup function for the offset of an item relative to the start of the view port. + * @param nextItemOnMainAxis a lookup function for the next item on the main axis in the direction + * of the scroll. + * @param itemIndex a lookup function for index of an item in the layout relative to + * the total amount of items available. + * + * @return a [Float] in the range [firstItemPosition..nextItemPosition) where nextItemPosition + * is the index of the consecutive item along the major axis. + * */ +internal inline fun LazyState.interpolateFirstItemIndex( + visibleItems: List, + crossinline itemSize: LazyState.(LazyStateItem) -> Int, + crossinline offset: LazyState.(LazyStateItem) -> Int, + crossinline nextItemOnMainAxis: LazyState.(LazyStateItem) -> LazyStateItem?, + crossinline itemIndex: (LazyStateItem) -> Int, +): Float { + if (visibleItems.isEmpty()) return 0f + + val firstItem = visibleItems.first() + val firstItemIndex = itemIndex(firstItem) + + if (firstItemIndex < 0) return Float.NaN + + val firstItemSize = itemSize(firstItem) + if (firstItemSize == 0) return Float.NaN + + val itemOffset = offset(firstItem).toFloat() + val offsetPercentage = abs(itemOffset) / firstItemSize + + val nextItem = nextItemOnMainAxis(firstItem) ?: return firstItemIndex + offsetPercentage + + val nextItemIndex = itemIndex(nextItem) + + return firstItemIndex + ((nextItemIndex - firstItemIndex) * offsetPercentage) +} + +/** + * Returns the percentage of an item that is currently visible in the view port. + * @param itemSize the size of the item + * @param itemStartOffset the start offset of the item relative to the view port start + * @param viewportStartOffset the start offset of the view port + * @param viewportEndOffset the end offset of the view port + */ +internal fun itemVisibilityPercentage( + itemSize: Int, + itemStartOffset: Int, + viewportStartOffset: Int, + viewportEndOffset: Int, +): Float { + if (itemSize == 0) return 0f + val itemEnd = itemStartOffset + itemSize + val startOffset = when { + itemStartOffset > viewportStartOffset -> 0 + else -> abs(abs(viewportStartOffset) - abs(itemStartOffset)) + } + val endOffset = when { + itemEnd < viewportEndOffset -> 0 + else -> abs(abs(itemEnd) - abs(viewportEndOffset)) + } + val size = itemSize.toFloat() + return (size - startOffset - endOffset) / size +} diff --git a/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/component/scrollbar/Scrollbar.kt b/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/component/scrollbar/Scrollbar.kt new file mode 100644 index 00000000..405d7402 --- /dev/null +++ b/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/component/scrollbar/Scrollbar.kt @@ -0,0 +1,402 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * 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. + */ + +package com.github.andiim.plantscan.core.designsystem.component.scrollbar + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.DragInteraction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.max +import androidx.compose.ui.util.packFloats +import androidx.compose.ui.util.unpackFloat1 +import androidx.compose.ui.util.unpackFloat2 +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout +import kotlin.math.max +import kotlin.math.min + +/** + * The delay between scrolls when a user long presses on the scrollbar track to initiate a scroll + * instead of dragging the scrollbar thumb. + */ +private const val SCROLLBAR_PRESS_DELAY_MS = 10L + +/** + * The percentage displacement of the scrollbar when scrolled by long presses on the scrollbar + * track. + */ +private const val SCROLLBAR_PRESS_DELTA_PCT = 0.02f + +/** + * Class definition for the core properties of a scroll bar + */ +@Immutable +@JvmInline +value class ScrollbarState internal constructor( + internal val packedValue: Long, +) { + companion object { + val FULL = ScrollbarState( + thumbSizePercent = 1f, + thumbMovedPercent = 0f, + ) + } +} + +/** + * Class definition for the core properties of a scroll bar track + */ +@Immutable +@JvmInline +private value class ScrollbarTrack( + val packedValue: Long, +) { + constructor( + max: Float, + min: Float, + ) : this(packFloats(max, min)) +} + +/** + * Creates a [ScrollbarState] with the listed properties + * @param thumbSizePercent the thumb size of the scrollbar as a percentage of the total track size. + * Refers to either the thumb width (for horizontal scrollbars) + * or height (for vertical scrollbars). + * @param thumbMovedPercent the distance the thumb has traveled as a percentage of total + * track size. + */ +fun ScrollbarState( + thumbSizePercent: Float, + thumbMovedPercent: Float, +) = ScrollbarState( + packFloats( + val1 = thumbSizePercent, + val2 = thumbMovedPercent, + ), +) + +/** + * Returns the thumb size of the scrollbar as a percentage of the total track size + */ +val ScrollbarState.thumbSizePercent + get() = unpackFloat1(packedValue) + +/** + * Returns the distance the thumb has traveled as a percentage of total track size + */ +val ScrollbarState.thumbMovedPercent + get() = unpackFloat2(packedValue) + +/** + * Returns the size of the scrollbar track in pixels + */ +private val ScrollbarTrack.size + get() = unpackFloat2(packedValue) - unpackFloat1(packedValue) + +/** + * Returns the position of the scrollbar thumb on the track as a percentage + */ +private fun ScrollbarTrack.thumbPosition( + dimension: Float, +): Float = max( + a = min( + a = dimension / size, + b = 1f, + ), + b = 0f, +) + +/** + * Returns the value of [offset] along the axis specified by [this] + */ +internal fun Orientation.valueOf(offset: Offset) = when (this) { + Orientation.Horizontal -> offset.x + Orientation.Vertical -> offset.y +} + +/** + * Returns the value of [intSize] along the axis specified by [this] + */ +internal fun Orientation.valueOf(intSize: IntSize) = when (this) { + Orientation.Horizontal -> intSize.width + Orientation.Vertical -> intSize.height +} + +/** + * Returns the value of [intOffset] along the axis specified by [this] + */ +internal fun Orientation.valueOf(intOffset: IntOffset) = when (this) { + Orientation.Horizontal -> intOffset.x + Orientation.Vertical -> intOffset.y +} + +/** + * A Composable for drawing a scrollbar + * @param orientation the scroll direction of the scrollbar + * @param state the state describing the position of the scrollbar + * @param minThumbSize the minimum size of the scrollbar thumb + * @param interactionSource allows for observing the state of the scroll bar + * @param thumb a composable for drawing the scrollbar thumb + * @param onThumbMoved an function for reacting to scroll bar displacements caused by direct + * interactions on the scrollbar thumb by the user, for example implementing a fast scroll + */ +@Composable +fun Scrollbar( + modifier: Modifier = Modifier, + orientation: Orientation, + state: ScrollbarState, + minThumbSize: Dp = 40.dp, + interactionSource: MutableInteractionSource? = null, + thumb: @Composable () -> Unit, + onThumbMoved: ((Float) -> Unit)? = null, +) { + val localDensity = LocalDensity.current + + // Using Offset.Unspecified and Float.NaN instead of null + // to prevent unnecessary boxing of primitives + var pressedOffset by remember { mutableStateOf(Offset.Unspecified) } + var draggedOffset by remember { mutableStateOf(Offset.Unspecified) } + + // Used to immediately show drag feedback in the UI while the scrolling implementation + // catches up + var interactionThumbTravelPercent by remember { mutableStateOf(Float.NaN) } + + var track by remember { mutableStateOf(ScrollbarTrack(packedValue = 0)) } + + val thumbTravelPercent = when { + interactionThumbTravelPercent.isNaN() -> state.thumbMovedPercent + else -> interactionThumbTravelPercent + } + val thumbSizePx = max( + a = state.thumbSizePercent * track.size, + b = with(localDensity) { minThumbSize.toPx() }, + ) + val thumbSizeDp by animateDpAsState( + targetValue = with(localDensity) { thumbSizePx.toDp() }, + label = "scrollbar thumb size", + ) + val thumbMovedPx = min( + a = track.size * thumbTravelPercent, + b = track.size - thumbSizePx, + ) + + // scrollbar track container + Box( + modifier = modifier + .run { + val withHover = interactionSource?.let(::hoverable) ?: this + when (orientation) { + Orientation.Vertical -> withHover.fillMaxHeight() + Orientation.Horizontal -> withHover.fillMaxWidth() + } + } + .onGloballyPositioned { coordinates -> + val scrollbarStartCoordinate = orientation.valueOf(coordinates.positionInRoot()) + track = ScrollbarTrack( + max = scrollbarStartCoordinate, + min = scrollbarStartCoordinate + orientation.valueOf(coordinates.size), + ) + } + // Process scrollbar presses + .pointerInput(Unit) { + detectTapGestures( + onPress = { offset -> + try { + // Wait for a long press before scrolling + withTimeout(viewConfiguration.longPressTimeoutMillis) { + tryAwaitRelease() + } + } catch (e: TimeoutCancellationException) { + // Start the press triggered scroll + val initialPress = PressInteraction.Press(offset) + interactionSource?.tryEmit(initialPress) + + pressedOffset = offset + interactionSource?.tryEmit( + when { + tryAwaitRelease() -> PressInteraction.Release(initialPress) + else -> PressInteraction.Cancel(initialPress) + }, + ) + + // End the press + pressedOffset = Offset.Unspecified + } + }, + ) + } + // Process scrollbar drags + .pointerInput(Unit) { + var dragInteraction: DragInteraction.Start? = null + val onDragStart: (Offset) -> Unit = { offset -> + val start = DragInteraction.Start() + dragInteraction = start + interactionSource?.tryEmit(start) + draggedOffset = offset + } + val onDragEnd: () -> Unit = { + dragInteraction?.let { interactionSource?.tryEmit(DragInteraction.Stop(it)) } + draggedOffset = Offset.Unspecified + } + val onDragCancel: () -> Unit = { + dragInteraction?.let { interactionSource?.tryEmit(DragInteraction.Cancel(it)) } + draggedOffset = Offset.Unspecified + } + val onDrag: (change: PointerInputChange, dragAmount: Float) -> Unit = + onDrag@{ _, delta -> + if (draggedOffset == Offset.Unspecified) return@onDrag + draggedOffset = when (orientation) { + Orientation.Vertical -> draggedOffset.copy( + y = draggedOffset.y + delta, + ) + + Orientation.Horizontal -> draggedOffset.copy( + x = draggedOffset.x + delta, + ) + } + } + + when (orientation) { + Orientation.Horizontal -> detectHorizontalDragGestures( + onDragStart = onDragStart, + onDragEnd = onDragEnd, + onDragCancel = onDragCancel, + onHorizontalDrag = onDrag, + ) + + Orientation.Vertical -> detectVerticalDragGestures( + onDragStart = onDragStart, + onDragEnd = onDragEnd, + onDragCancel = onDragCancel, + onVerticalDrag = onDrag, + ) + } + }, + ) { + val scrollbarThumbMovedDp = max( + a = with(localDensity) { thumbMovedPx.toDp() }, + b = 0.dp, + ) + // scrollbar thumb container + Box( + modifier = Modifier + .align(Alignment.TopStart) + .run { + when (orientation) { + Orientation.Horizontal -> width(thumbSizeDp) + Orientation.Vertical -> height(thumbSizeDp) + } + } + .offset( + y = when (orientation) { + Orientation.Horizontal -> 0.dp + Orientation.Vertical -> scrollbarThumbMovedDp + }, + x = when (orientation) { + Orientation.Horizontal -> scrollbarThumbMovedDp + Orientation.Vertical -> 0.dp + }, + ), + ) { + thumb() + } + } + + if (onThumbMoved == null) return + + // State that will be read inside the effects that follow + // but will not cause re-triggering of them + val updatedState by rememberUpdatedState(state) + + // Process presses + LaunchedEffect(pressedOffset) { + // Press ended, reset interactionThumbTravelPercent + if (pressedOffset == Offset.Unspecified) { + interactionThumbTravelPercent = Float.NaN + return@LaunchedEffect + } + + var currentThumbMovedPercent = updatedState.thumbMovedPercent + val destinationThumbMovedPercent = track.thumbPosition( + dimension = orientation.valueOf(pressedOffset), + ) + val isPositive = currentThumbMovedPercent < destinationThumbMovedPercent + val delta = SCROLLBAR_PRESS_DELTA_PCT * if (isPositive) 1f else -1f + + while (currentThumbMovedPercent != destinationThumbMovedPercent) { + currentThumbMovedPercent = when { + isPositive -> min( + a = currentThumbMovedPercent + delta, + b = destinationThumbMovedPercent, + ) + + else -> max( + a = currentThumbMovedPercent + delta, + b = destinationThumbMovedPercent, + ) + } + onThumbMoved(currentThumbMovedPercent) + interactionThumbTravelPercent = currentThumbMovedPercent + delay(SCROLLBAR_PRESS_DELAY_MS) + } + } + + // Process drags + LaunchedEffect(draggedOffset) { + if (draggedOffset == Offset.Unspecified) { + interactionThumbTravelPercent = Float.NaN + return@LaunchedEffect + } + val currentTravel = track.thumbPosition( + dimension = orientation.valueOf(draggedOffset), + ) + onThumbMoved(currentTravel) + interactionThumbTravelPercent = currentTravel + } +} diff --git a/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/component/scrollbar/ScrollbarExt.kt b/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/component/scrollbar/ScrollbarExt.kt new file mode 100644 index 00000000..048c9690 --- /dev/null +++ b/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/component/scrollbar/ScrollbarExt.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * 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. + */ + +package com.github.andiim.plantscan.core.designsystem.component.scrollbar + +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridItemInfo +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.runtime.Composable + +/** + * Calculates a [ScrollbarState] driven by the changes in a [LazyListState]. + * + * @param itemsAvailable the total amount of items available to scroll in the lazy list. + * @param itemIndex a lookup function for index of an item in the list relative to [itemsAvailable]. + */ +@Composable +fun LazyListState.scrollbarState( + itemsAvailable: Int, + itemIndex: (LazyListItemInfo) -> Int = LazyListItemInfo::index, +): ScrollbarState = + scrollbarState( + itemsAvailable = itemsAvailable, + visibleItems = { layoutInfo.visibleItemsInfo }, + firstVisibleItemIndex = { visibleItems -> + interpolateFirstItemIndex( + visibleItems = visibleItems, + itemSize = { it.size }, + offset = { it.offset }, + nextItemOnMainAxis = { first -> visibleItems.find { it != first } }, + itemIndex = itemIndex, + ) + }, + itemPercentVisible = itemPercentVisible@{ itemInfo -> + itemVisibilityPercentage( + itemSize = itemInfo.size, + itemStartOffset = itemInfo.offset, + viewportStartOffset = layoutInfo.viewportStartOffset, + viewportEndOffset = layoutInfo.viewportEndOffset, + ) + }, + reverseLayout = { layoutInfo.reverseLayout }, + ) + +/** + * Calculates a [ScrollbarState] driven by the changes in a [LazyGridState]. + * + * @param itemsAvailable the total amount of items available to scroll in the grid. + * @param itemIndex a lookup function for index of an item in the grid relative to [itemsAvailable]. + */ +@Composable +fun LazyGridState.scrollbarState( + itemsAvailable: Int, + itemIndex: (LazyGridItemInfo) -> Int = LazyGridItemInfo::index, +): ScrollbarState = + scrollbarState( + itemsAvailable = itemsAvailable, + visibleItems = { layoutInfo.visibleItemsInfo }, + firstVisibleItemIndex = { visibleItems -> + interpolateFirstItemIndex( + visibleItems = visibleItems, + itemSize = { + layoutInfo.orientation.valueOf(it.size) + }, + offset = { layoutInfo.orientation.valueOf(it.offset) }, + nextItemOnMainAxis = { first -> + when (layoutInfo.orientation) { + Orientation.Vertical -> visibleItems.find { + it != first && it.row != first.row + } + + Orientation.Horizontal -> visibleItems.find { + it != first && it.column != first.column + } + } + }, + itemIndex = itemIndex, + ) + }, + itemPercentVisible = itemPercentVisible@{ itemInfo -> + itemVisibilityPercentage( + itemSize = layoutInfo.orientation.valueOf(itemInfo.size), + itemStartOffset = layoutInfo.orientation.valueOf(itemInfo.offset), + viewportStartOffset = layoutInfo.viewportStartOffset, + viewportEndOffset = layoutInfo.viewportEndOffset, + ) + }, + reverseLayout = { layoutInfo.reverseLayout }, + ) diff --git a/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/component/scrollbar/ThumbExt.kt b/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/component/scrollbar/ThumbExt.kt new file mode 100644 index 00000000..5c201a26 --- /dev/null +++ b/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/component/scrollbar/ThumbExt.kt @@ -0,0 +1,61 @@ +package com.github.andiim.plantscan.core.designsystem.component.scrollbar + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue + +/** + * Remembers a function to react to [Scrollbar] thumb position displacements for a [LazyListState]. + * @param itemsAvailable the amount of items in the list. + */ +@Composable +fun LazyListState.rememberDraggableScroller( + itemsAvailable: Int, +): (Float) -> Unit = rememberDraggableScroller( + itemsAvailable = itemsAvailable, + scroll = ::scrollToItem, +) + +/** + * Remembers a function to react to [Scrollbar] thumb position displacements for a [LazyGridState]. + * @param itemsAvailable the amount of items in the grid. + */ +@Composable +fun LazyGridState.rememberDraggableScroller( + itemsAvailable: Int, +): (Float) -> Unit = rememberDraggableScroller( + itemsAvailable = itemsAvailable, + scroll = ::scrollToItem, +) + +/** + * Generic function to react to [Scrollbar] thumb displacements in a lazy layout. + * @param itemsAvailable the total amount of items available to scroll in the layout. + * @param scroll a function to be invoked when an index has been identified to scroll to. + */ +@Composable +private inline fun rememberDraggableScroller( + itemsAvailable: Int, + crossinline scroll: suspend (index: Int) -> Unit, +): (Float) -> Unit { + var percentage by remember { mutableStateOf(Float.NaN) } + val itemCount by rememberUpdatedState(itemsAvailable) + + LaunchedEffect(percentage) { + if (percentage.isNaN()) return@LaunchedEffect + val indexToFind = (itemCount * percentage).toInt() + scroll(indexToFind) + } + return remember { + { + newPercentage -> + percentage = newPercentage + } + } +} diff --git a/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/extensions/ModifierExtensions.kt b/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/extensions/ModifierExtensions.kt new file mode 100644 index 00000000..3496b0d3 --- /dev/null +++ b/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/extensions/ModifierExtensions.kt @@ -0,0 +1,16 @@ +package com.github.andiim.plantscan.core.designsystem.extensions + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp + +fun Modifier.fieldModifier(): Modifier { + return this.fillMaxWidth().padding(16.dp, 4.dp) +} + +fun Modifier.withSemantics(name: String): Modifier { + return this.semantics(true) { contentDescription = name } +} diff --git a/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/theme/BackgroundTheme.kt b/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/theme/BackgroundTheme.kt new file mode 100644 index 00000000..f002fbd8 --- /dev/null +++ b/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/theme/BackgroundTheme.kt @@ -0,0 +1,21 @@ +package com.github.andiim.plantscan.core.designsystem.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp + +/** + * A class to model background color and tonal elevation values for PlantScan app. + */ +@Immutable +data class BackgroundTheme( + val color: Color = Color.Unspecified, + val tonalElevation: Dp = Dp.Unspecified +) + +/** + * A composition local for [BackgroundTheme]. + */ +val LocalBackgroundTheme: ProvidableCompositionLocal = staticCompositionLocalOf { BackgroundTheme() } diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/theme/Color.kt b/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/theme/Color.kt similarity index 98% rename from app/src/main/java/com/github/andiim/plantscan/app/ui/theme/Color.kt rename to core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/theme/Color.kt index cc7e76e5..18507152 100644 --- a/app/src/main/java/com/github/andiim/plantscan/app/ui/theme/Color.kt +++ b/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/theme/Color.kt @@ -1,4 +1,4 @@ -package com.github.andiim.plantscan.app.ui.theme +package com.github.andiim.plantscan.core.designsystem.theme import androidx.compose.ui.graphics.Color @@ -64,5 +64,4 @@ val md_theme_dark_surfaceTint = Color(0xFFFFADE4) val md_theme_dark_outlineVariant = Color(0xFF4F444A) val md_theme_dark_scrim = Color(0xFF000000) - val seed = Color(0xFFDF06BE) diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/theme/Theme.kt b/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/theme/Theme.kt similarity index 62% rename from app/src/main/java/com/github/andiim/plantscan/app/ui/theme/Theme.kt rename to core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/theme/Theme.kt index a8abc16e..8c4f60e7 100644 --- a/app/src/main/java/com/github/andiim/plantscan/app/ui/theme/Theme.kt +++ b/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/theme/Theme.kt @@ -1,7 +1,8 @@ -package com.github.andiim.plantscan.app.ui.theme +package com.github.andiim.plantscan.core.designsystem.theme import android.app.Activity import android.os.Build +import androidx.annotation.ChecksSdkIntAtLeast import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme @@ -9,13 +10,20 @@ import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.SideEffect import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.dp import androidx.core.view.WindowCompat +import org.jetbrains.annotations.VisibleForTesting -private val lightColorScheme = lightColorScheme( +/** + * Light default theme color scheme. + */ +@VisibleForTesting +val lightColorScheme = lightColorScheme( primary = md_theme_light_primary, onPrimary = md_theme_light_onPrimary, primaryContainer = md_theme_light_primaryContainer, @@ -47,8 +55,11 @@ private val lightColorScheme = lightColorScheme( scrim = md_theme_light_scrim, ) - -private val darkColorScheme = darkColorScheme( +/** + * Dark default theme color scheme. + */ +@VisibleForTesting +val darkColorScheme = darkColorScheme( primary = md_theme_dark_primary, onPrimary = md_theme_dark_onPrimary, primaryContainer = md_theme_dark_primaryContainer, @@ -80,29 +91,54 @@ private val darkColorScheme = darkColorScheme( scrim = md_theme_dark_scrim, ) +/** + * PlantScan theme. + * + * @param darkTheme Whether the theme should use a dark color scheme (follows system by default). + * @param disableDynamicTheming If `true`, disables the use of dynamic theming, even when it is + * supported. + */ @Composable -fun PlantScanTheme( +fun PsTheme( darkTheme: Boolean = isSystemInDarkTheme(), - dynamicColor: Boolean = true, - content: @Composable () -> Unit + disableDynamicTheming: Boolean = true, + content: @Composable () -> Unit, ) { - val colorScheme = - when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + // Color scheme + val colorScheme = when { + !disableDynamicTheming && supportsDynamicTheming() -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } - darkTheme -> darkColorScheme - else -> lightColorScheme - } - val view = LocalView.current - if (!view.isInEditMode) { - SideEffect { - val window = (view.context as Activity).window - window.statusBarColor = colorScheme.primary.toArgb() - WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme + + else -> if (darkTheme) darkColorScheme else lightColorScheme } - } + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme + } + } + + val backgroundTheme = BackgroundTheme( + color = colorScheme.surface, + tonalElevation = 2.dp, + ) - MaterialTheme(colorScheme = colorScheme, typography = Typography, content = content) + val tintTheme = when { + !disableDynamicTheming && supportsDynamicTheming() -> TintTheme(colorScheme.primary) + else -> TintTheme() + } + + CompositionLocalProvider( + LocalBackgroundTheme provides backgroundTheme, + LocalTintTheme provides tintTheme, + ) { + MaterialTheme(colorScheme = colorScheme, typography = Typography, content = content) + } } + +@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S) +fun supportsDynamicTheming() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S diff --git a/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/theme/TintTheme.kt b/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/theme/TintTheme.kt new file mode 100644 index 00000000..e5235c95 --- /dev/null +++ b/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/theme/TintTheme.kt @@ -0,0 +1,19 @@ +package com.github.andiim.plantscan.core.designsystem.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +/** + * A class to model background color of Icon for PlantScan. + */ +@Immutable +data class TintTheme( + val iconTint: Color? = null, +) + +/** + * A composition local for [TintTheme]. + */ +val LocalTintTheme: ProvidableCompositionLocal = staticCompositionLocalOf { TintTheme() } diff --git a/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/theme/Type.kt b/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/theme/Type.kt new file mode 100644 index 00000000..2e8d3334 --- /dev/null +++ b/core/designsystem/src/main/java/com/github/andiim/plantscan/core/designsystem/theme/Type.kt @@ -0,0 +1,118 @@ +package com.github.andiim.plantscan.core.designsystem.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import com.github.andiim.plantscan.core.designsystem.R + +val Inter = FontFamily( + Font(R.font.inter), + Font(R.font.inter_light), + Font(R.font.inter_medium), + Font(R.font.inter_bold), +) + +val Poppins = FontFamily( + Font(R.font.poppins), + Font(R.font.poppins_medium), +) + +/** + * PlantScan typography. + */ +val Typography = + Typography( + displayLarge = TextStyle( + fontFamily = Inter, + fontWeight = FontWeight.Light, + fontSize = 94.sp, + letterSpacing = (-1.5).sp, + ), + displayMedium = TextStyle( + fontFamily = Inter, + fontWeight = FontWeight.Light, + fontSize = 59.sp, + letterSpacing = (-0.5).sp, + ), + displaySmall = TextStyle( + fontFamily = Inter, + fontWeight = FontWeight.Normal, + fontSize = 47.sp, + letterSpacing = 0.sp, + ), + headlineLarge = TextStyle( + fontFamily = Inter, + fontWeight = FontWeight.Bold, + fontSize = 36.sp, + letterSpacing = 0.sp, + ), + headlineMedium = TextStyle( + fontFamily = Inter, + fontWeight = FontWeight.Normal, + fontSize = 33.sp, + letterSpacing = (0.25).sp, + ), + headlineSmall = TextStyle( + fontFamily = Inter, + fontWeight = FontWeight.Normal, + fontSize = 24.sp, + letterSpacing = 0.sp, + ), + titleLarge = TextStyle( + fontFamily = Inter, + fontWeight = FontWeight.Medium, + fontSize = 20.sp, + letterSpacing = (0.15).sp, + ), + titleMedium = TextStyle( + fontFamily = Inter, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + letterSpacing = (0.15).sp, + ), + titleSmall = TextStyle( + fontFamily = Inter, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + letterSpacing = (0.1).sp, + ), + bodyLarge = TextStyle( + fontFamily = Poppins, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + letterSpacing = 0.5.sp, + ), + bodyMedium = TextStyle( + fontFamily = Poppins, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + letterSpacing = 0.25.sp, + ), + bodySmall = TextStyle( + fontFamily = Poppins, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + letterSpacing = 0.4.sp, + ), + labelLarge = TextStyle( + fontFamily = Poppins, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + letterSpacing = 1.25.sp, + ), + labelMedium = TextStyle( + fontFamily = Poppins, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + letterSpacing = 1.5.sp, + ), + labelSmall = TextStyle( + fontFamily = Poppins, + fontWeight = FontWeight.Normal, + fontSize = 10.sp, + letterSpacing = 1.5.sp, + ), + ) diff --git a/core/designsystem/src/main/res/drawable/orchid.webp b/core/designsystem/src/main/res/drawable/orchid.webp new file mode 100644 index 00000000..6a9825c2 Binary files /dev/null and b/core/designsystem/src/main/res/drawable/orchid.webp differ diff --git a/core/designsystem/src/main/res/font/inter.ttf b/core/designsystem/src/main/res/font/inter.ttf new file mode 100644 index 00000000..2348f6b9 Binary files /dev/null and b/core/designsystem/src/main/res/font/inter.ttf differ diff --git a/core/designsystem/src/main/res/font/inter_bold.ttf b/core/designsystem/src/main/res/font/inter_bold.ttf new file mode 100644 index 00000000..cb1b884e Binary files /dev/null and b/core/designsystem/src/main/res/font/inter_bold.ttf differ diff --git a/core/designsystem/src/main/res/font/inter_light.ttf b/core/designsystem/src/main/res/font/inter_light.ttf new file mode 100644 index 00000000..a060a98a Binary files /dev/null and b/core/designsystem/src/main/res/font/inter_light.ttf differ diff --git a/core/designsystem/src/main/res/font/inter_medium.ttf b/core/designsystem/src/main/res/font/inter_medium.ttf new file mode 100644 index 00000000..048f0f70 Binary files /dev/null and b/core/designsystem/src/main/res/font/inter_medium.ttf differ diff --git a/core/designsystem/src/main/res/font/poppins.ttf b/core/designsystem/src/main/res/font/poppins.ttf new file mode 100644 index 00000000..246a861a Binary files /dev/null and b/core/designsystem/src/main/res/font/poppins.ttf differ diff --git a/core/designsystem/src/main/res/font/poppins_medium.ttf b/core/designsystem/src/main/res/font/poppins_medium.ttf new file mode 100644 index 00000000..5b46f198 Binary files /dev/null and b/core/designsystem/src/main/res/font/poppins_medium.ttf differ diff --git a/core/designsystem/src/main/res/values/font_certs.xml b/core/designsystem/src/main/res/values/font_certs.xml new file mode 100644 index 00000000..d2226ac0 --- /dev/null +++ b/core/designsystem/src/main/res/values/font_certs.xml @@ -0,0 +1,17 @@ + + + + @array/com_google_android_gms_fonts_certs_dev + @array/com_google_android_gms_fonts_certs_prod + + + + MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTAeFw0wODA0MTUyMzM2NTZaFw0zNTA5MDEyMzM2NTZaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBANbOLggKv+IxTdGNs8/TGFy0PTP6DHThvbbR24kT9ixcOd9W+EaBPWW+wPPKQmsHxajtWjmQwWfna8mZuSeJS48LIgAZlKkpFeVyxW0qMBujb8X8ETrWy550NaFtI6t9+u7hZeTfHwqNvacKhp1RbE6dBRGWynwMVX8XW8N1+UjFaq6GCJukT4qmpN2afb8sCjUigq0GuMwYXrFVee74bQgLHWGJwPmvmLHC69EH6kWr22ijx4OKXlSIx2xT1AsSHee70w5iDBiK4aph27yH3TxkXy9V89TDdexAcKk/cVHYNnDBapcavl7y0RiQ4biu8ymM8Ga/nmzhRKya6G0cGw8CAQOjgfwwgfkwHQYDVR0OBBYEFI0cxb6VTEM8YYY6FbBMvAPyT+CyMIHJBgNVHSMEgcEwgb6AFI0cxb6VTEM8YYY6FbBMvAPyT+CyoYGapIGXMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbYIJANWFuGx90071MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABnTDPEF+3iSP0wNfdIjIz1AlnrPzgAIHVvXxunW7SBrDhEglQZBbKJEk5kT0mtKoOD1JMrSu1xuTKEBahWRbqHsXclaXjoBADb0kkjVEJu/Lh5hgYZnOjvlba8Ld7HCKePCVePoTJBdI4fvugnL8TsgK05aIskyY0hKI9L8KfqfGTl1lzOv2KoWD0KWwtAWPoGChZxmQ+nBli+gwYMzM1vAkP+aayLe0a1EQimlOalO762r0GXO0ks+UeXde2Z4e+8S/pf7pITEI/tP+MxJTALw9QUWEv9lKTk+jkbqxbsh8nfBUapfKqYn0eidpwq2AzVp3juYl7//fKnaPhJD9gs= + + + + + MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBAKtWLgDYO6IIrgqWbxJOKdoR8qtW0I9Y4sypEwPpt1TTcvZApxsdyxMJZ2JORland2qSGT2y5b+3JKkedxiLDmpHpDsz2WCbdxgxRczfey5YZnTJ4VZbH0xqWVW/8lGmPav5xVwnIiJS6HXk+BVKZF+JcWjAsb/GEuq/eFdpuzSqeYTcfi6idkyugwfYwXFU1+5fZKUaRKYCwkkFQVfcAs1fXA5V+++FGfvjJ/CxURaSxaBvGdGDhfXE28LWuT9ozCl5xw4Yq5OGazvV24mZVSoOO0yZ31j7kYvtwYK6NeADwbSxDdJEqO4k//0zOHKrUiGYXtqw/A0LFFtqoZKFjnkCAQOjgdkwgdYwHQYDVR0OBBYEFMd9jMIhF1Ylmn/Tgt9r45jk14alMIGmBgNVHSMEgZ4wgZuAFMd9jMIhF1Ylmn/Tgt9r45jk14aloXikdjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEUMBIGA1UEChMLR29vZ2xlIEluYy4xEDAOBgNVBAsTB0FuZHJvaWQxEDAOBgNVBAMTB0FuZHJvaWSCCQDC4IdGZEowjTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBAUAA4IBAQBt0lLO74UwLDYKqs6Tm8/yzKkEu116FmH4rkaymUIE0P9KaMftGlMexFlaYjzmB2OxZyl6euNXEsQH8gjwyxCUKRJNexBiGcCEyj6z+a1fuHHvkiaai+KL8W1EyNmgjmyy8AW7P+LLlkR+ho5zEHatRbM/YAnqGcFh5iZBqpknHf1SKMXFh4dd239FJ1jWYfbMDMy3NS5CTMQ2XFI1MvcyUTdZPErjQfTbQe3aDQsQcafEQPD+nqActifKZ0Np0IS9L9kR/wbNvyz6ENwPiTrjV2KRkEjH78ZMcUQXg0L3BYHJ3lc69Vs5Ddf9uUGGMYldX3WfMBEmh/9iFBDAaTCK + + + diff --git a/core/designsystem/src/main/res/values/preloaded_fonts.xml b/core/designsystem/src/main/res/values/preloaded_fonts.xml new file mode 100644 index 00000000..c9448d96 --- /dev/null +++ b/core/designsystem/src/main/res/values/preloaded_fonts.xml @@ -0,0 +1,11 @@ + + + + @font/inter + @font/inter_bold + @font/inter_light + @font/inter_medium + @font/poppins + @font/poppins_medium + + diff --git a/core/domain/.gitignore b/core/domain/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/domain/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts new file mode 100644 index 00000000..a457e1db --- /dev/null +++ b/core/domain/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.android.library.jacoco) + alias(libs.plugins.ksp) +} + +android { + namespace = "com.github.andiim.plantscan.core.domain" +} + +dependencies { + implementation(project(":core:auth")) + implementation(project(":core:data")) + implementation(project(":core:model")) + implementation(libs.hilt.android) + implementation(libs.kotlin.coroutines.android) + implementation(libs.kotlinx.datetime) + + ksp(libs.hilt.compiler) + + testImplementation(project(":core:testing")) +} diff --git a/core/domain/src/main/AndroidManifest.xml b/core/domain/src/main/AndroidManifest.xml new file mode 100644 index 00000000..568741e5 --- /dev/null +++ b/core/domain/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/core/domain/src/main/java/com/github/andiim/plantscan/core/domain/GetDetectionDetailsUseCase.kt b/core/domain/src/main/java/com/github/andiim/plantscan/core/domain/GetDetectionDetailsUseCase.kt new file mode 100644 index 00000000..e0713117 --- /dev/null +++ b/core/domain/src/main/java/com/github/andiim/plantscan/core/domain/GetDetectionDetailsUseCase.kt @@ -0,0 +1,13 @@ +package com.github.andiim.plantscan.core.domain + +import com.github.andiim.plantscan.core.data.repository.DetectHistoryRepo +import com.github.andiim.plantscan.core.model.data.DetectionHistory +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class GetDetectionDetailsUseCase @Inject constructor( + private val repository: DetectHistoryRepo, +) { + operator fun invoke(historyId: String): Flow = + repository.getDetectionDetail(historyId) +} diff --git a/core/domain/src/main/java/com/github/andiim/plantscan/core/domain/GetHistoryUseCase.kt b/core/domain/src/main/java/com/github/andiim/plantscan/core/domain/GetHistoryUseCase.kt new file mode 100644 index 00000000..f10128cb --- /dev/null +++ b/core/domain/src/main/java/com/github/andiim/plantscan/core/domain/GetHistoryUseCase.kt @@ -0,0 +1,16 @@ +package com.github.andiim.plantscan.core.domain + +import com.github.andiim.plantscan.core.data.repository.DetectHistoryRepo +import com.github.andiim.plantscan.core.model.data.DetectionHistory +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +/** + * A use case which returns the history. + */ +class GetHistoryUseCase @Inject constructor( + private val historyRepository: DetectHistoryRepo +) { + operator fun invoke(id: String): Flow> = + historyRepository.getDetectionHistories(id) +} diff --git a/core/domain/src/main/java/com/github/andiim/plantscan/core/domain/GetRecentSearchQueriesUseCase.kt b/core/domain/src/main/java/com/github/andiim/plantscan/core/domain/GetRecentSearchQueriesUseCase.kt new file mode 100644 index 00000000..8425b5dd --- /dev/null +++ b/core/domain/src/main/java/com/github/andiim/plantscan/core/domain/GetRecentSearchQueriesUseCase.kt @@ -0,0 +1,16 @@ +package com.github.andiim.plantscan.core.domain + +import com.github.andiim.plantscan.core.data.model.RecentSearchQuery +import com.github.andiim.plantscan.core.data.repository.RecentSearchRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +/** + * A use case which returns the recent search queries. + */ +class GetRecentSearchQueriesUseCase @Inject constructor( + private val recentSearchRepository: RecentSearchRepository, +) { + operator fun invoke(limit: Int = 10): Flow> = + recentSearchRepository.getRecentSearchQueries(limit) +} diff --git a/core/domain/src/main/java/com/github/andiim/plantscan/core/domain/GetSearchContentsCountUseCase.kt b/core/domain/src/main/java/com/github/andiim/plantscan/core/domain/GetSearchContentsCountUseCase.kt new file mode 100644 index 00000000..52a5ec1e --- /dev/null +++ b/core/domain/src/main/java/com/github/andiim/plantscan/core/domain/GetSearchContentsCountUseCase.kt @@ -0,0 +1,15 @@ +package com.github.andiim.plantscan.core.domain + +import com.github.andiim.plantscan.core.data.repository.SearchContentsRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +/** + * A use case which returns total count of *Fts tables. + */ +class GetSearchContentsCountUseCase @Inject constructor( + private val searchContentRepository: SearchContentsRepository, +) { + operator fun invoke(): Flow = + searchContentRepository.getSearchContentsCount() +} diff --git a/core/domain/src/main/java/com/github/andiim/plantscan/core/domain/GetSearchContentsUseCase.kt b/core/domain/src/main/java/com/github/andiim/plantscan/core/domain/GetSearchContentsUseCase.kt new file mode 100644 index 00000000..667d4810 --- /dev/null +++ b/core/domain/src/main/java/com/github/andiim/plantscan/core/domain/GetSearchContentsUseCase.kt @@ -0,0 +1,14 @@ +package com.github.andiim.plantscan.core.domain + +import com.github.andiim.plantscan.core.data.repository.SearchContentsRepository +import com.github.andiim.plantscan.core.model.data.SearchResult +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class GetSearchContentsUseCase @Inject constructor( + private val searchContentsRepository: SearchContentsRepository +) { + operator fun invoke( + searchQuery: String, + ): Flow = searchContentsRepository.searchContents(searchQuery) +} diff --git a/core/domain/src/main/java/com/github/andiim/plantscan/core/domain/GetUserIdUsecase.kt b/core/domain/src/main/java/com/github/andiim/plantscan/core/domain/GetUserIdUsecase.kt new file mode 100644 index 00000000..bbc47d67 --- /dev/null +++ b/core/domain/src/main/java/com/github/andiim/plantscan/core/domain/GetUserIdUsecase.kt @@ -0,0 +1,10 @@ +package com.github.andiim.plantscan.core.domain + +import com.github.andiim.plantscan.core.auth.AuthHelper +import javax.inject.Inject + +class GetUserIdUsecase @Inject constructor( + private val authHelper: AuthHelper, +) { + operator fun invoke(): String = authHelper.currentUserId +} diff --git a/core/domain/src/main/java/com/github/andiim/plantscan/core/domain/GetUserLoginInfoUseCase.kt b/core/domain/src/main/java/com/github/andiim/plantscan/core/domain/GetUserLoginInfoUseCase.kt new file mode 100644 index 00000000..70a84169 --- /dev/null +++ b/core/domain/src/main/java/com/github/andiim/plantscan/core/domain/GetUserLoginInfoUseCase.kt @@ -0,0 +1,25 @@ +package com.github.andiim.plantscan.core.domain + +import com.github.andiim.plantscan.core.auth.AuthHelper +import com.github.andiim.plantscan.core.model.data.LoginInfo +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class GetUserLoginInfoUseCase @Inject constructor( + private val authHelper: AuthHelper, +) { + operator fun invoke(): Flow { + return authHelper.currentUser.map { auth -> + if (!authHelper.hasUser) { + authHelper.createAnonymousAccount().collect() + } + + LoginInfo( + userId = auth.id, + isAnonymous = auth.isAnonymous, + ) + } + } +} diff --git a/core/domain/src/main/java/com/github/andiim/plantscan/core/domain/PostDetectionRecord.kt b/core/domain/src/main/java/com/github/andiim/plantscan/core/domain/PostDetectionRecord.kt new file mode 100644 index 00000000..105af016 --- /dev/null +++ b/core/domain/src/main/java/com/github/andiim/plantscan/core/domain/PostDetectionRecord.kt @@ -0,0 +1,13 @@ +package com.github.andiim.plantscan.core.domain + +import com.github.andiim.plantscan.core.data.repository.DetectHistoryRepo +import com.github.andiim.plantscan.core.model.data.DetectionHistory +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class PostDetectionRecord @Inject constructor( + private val historyRepo: DetectHistoryRepo, +) { + operator fun invoke(history: DetectionHistory): Flow = + historyRepo.recordDetection(history) +} diff --git a/core/domain/src/main/java/com/github/andiim/plantscan/core/domain/PostSuggestionUseCase.kt b/core/domain/src/main/java/com/github/andiim/plantscan/core/domain/PostSuggestionUseCase.kt new file mode 100644 index 00000000..51427b7c --- /dev/null +++ b/core/domain/src/main/java/com/github/andiim/plantscan/core/domain/PostSuggestionUseCase.kt @@ -0,0 +1,13 @@ +package com.github.andiim.plantscan.core.domain + +import com.github.andiim.plantscan.core.data.repository.DetectRepository +import com.github.andiim.plantscan.core.model.data.Suggestion +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class PostSuggestionUseCase @Inject constructor( + private val detectRepository: DetectRepository, +) { + operator fun invoke(suggestion: Suggestion): Flow = + detectRepository.sendSuggestion(suggestion) +} diff --git a/core/domain/src/test/java/com/github/andiim/domain/ExampleUnitTest.kt b/core/domain/src/test/java/com/github/andiim/domain/ExampleUnitTest.kt new file mode 100644 index 00000000..93468f73 --- /dev/null +++ b/core/domain/src/test/java/com/github/andiim/domain/ExampleUnitTest.kt @@ -0,0 +1,16 @@ +package com.github.andiim.domain + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * 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) + } +} diff --git a/core/firestore/.gitignore b/core/firestore/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/firestore/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/firestore/build.gradle.kts b/core/firestore/build.gradle.kts new file mode 100644 index 00000000..03d893c6 --- /dev/null +++ b/core/firestore/build.gradle.kts @@ -0,0 +1,39 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.android.library.jacoco) + alias(libs.plugins.android.hilt) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.secrets) +} + +android { + buildFeatures { + buildConfig = true + } + namespace = "com.github.andiim.plantscan.core.firestore" + @Suppress("UnstableApiUsage") + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +secrets { + defaultPropertiesFileName = "secrets.defaults.properties" +} + +dependencies { + implementation(project(":core:common")) + implementation(project(":core:model")) + + implementation(libs.coil) + implementation(libs.coil.kt.svg) + implementation(libs.kotlin.coroutines.android) + implementation(libs.kotlinx.datetime) + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.firestore) + implementation(libs.kotlinx.serialization.json) + + testImplementation(project(":core:testing")) +} diff --git a/core/firestore/src/demo/java/com/github/andiim/plantscan/core/firestore/di/FlavoredFirestoreModule.kt b/core/firestore/src/demo/java/com/github/andiim/plantscan/core/firestore/di/FlavoredFirestoreModule.kt new file mode 100644 index 00000000..01dcf068 --- /dev/null +++ b/core/firestore/src/demo/java/com/github/andiim/plantscan/core/firestore/di/FlavoredFirestoreModule.kt @@ -0,0 +1,15 @@ +package com.github.andiim.plantscan.core.firestore.di + +import com.github.andiim.plantscan.core.firestore.PsFirebaseDataSource +import com.github.andiim.plantscan.core.firestore.fake.FakePsFirebaseDataSource +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface FlavoredFirestoreModule { + @Binds + fun binds(fakeFirebase: FakePsFirebaseDataSource): PsFirebaseDataSource +} \ No newline at end of file diff --git a/core/firestore/src/main/AndroidManifest.xml b/core/firestore/src/main/AndroidManifest.xml new file mode 100644 index 00000000..67d9f942 --- /dev/null +++ b/core/firestore/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/core/firestore/src/main/assets/history.json b/core/firestore/src/main/assets/history.json new file mode 100644 index 00000000..e1f5a89a --- /dev/null +++ b/core/firestore/src/main/assets/history.json @@ -0,0 +1,9 @@ +[ + { + "id":"2V0m2Zv2EJi0ijTSz64R", + "userId": "demo", + "timestamp": "2014-09-12T19:34:29Z", + "plantRef": "ddd", + "accuracy": "0.3" + } +] \ No newline at end of file diff --git a/core/firestore/src/main/assets/plant.json b/core/firestore/src/main/assets/plant.json new file mode 100644 index 00000000..6973300b --- /dev/null +++ b/core/firestore/src/main/assets/plant.json @@ -0,0 +1,43 @@ +{ + "id": "Ds2dwTwRjEXiaVznCJOk", + "taxonomy": { + "phylum": "phylum1", + "order": "order1", + "class": "class1", + "family": "family1", + "genus": "genuss" + }, + "common_name": [ + { + "name": "otherName1" + }, + { + "name": "otherName2" + } + ], + "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum", + "date": { + "seconds": 1693961153, + "nanoseconds": 909000000 + }, + "species": "spesies", + "Updated": { + "seconds": 1693972013, + "nanoseconds": 109000000 + }, + "name": "Susu Kotak", + "images": [ + { + "id": 1693971964363, + "date": { + "seconds": 1693971964, + "nanoseconds": 363000000 + }, + "attribution": "", + "desc": "", + "description" : "", + "url": "https://firebasestorage.googleapis.com/v0/b/orchid-app-7fe3d.appspot.com/o/orchids%2FSusu%20Kotak%2Fc52689fe6c832fb2c925e8e534e53da3%20(2).jpg?alt=media&token=fb7d975f-172c-4013-a74d-0958d3e5ec23" + } + ], + "thumbnail": "https://firebasestorage.googleapis.com/v0/b/orchid-app-7fe3d.appspot.com/o/orchids%2FSusu%20Kotak%2Fc52689fe6c832fb2c925e8e534e53da3%20(2).jpg?alt=media&token=fb7d975f-172c-4013-a74d-0958d3e5ec23" +} \ No newline at end of file diff --git a/core/firestore/src/main/assets/plants.json b/core/firestore/src/main/assets/plants.json new file mode 100644 index 00000000..d18d9498 --- /dev/null +++ b/core/firestore/src/main/assets/plants.json @@ -0,0 +1,45 @@ +[ + { + "id": "Ds2dwTwRjEXiaVznCJOk", + "images": [ + { + "url": "https://firebasestorage.googleapis.com/v0/b/orchid-app-7fe3d.appspot.com/o/orchids%2FSusu%20Kotak%2Fc52689fe6c832fb2c925e8e534e53da3%20(2).jpg?alt=media&token=fb7d975f-172c-4013-a74d-0958d3e5ec23", + "date": { + "seconds": 1693971964, + "nanoseconds": 363000000 + }, + "attribution": "", + "desc": "", + "description": "", + "id": 1693971964363 + } + ], + "name": "Susu Kotak", + "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum", + "species": "spesies", + "date": { + "seconds": 1693961153, + "nanoseconds": 909000000 + }, + "Updated": { + "seconds": 1693972013, + "nanoseconds": 109000000 + }, + "taxonomy": { + "phylum": "phylum1", + "order": "order1", + "family": "family1", + "genus": "genuss", + "class": "class1" + }, + "common_name": [ + { + "name": "otherName1" + }, + { + "name": "otherName2" + } + ], + "thumbnail": "https://firebasestorage.googleapis.com/v0/b/orchid-app-7fe3d.appspot.com/o/orchids%2FSusu%20Kotak%2Fc52689fe6c832fb2c925e8e534e53da3%20(2).jpg?alt=media&token=fb7d975f-172c-4013-a74d-0958d3e5ec23" + } +] \ No newline at end of file diff --git a/core/firestore/src/main/java/FirestoreJvmUnitTestFakeAssetManager.kt b/core/firestore/src/main/java/FirestoreJvmUnitTestFakeAssetManager.kt new file mode 100644 index 00000000..5fa5c883 --- /dev/null +++ b/core/firestore/src/main/java/FirestoreJvmUnitTestFakeAssetManager.kt @@ -0,0 +1,27 @@ +import androidx.annotation.VisibleForTesting +import com.github.andiim.plantscan.core.firestore.fake.FakeAssetManager +import java.io.File +import java.io.InputStream +import java.util.Properties + +@Suppress("MaxLineLength") +/** + * This class helps with loading Android `/assets` files, especially when running JVM unit tests. + * It must remain on the root package for an easier [Class.getResource] with relative paths. + * @see UnitTestOptions + */ +@VisibleForTesting +internal object FirestoreJvmUnitTestFakeAssetManager : FakeAssetManager { + private val config = + requireNotNull(javaClass.getResource("com/android/tools/test_config.properties")) { + """ + Missing Android resources properties file. + Did you forget to enable the feature in the gradle build file? + android.testOptions.unitTests.isIncludeAndroidResources = true + """.trimIndent() + } + private val properties = Properties().apply { config.openStream().use(::load) } + private val assets = File(properties["android_merged_assets"].toString()) + + override fun open(fileName: String): InputStream = File(assets, fileName).inputStream() +} diff --git a/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/PsFirebaseDataSource.kt b/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/PsFirebaseDataSource.kt new file mode 100644 index 00000000..3e9261e7 --- /dev/null +++ b/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/PsFirebaseDataSource.kt @@ -0,0 +1,17 @@ +package com.github.andiim.plantscan.core.firestore + +import com.github.andiim.plantscan.core.firestore.model.HistoryDocument +import com.github.andiim.plantscan.core.firestore.model.PlantDocument +import com.github.andiim.plantscan.core.firestore.model.SuggestionDocument + +/** + * Interface representing API calls to the App Firestore backend. + */ +interface PsFirebaseDataSource { + suspend fun getPlants(): List + suspend fun getPlantById(id: String): PlantDocument + suspend fun recordDetection(detection: HistoryDocument): String + suspend fun getDetectionHistories(userId: String): List + suspend fun getDetectionDetail(historyId: String): HistoryDocument + suspend fun sendSuggestion(suggestionDocument: SuggestionDocument): String +} diff --git a/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/di/FirestoreModule.kt b/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/di/FirestoreModule.kt new file mode 100644 index 00000000..10ea54d6 --- /dev/null +++ b/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/di/FirestoreModule.kt @@ -0,0 +1,41 @@ +package com.github.andiim.plantscan.core.firestore.di + +import android.content.Context +import com.github.andiim.plantscan.core.firestore.BuildConfig +import com.github.andiim.plantscan.core.firestore.fake.FakeAssetManager +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.MemoryCacheSettings +import com.google.firebase.firestore.ktx.firestore +import com.google.firebase.firestore.ktx.firestoreSettings +import com.google.firebase.ktx.Firebase +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 FirestoreModule { + + @Provides + @Singleton + fun providesFakeAssetManager( + @ApplicationContext context: Context, + ): FakeAssetManager = FakeAssetManager(context.assets::open) + + @Provides + @Singleton + fun provideFirebaseFirestore(): FirebaseFirestore = Firebase.firestore.also { + if (BuildConfig.USE_EMULTAOR.toBoolean()) { + it.useEmulator(HOST, PORT) + it.firestoreSettings = firestoreSettings { + setLocalCacheSettings(MemoryCacheSettings.newBuilder().build()) + } + } + } + + private const val HOST = "10.0.2.2" + private const val PORT = 8080 +} diff --git a/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/fake/FakeAssetManager.kt b/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/fake/FakeAssetManager.kt new file mode 100644 index 00000000..c9cb21a5 --- /dev/null +++ b/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/fake/FakeAssetManager.kt @@ -0,0 +1,7 @@ +package com.github.andiim.plantscan.core.firestore.fake + +import java.io.InputStream + +fun interface FakeAssetManager { + fun open(fileName: String): InputStream +} diff --git a/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/fake/FakePsFirebaseDataSource.kt b/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/fake/FakePsFirebaseDataSource.kt new file mode 100644 index 00000000..c114d5e8 --- /dev/null +++ b/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/fake/FakePsFirebaseDataSource.kt @@ -0,0 +1,61 @@ +package com.github.andiim.plantscan.core.firestore.fake + +import FirestoreJvmUnitTestFakeAssetManager +import com.github.andiim.plantscan.core.firestore.PsFirebaseDataSource +import com.github.andiim.plantscan.core.firestore.fake.model.PlantJson +import com.github.andiim.plantscan.core.firestore.fake.model.toDocument +import com.github.andiim.plantscan.core.firestore.model.HistoryDocument +import com.github.andiim.plantscan.core.firestore.model.PlantDocument +import com.github.andiim.plantscan.core.firestore.model.SuggestionDocument +import com.github.andiim.plantscan.core.network.AppDispatchers.IO +import com.github.andiim.plantscan.core.network.Dispatcher +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import javax.inject.Inject + +@OptIn(ExperimentalSerializationApi::class) +class FakePsFirebaseDataSource @Inject constructor( + @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, + private val networkJson: Json, + private val assets: FakeAssetManager = FirestoreJvmUnitTestFakeAssetManager, +) : PsFirebaseDataSource { + + private suspend fun getPlantsJson(): List = withContext(ioDispatcher) { + assets.open(PLANTS_ASSET).use(networkJson::decodeFromStream) + } + + override suspend fun getPlants(): List = + getPlantsJson().map(PlantJson::toDocument) + + private suspend fun getPlantJson(): PlantJson = withContext(ioDispatcher) { + assets.open(PLANT_ASSET).use(networkJson::decodeFromStream) + } + + override suspend fun getPlantById(id: String): PlantDocument = getPlantJson().toDocument() + + private val _detections = mutableListOf() + + override suspend fun recordDetection(detection: HistoryDocument): String { + _detections.add(detection) + return detection.id.toString() + } + + override suspend fun getDetectionHistories(userId: String): List = + _detections + + override suspend fun getDetectionDetail(historyId: String): HistoryDocument = + _detections.first { it.id == historyId } + + override suspend fun sendSuggestion(suggestionDocument: SuggestionDocument): String { + return suggestionDocument.id + } + + companion object { + // private const val HISTORY_ASSET = "history.json" + private const val PLANTS_ASSET = "plants.json" + private const val PLANT_ASSET = "plant.json" + } +} diff --git a/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/fake/model/ImageJson.kt b/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/fake/model/ImageJson.kt new file mode 100644 index 00000000..69b869e8 --- /dev/null +++ b/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/fake/model/ImageJson.kt @@ -0,0 +1,26 @@ +package com.github.andiim.plantscan.core.firestore.fake.model + +import com.github.andiim.plantscan.core.firestore.model.ImageDocument +import com.github.andiim.plantscan.core.firestore.utils.DateJson +import kotlinx.serialization.Serializable +import java.util.Date + +@Serializable +data class ImageJson( + val url: String, + val date: DateJson, + val attribution: String, + val desc: String, + val description: String, + val id: Long, +) + +@Suppress("detekt:MagicNumber") +fun ImageJson.toImageDocument() = ImageDocument( + id = id, + url = url, + date = Date(date.seconds * 1000 + (date.nanoseconds / 1000000)), + attribution = attribution, + desc = desc, + description = description, +) diff --git a/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/fake/model/PlantJson.kt b/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/fake/model/PlantJson.kt new file mode 100644 index 00000000..e3133847 --- /dev/null +++ b/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/fake/model/PlantJson.kt @@ -0,0 +1,27 @@ +package com.github.andiim.plantscan.core.firestore.fake.model + +import com.github.andiim.plantscan.core.firestore.model.PlantDocument +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PlantJson( + val id: String, + val name: String, + val species: String, + val description: String, + val thumbnail: String, + @SerialName("common_name") + val commonName: List>, + val images: List, +) + +fun PlantJson.toDocument() = PlantDocument( + id = id, + name = name, + species = species, + description = description, + thumbnail = thumbnail, + commonName = commonName.map { PlantDocument.CommonName(it["name"]!!) }, + images = images.map { it.toImageDocument() }, +) diff --git a/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/model/HistoryDocument.kt b/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/model/HistoryDocument.kt new file mode 100644 index 00000000..992a8f4e --- /dev/null +++ b/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/model/HistoryDocument.kt @@ -0,0 +1,27 @@ +@file:UseSerializers(DateSerializer::class) + +package com.github.andiim.plantscan.core.firestore.model + +import com.github.andiim.plantscan.core.firestore.utils.DateSerializer +import com.google.firebase.firestore.DocumentId +import com.google.firebase.firestore.ServerTimestamp +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import java.util.Date + +@Serializable +data class HistoryDocument( + @DocumentId val id: String? = null, + @ServerTimestamp val timestamp: Date? = null, + val userId: String = "", + val plantRef: String = "", + val accuracy: Float = 0f, + val image: String = "", + val detections: List = listOf(), +) + +@Serializable +data class LabelPredictDocument( + val objectClass: String = "", + val confidence: Float = 0f, +) diff --git a/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/model/ImageDocument.kt b/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/model/ImageDocument.kt new file mode 100644 index 00000000..4d63a8ea --- /dev/null +++ b/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/model/ImageDocument.kt @@ -0,0 +1,15 @@ +package com.github.andiim.plantscan.core.firestore.model + +import com.google.firebase.firestore.Exclude +import com.google.firebase.firestore.PropertyName +import com.google.firebase.firestore.ServerTimestamp +import java.util.Date + +data class ImageDocument( + val url: String = "", + @ServerTimestamp val date: Date? = null, + val attribution: String? = "", + @Exclude val id: Long? = null, + @get:PropertyName("desc") @set:PropertyName("desc") var desc: String? = "", + @get:PropertyName("description") @set:PropertyName("description") var description: String? = "", +) diff --git a/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/model/PlantDocument.kt b/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/model/PlantDocument.kt new file mode 100644 index 00000000..9d42d3bc --- /dev/null +++ b/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/model/PlantDocument.kt @@ -0,0 +1,46 @@ +package com.github.andiim.plantscan.core.firestore.model + +import com.google.firebase.firestore.DocumentId +import com.google.firebase.firestore.Exclude +import com.google.firebase.firestore.PropertyName +import com.google.firebase.firestore.ServerTimestamp +import java.util.Date + +data class PlantDocument( + @DocumentId val id: String = "", + val name: String = "", + val species: String = "", + val description: String = "", + val thumbnail: String = "", + @get:PropertyName("common_name") + @set:PropertyName("common_name") + var commonName: List = listOf(), + val images: List = listOf(), + var taxonomy: TaxonomyDocument? = null, + @Exclude @ServerTimestamp var date: Date? = null, + @Exclude @ServerTimestamp @get:PropertyName("Updated") @set:PropertyName("Updated") + var updated: Date? = null, +) { + data class CommonName(val name: String = "") + + constructor( + id: String, + name: String, + species: String, + description: String, + thumbnail: String, + commonName: List, + images: List, + ) : this( + id, + name, + species, + description, + thumbnail, + commonName, + images, + null, + null, + null, + ) +} diff --git a/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/model/SuggestionDocument.kt b/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/model/SuggestionDocument.kt new file mode 100644 index 00000000..95e2a458 --- /dev/null +++ b/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/model/SuggestionDocument.kt @@ -0,0 +1,13 @@ +package com.github.andiim.plantscan.core.firestore.model + +import com.google.firebase.firestore.DocumentId +import com.google.firebase.firestore.ServerTimestamp +import java.util.Date + +data class SuggestionDocument( + @DocumentId val id: String = "", + val userId: String = "", + @ServerTimestamp val date: Date? = null, + val description: String = "", + val images: List = listOf(), +) diff --git a/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/model/TaxonomyDocument.kt b/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/model/TaxonomyDocument.kt new file mode 100644 index 00000000..4fd3b448 --- /dev/null +++ b/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/model/TaxonomyDocument.kt @@ -0,0 +1,14 @@ +package com.github.andiim.plantscan.core.firestore.model + +import com.google.firebase.firestore.PropertyName + +data class TaxonomyDocument( + val phylum: String = "", + val order: String = "", + val family: String = "", + val genus: String = "", +) { + @get:PropertyName("class") + @set:PropertyName("class") + var className: String = "" +} diff --git a/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/network/PsFirebaseNetwork.kt b/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/network/PsFirebaseNetwork.kt new file mode 100644 index 00000000..1f4cc403 --- /dev/null +++ b/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/network/PsFirebaseNetwork.kt @@ -0,0 +1,54 @@ +package com.github.andiim.plantscan.core.firestore.network + +import com.github.andiim.plantscan.core.firestore.PsFirebaseDataSource +import com.github.andiim.plantscan.core.firestore.model.HistoryDocument +import com.github.andiim.plantscan.core.firestore.model.PlantDocument +import com.github.andiim.plantscan.core.firestore.model.SuggestionDocument +import com.google.firebase.firestore.DocumentReference +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.Query +import com.google.firebase.firestore.ktx.toObject +import com.google.firebase.firestore.ktx.toObjects +import kotlinx.coroutines.tasks.await +import javax.inject.Inject + +class PsFirebaseNetwork @Inject constructor( + private val db: FirebaseFirestore, +) : PsFirebaseDataSource { + override suspend fun getPlants(): List = + querySnapshotHandling(db.collection(PLANT_COLLECTION)) + + override suspend fun getPlantById(id: String): PlantDocument = + documentSnapshotHandling(db.collection(PLANT_COLLECTION).document(id)) + + override suspend fun recordDetection(detection: HistoryDocument): String = + db.collection(DETECT_COLLECTION).add(detection).await().id + + override suspend fun getDetectionHistories(userId: String): List = + querySnapshotHandling( + db.collection(DETECT_COLLECTION).whereEqualTo(USER_ID_FIELD, userId), + ) + + override suspend fun getDetectionDetail(historyId: String): HistoryDocument = + documentSnapshotHandling(db.collection(DETECT_COLLECTION).document(historyId)) + + override suspend fun sendSuggestion(suggestionDocument: SuggestionDocument): String = + db.collection(SUGGESTION_COLLECTION).add(suggestionDocument).await().id + + private suspend inline fun querySnapshotHandling(reference: Query): List { + val snapshot = reference.get().await() + return snapshot.toObjects() + } + + private suspend inline fun documentSnapshotHandling(ref: DocumentReference): T { + val snapshot = ref.get().await() + return snapshot.toObject()!! + } + + companion object { + private const val PLANT_COLLECTION = "plants" + private const val SUGGESTION_COLLECTION = "suggestions" + private const val DETECT_COLLECTION = "detections" + private const val USER_ID_FIELD = "userId" + } +} diff --git a/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/utils/DateSerializer.kt b/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/utils/DateSerializer.kt new file mode 100644 index 00000000..3010c927 --- /dev/null +++ b/core/firestore/src/main/java/com/github/andiim/plantscan/core/firestore/utils/DateSerializer.kt @@ -0,0 +1,30 @@ +package com.github.andiim.plantscan.core.firestore.utils + +import android.util.Log +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import java.time.Instant +import java.util.Date + +object DateSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("java.util.Date", PrimitiveKind.STRING) + override fun deserialize(decoder: Decoder): Date { + val decode = decoder.decodeString() + Log.d("TAG", "deserialize: $decode") + return Date.from(Instant.parse(decode)) + } + override fun serialize(encoder: Encoder, value: Date) = + encoder.encodeString(value.toInstant().toString()) +} + +@Serializable +class DateJson( + val seconds: Long, + val nanoseconds: Long, +) diff --git a/core/firestore/src/prod/java/com/github/andiim/plantscan/core/firestore/di/FlavoredFirestoreModule.kt b/core/firestore/src/prod/java/com/github/andiim/plantscan/core/firestore/di/FlavoredFirestoreModule.kt new file mode 100644 index 00000000..b6ac551c --- /dev/null +++ b/core/firestore/src/prod/java/com/github/andiim/plantscan/core/firestore/di/FlavoredFirestoreModule.kt @@ -0,0 +1,15 @@ +package com.github.andiim.plantscan.core.firestore.di + +import com.github.andiim.plantscan.core.firestore.PsFirebaseDataSource +import com.github.andiim.plantscan.core.firestore.network.PsFirebaseNetwork +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface FlavoredFirestoreModule { + @Binds + fun binds(firebase: PsFirebaseNetwork): PsFirebaseDataSource +} diff --git a/core/firestore/src/test/java/com/github/andiim/core/firestore/ExampleUnitTest.kt b/core/firestore/src/test/java/com/github/andiim/core/firestore/ExampleUnitTest.kt new file mode 100644 index 00000000..490ec4e2 --- /dev/null +++ b/core/firestore/src/test/java/com/github/andiim/core/firestore/ExampleUnitTest.kt @@ -0,0 +1,16 @@ +package com.github.andiim.core.firestore + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * 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) + } +} diff --git a/core/model/.gitignore b/core/model/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/model/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/model/README.md b/core/model/README.md new file mode 100644 index 00000000..8a443250 --- /dev/null +++ b/core/model/README.md @@ -0,0 +1 @@ +# :core:model module \ No newline at end of file diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts new file mode 100644 index 00000000..f452a5cd --- /dev/null +++ b/core/model/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.jvm.library) +} + +dependencies { + implementation(libs.kotlinx.datetime) +} \ No newline at end of file diff --git a/core/model/src/main/AndroidManifest.xml b/core/model/src/main/AndroidManifest.xml new file mode 100644 index 00000000..568741e5 --- /dev/null +++ b/core/model/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/core/model/src/main/java/com/github/andiim/plantscan/core/model/data/DarkThemeConfig.kt b/core/model/src/main/java/com/github/andiim/plantscan/core/model/data/DarkThemeConfig.kt new file mode 100644 index 00000000..c7af8e7b --- /dev/null +++ b/core/model/src/main/java/com/github/andiim/plantscan/core/model/data/DarkThemeConfig.kt @@ -0,0 +1,5 @@ +package com.github.andiim.plantscan.core.model.data + +enum class DarkThemeConfig { + FOLLOW_SYSTEM, LIGHT, DARK +} diff --git a/core/model/src/main/java/com/github/andiim/plantscan/core/model/data/DetectResult.kt b/core/model/src/main/java/com/github/andiim/plantscan/core/model/data/DetectResult.kt new file mode 100644 index 00000000..d6de3c7c --- /dev/null +++ b/core/model/src/main/java/com/github/andiim/plantscan/core/model/data/DetectResult.kt @@ -0,0 +1,22 @@ +package com.github.andiim.plantscan.core.model.data + +data class ObjectDetection( + val time: Float, + var image: Imgz, + val predictions: List, +) + +data class Prediction( + val confidence: Float, + val x: Float, + val width: Float, + val y: Float, + val jsonMemberClass: String, + val height: Float, +) + +data class Imgz( + val width: Float, + val height: Float, + val base64: String = "", +) diff --git a/core/model/src/main/java/com/github/andiim/plantscan/core/model/data/DetectionHistory.kt b/core/model/src/main/java/com/github/andiim/plantscan/core/model/data/DetectionHistory.kt new file mode 100644 index 00000000..8a240747 --- /dev/null +++ b/core/model/src/main/java/com/github/andiim/plantscan/core/model/data/DetectionHistory.kt @@ -0,0 +1,18 @@ +package com.github.andiim.plantscan.core.model.data + +import kotlinx.datetime.Instant + +data class DetectionHistory( + val id: String?, + val timestamp: Instant, + val plantRef: String, + val userId: String, + val accuracy: Float, + val image: String, + val detections: List, +) + +data class LabelPredict( + val objectClass: String = "", + val confidence: Float = 0f, +) diff --git a/core/model/src/main/java/com/github/andiim/plantscan/core/model/data/LoginInfo.kt b/core/model/src/main/java/com/github/andiim/plantscan/core/model/data/LoginInfo.kt new file mode 100644 index 00000000..7ca47b5d --- /dev/null +++ b/core/model/src/main/java/com/github/andiim/plantscan/core/model/data/LoginInfo.kt @@ -0,0 +1,6 @@ +package com.github.andiim.plantscan.core.model.data + +data class LoginInfo( + val userId: String = "", + val isAnonymous: Boolean = true +) diff --git a/core/model/src/main/java/com/github/andiim/plantscan/core/model/data/Plant.kt b/core/model/src/main/java/com/github/andiim/plantscan/core/model/data/Plant.kt new file mode 100644 index 00000000..86c00219 --- /dev/null +++ b/core/model/src/main/java/com/github/andiim/plantscan/core/model/data/Plant.kt @@ -0,0 +1,14 @@ +package com.github.andiim.plantscan.core.model.data + +/** + * External data layer representation of Plant data. + */ +data class Plant( + val id: String, + val name: String, + val species: String, + val description: String, + val thumbnail: String, + val commonName: List, + val images: List +) diff --git a/core/model/src/main/java/com/github/andiim/plantscan/core/model/data/PlantImage.kt b/core/model/src/main/java/com/github/andiim/plantscan/core/model/data/PlantImage.kt new file mode 100644 index 00000000..06a0d6a3 --- /dev/null +++ b/core/model/src/main/java/com/github/andiim/plantscan/core/model/data/PlantImage.kt @@ -0,0 +1,8 @@ +package com.github.andiim.plantscan.core.model.data + +data class PlantImage( + val id: String, + val url: String, + val attribution: String, + val description: String, +) diff --git a/core/model/src/main/java/com/github/andiim/plantscan/core/model/data/SearchResult.kt b/core/model/src/main/java/com/github/andiim/plantscan/core/model/data/SearchResult.kt new file mode 100644 index 00000000..0204d996 --- /dev/null +++ b/core/model/src/main/java/com/github/andiim/plantscan/core/model/data/SearchResult.kt @@ -0,0 +1,8 @@ +package com.github.andiim.plantscan.core.model.data + +/** + * An entity that holds the search result. + */ +class SearchResult( + val plants: List = emptyList() +) diff --git a/core/model/src/main/java/com/github/andiim/plantscan/core/model/data/Suggestion.kt b/core/model/src/main/java/com/github/andiim/plantscan/core/model/data/Suggestion.kt new file mode 100644 index 00000000..f17a9a74 --- /dev/null +++ b/core/model/src/main/java/com/github/andiim/plantscan/core/model/data/Suggestion.kt @@ -0,0 +1,17 @@ +package com.github.andiim.plantscan.core.model.data + +import java.util.Date + +data class Suggestion( + val id: String, + val userId: String, + val date: Date?, + val description: String, + val images: List = listOf(), +) { + constructor( + userId: String = "", + description: String = "", + image: List = listOf(), + ) : this("", userId, null, description, image) +} diff --git a/core/model/src/main/java/com/github/andiim/plantscan/core/model/data/UserData.kt b/core/model/src/main/java/com/github/andiim/plantscan/core/model/data/UserData.kt new file mode 100644 index 00000000..628473c7 --- /dev/null +++ b/core/model/src/main/java/com/github/andiim/plantscan/core/model/data/UserData.kt @@ -0,0 +1,9 @@ +package com.github.andiim.plantscan.core.model.data + +data class UserData( + val isLogin: Boolean, + val userId: String, + val darkThemeConfig: DarkThemeConfig, + val useDynamicColor: Boolean, + val shouldHideOnboarding: Boolean, +) diff --git a/core/network/.gitignore b/core/network/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/network/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/network/README.md b/core/network/README.md new file mode 100644 index 00000000..d0cfef26 --- /dev/null +++ b/core/network/README.md @@ -0,0 +1 @@ +# :core:network module \ No newline at end of file diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts new file mode 100644 index 00000000..4f669d21 --- /dev/null +++ b/core/network/build.gradle.kts @@ -0,0 +1,41 @@ +@file:Suppress("UnstableApiUsage") + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.android.library.jacoco) + alias(libs.plugins.android.hilt) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.secrets) +} + +android { + buildFeatures { + buildConfig = true + } + namespace = "com.github.andiim.plantscan.core.network" + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +secrets { + defaultPropertiesFileName = "secrets.defaults.properties" +} + +dependencies { + implementation(project(":core:common")) + implementation(project(":core:model")) + + implementation(libs.coil) + implementation(libs.coil.kt.svg) + implementation(libs.kotlin.coroutines.android) + implementation(libs.kotlinx.datetime) + implementation(libs.kotlinx.serialization.json) + implementation(libs.okhttp.logging) + implementation(libs.retrofit.core) + implementation(libs.retrofit.kotlin.serialization) + + testImplementation(project(":core:testing")) +} diff --git a/core/network/lint.xml b/core/network/lint.xml new file mode 100644 index 00000000..da8ce307 --- /dev/null +++ b/core/network/lint.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/core/network/src/demo/java/com/github/andiim/plantscan/core/network/di/FlavoredNetworkModule.kt b/core/network/src/demo/java/com/github/andiim/plantscan/core/network/di/FlavoredNetworkModule.kt new file mode 100644 index 00000000..5de60e12 --- /dev/null +++ b/core/network/src/demo/java/com/github/andiim/plantscan/core/network/di/FlavoredNetworkModule.kt @@ -0,0 +1,15 @@ +package com.github.andiim.plantscan.core.network.di + +import com.github.andiim.plantscan.core.network.PsNetworkDataSource +import com.github.andiim.plantscan.core.network.fake.FakePsNetworkDataSource +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface FlavoredNetworkModule { + @Binds + fun bindFakeDataSource(dataSource: FakePsNetworkDataSource): PsNetworkDataSource +} diff --git a/core/network/src/main/AndroidManifest.xml b/core/network/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/core/network/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/core/network/src/main/assets/detect.json b/core/network/src/main/assets/detect.json new file mode 100644 index 00000000..5f8258a8 --- /dev/null +++ b/core/network/src/main/assets/detect.json @@ -0,0 +1,17 @@ +{ + "time": 1.000, + "predictions": [ + { + "x": 189.5, + "y": 100, + "width": 163, + "height": 186, + "class": "helmet", + "confidence": 0.544 + } + ], + "image": { + "width": 2048, + "height": 1371 + } +} \ No newline at end of file diff --git a/core/network/src/main/java/NetworkJvmUnitTestFakeAssetManager.kt b/core/network/src/main/java/NetworkJvmUnitTestFakeAssetManager.kt new file mode 100644 index 00000000..0822137a --- /dev/null +++ b/core/network/src/main/java/NetworkJvmUnitTestFakeAssetManager.kt @@ -0,0 +1,27 @@ +import androidx.annotation.VisibleForTesting +import com.github.andiim.plantscan.core.network.fake.FakeAssetManager +import java.io.File +import java.io.InputStream +import java.util.Properties + +@Suppress("MaxLineLength") +/** + * This class helps with loading Android `/assets` files, especially when running JVM unit tests. + * It must remain on the root package for an easier [Class.getResource] with relative paths. + * @see UnitTestOptions + */ +@VisibleForTesting +internal object NetworkJvmUnitTestFakeAssetManager : FakeAssetManager { + private val config = + requireNotNull(javaClass.getResource("com/android/tools/test_config.properties")) { + """ + Missing Android resources properties file. + Did you forget to enable the feature in the gradle build file? + android.testOptions.unitTests.isIncludeAndroidResources = true + """.trimIndent() + } + private val properties = Properties().apply { config.openStream().use(::load) } + private val assets = File(properties["android_merged_assets"].toString()) + + override fun open(fileName: String): InputStream = File(assets, fileName).inputStream() +} diff --git a/core/network/src/main/java/com/github/andiim/plantscan/core/network/PsNetworkDataSource.kt b/core/network/src/main/java/com/github/andiim/plantscan/core/network/PsNetworkDataSource.kt new file mode 100644 index 00000000..b56d848b --- /dev/null +++ b/core/network/src/main/java/com/github/andiim/plantscan/core/network/PsNetworkDataSource.kt @@ -0,0 +1,15 @@ +package com.github.andiim.plantscan.core.network + +import com.github.andiim.plantscan.core.network.model.DetectionResponse +import javax.inject.Qualifier + +/** + * Interface representing network calls to the backend. + */ +interface PsNetworkDataSource { + suspend fun detect(@Base64String image: String, confidence: Int, overlap: Int): DetectionResponse +} + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class Base64String diff --git a/core/network/src/main/java/com/github/andiim/plantscan/core/network/di/NetworkModule.kt b/core/network/src/main/java/com/github/andiim/plantscan/core/network/di/NetworkModule.kt new file mode 100644 index 00000000..1361766f --- /dev/null +++ b/core/network/src/main/java/com/github/andiim/plantscan/core/network/di/NetworkModule.kt @@ -0,0 +1,54 @@ +package com.github.andiim.plantscan.core.network.di + +import android.content.Context +import com.github.andiim.plantscan.core.network.BuildConfig +import com.github.andiim.plantscan.core.network.fake.FakeAssetManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import okhttp3.Call +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import java.util.concurrent.TimeUnit +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + @Provides + @Singleton + fun providesFakeAssetManager( + @ApplicationContext context: Context, + ): FakeAssetManager = FakeAssetManager(context.assets::open) + + @Provides + @Singleton + fun okHttpCallFactory(): Call.Factory = OkHttpClient.Builder() + .addInterceptor( + HttpLoggingInterceptor() + .apply { + if (BuildConfig.DEBUG) { + setLevel(HttpLoggingInterceptor.Level.BODY) + } + }, + ) + .build() + + private const val TIME_OUT = 120L + + @Provides + @Singleton + fun provideLogging(): OkHttpClient { + val loggingInterceptor = if (BuildConfig.DEBUG) { +// HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY) + HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.HEADERS) + } else { + HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.NONE) + } + + return OkHttpClient.Builder().connectTimeout(TIME_OUT, TimeUnit.SECONDS) + .readTimeout(TIME_OUT, TimeUnit.SECONDS).addInterceptor(loggingInterceptor).build() + } +} diff --git a/core/network/src/main/java/com/github/andiim/plantscan/core/network/fake/FakeAssetManager.kt b/core/network/src/main/java/com/github/andiim/plantscan/core/network/fake/FakeAssetManager.kt new file mode 100644 index 00000000..22d6fd0d --- /dev/null +++ b/core/network/src/main/java/com/github/andiim/plantscan/core/network/fake/FakeAssetManager.kt @@ -0,0 +1,7 @@ +package com.github.andiim.plantscan.core.network.fake + +import java.io.InputStream + +fun interface FakeAssetManager { + fun open(fileName: String): InputStream +} diff --git a/core/network/src/main/java/com/github/andiim/plantscan/core/network/fake/FakePsNetworkDataSource.kt b/core/network/src/main/java/com/github/andiim/plantscan/core/network/fake/FakePsNetworkDataSource.kt new file mode 100644 index 00000000..ed16879b --- /dev/null +++ b/core/network/src/main/java/com/github/andiim/plantscan/core/network/fake/FakePsNetworkDataSource.kt @@ -0,0 +1,29 @@ +package com.github.andiim.plantscan.core.network.fake + +import NetworkJvmUnitTestFakeAssetManager +import com.github.andiim.plantscan.core.network.AppDispatchers.IO +import com.github.andiim.plantscan.core.network.Dispatcher +import com.github.andiim.plantscan.core.network.PsNetworkDataSource +import com.github.andiim.plantscan.core.network.model.DetectionResponse +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import javax.inject.Inject + +class FakePsNetworkDataSource @Inject constructor( + @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, + private val networkJson: Json, + private val assets: FakeAssetManager = NetworkJvmUnitTestFakeAssetManager, +) : PsNetworkDataSource { + @OptIn(ExperimentalSerializationApi::class) + override suspend fun detect(image: String, confidence: Int, overlap: Int): DetectionResponse = + withContext(ioDispatcher) { + assets.open(DETECT_ASSET).use(networkJson::decodeFromStream) + } + + companion object { + const val DETECT_ASSET = "detect.json" + } +} diff --git a/core/network/src/main/java/com/github/andiim/plantscan/core/network/model/NetworkDetection.kt b/core/network/src/main/java/com/github/andiim/plantscan/core/network/model/NetworkDetection.kt new file mode 100644 index 00000000..6a1ea4f4 --- /dev/null +++ b/core/network/src/main/java/com/github/andiim/plantscan/core/network/model/NetworkDetection.kt @@ -0,0 +1,27 @@ +package com.github.andiim.plantscan.core.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class DetectionResponse( + val time: Float, + val image: ImgzResponse, + val predictions: List +) + +@Serializable +data class PredictionResponse( + val confidence: Float, + val x: Float, + val width: Float, + val y: Float, + @SerialName("class") val jsonMemberClass: String, + val height: Float +) + +@Serializable +data class ImgzResponse( + val width: Float, + val height: Float +) diff --git a/core/network/src/main/java/com/github/andiim/plantscan/core/network/retrofit/RetrofitPsNetwork.kt b/core/network/src/main/java/com/github/andiim/plantscan/core/network/retrofit/RetrofitPsNetwork.kt new file mode 100644 index 00000000..d6276724 --- /dev/null +++ b/core/network/src/main/java/com/github/andiim/plantscan/core/network/retrofit/RetrofitPsNetwork.kt @@ -0,0 +1,52 @@ +package com.github.andiim.plantscan.core.network.retrofit + +import com.github.andiim.plantscan.core.network.BuildConfig +import com.github.andiim.plantscan.core.network.PsNetworkDataSource +import com.github.andiim.plantscan.core.network.model.DetectionResponse +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.serialization.json.Json +import okhttp3.Call +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.http.Body +import retrofit2.http.POST +import retrofit2.http.Query +import javax.inject.Inject +import javax.inject.Singleton + +private interface RetrofitNetworkApi { + @POST("/orchid-flower-detection/3") + suspend fun uploadImage( + @Query("api_key") apiKey: String, + @Query("confidence") confidence: Int, + @Query("overlap") overlap: Int, + @Body base64Image: String, + ): DetectionResponse +} + +@Singleton +class RetrofitPsNetwork @Inject constructor( + networkJson: Json, + okHttpCallFactory: Call.Factory, + client: OkHttpClient, +) : PsNetworkDataSource { + + companion object { + private const val BASE_URL: String = BuildConfig.BACKEND_URL + private const val API_KEY: String = BuildConfig.ROBOFLOW_API + } + + private val networkApi = + Retrofit.Builder().baseUrl(BASE_URL).callFactory(okHttpCallFactory).addConverterFactory( + networkJson.asConverterFactory("application/json".toMediaType()), + ).client(client).build().create(RetrofitNetworkApi::class.java) + + override suspend fun detect(image: String, confidence: Int, overlap: Int): DetectionResponse = + networkApi.uploadImage( + apiKey = API_KEY, + confidence = confidence, + overlap = overlap, + base64Image = image, + ) +} diff --git a/core/network/src/prod/java/com/github/andiim/plantscan/core/network/di/FlavoredNetworkModule.kt b/core/network/src/prod/java/com/github/andiim/plantscan/core/network/di/FlavoredNetworkModule.kt new file mode 100644 index 00000000..05b5c9bf --- /dev/null +++ b/core/network/src/prod/java/com/github/andiim/plantscan/core/network/di/FlavoredNetworkModule.kt @@ -0,0 +1,15 @@ +package com.github.andiim.plantscan.core.network.di + +import com.github.andiim.plantscan.core.network.PsNetworkDataSource +import com.github.andiim.plantscan.core.network.retrofit.RetrofitPsNetwork +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface FlavoredNetworkModule { + @Binds + fun bindRetrofit(dataSource: RetrofitPsNetwork): PsNetworkDataSource +} diff --git a/core/network/src/test/java/com/github/andiim/plantscan/core/network/fake/FakePsNetworkDataSourceTest.kt b/core/network/src/test/java/com/github/andiim/plantscan/core/network/fake/FakePsNetworkDataSourceTest.kt new file mode 100644 index 00000000..f6fd6b37 --- /dev/null +++ b/core/network/src/test/java/com/github/andiim/plantscan/core/network/fake/FakePsNetworkDataSourceTest.kt @@ -0,0 +1,50 @@ +package com.github.andiim.plantscan.core.network.fake + +import NetworkJvmUnitTestFakeAssetManager +import com.github.andiim.plantscan.core.network.model.DetectionResponse +import com.github.andiim.plantscan.core.network.model.ImgzResponse +import com.github.andiim.plantscan.core.network.model.PredictionResponse +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.junit.Before +import org.junit.Test +import kotlin.test.assertEquals + +class FakePsNetworkDataSourceTest { + private lateinit var subject: FakePsNetworkDataSource + private val testDispatcher = StandardTestDispatcher() + + @Before + fun setup() { + subject = FakePsNetworkDataSource( + ioDispatcher = testDispatcher, + networkJson = Json { ignoreUnknownKeys = true }, + assets = NetworkJvmUnitTestFakeAssetManager, + ) + } + + @Test + fun testDeserializationOfDetection() = runTest(testDispatcher) { + assertEquals( + expected = DetectionResponse( + time = 1.000f, + image = ImgzResponse( + width = 2048f, + height = 1371f, + ), + predictions = listOf( + PredictionResponse( + x = 189.5f, + y = 100f, + width = 163f, + height = 186f, + jsonMemberClass = "helmet", + confidence = 0.544f + ) + ), + ), + actual = subject.detect("", 1, 1), + ) + } +} diff --git a/core/notifications/.gitignore b/core/notifications/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/notifications/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/notifications/build.gradle.kts b/core/notifications/build.gradle.kts new file mode 100644 index 00000000..dcc67140 --- /dev/null +++ b/core/notifications/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.android.library.compose) + alias(libs.plugins.android.hilt) +} + +android { + namespace = "com.github.andiim.core.notifications" +} + +dependencies { + implementation(project(":core:common")) + implementation(project(":core:model")) + + implementation(libs.kotlin.coroutines.android) + implementation(libs.androidx.browser) + implementation(libs.compose.runtime) + implementation(libs.androidx.core.ktx) + + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.cloud.messaging) +} \ No newline at end of file diff --git a/library-android/consumer-rules.pro b/core/notifications/consumer-rules.pro similarity index 100% rename from library-android/consumer-rules.pro rename to core/notifications/consumer-rules.pro diff --git a/library-android/proguard-rules.pro b/core/notifications/proguard-rules.pro similarity index 88% rename from library-android/proguard-rules.pro rename to core/notifications/proguard-rules.pro index e5b5d760..481bb434 100644 --- a/library-android/proguard-rules.pro +++ b/core/notifications/proguard-rules.pro @@ -1,6 +1,6 @@ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle.kts.kts +# proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html @@ -18,4 +18,4 @@ # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/core/notifications/src/demo/java/com/github/andiim/plantscan/core/notifications/NotificationsModule.kt b/core/notifications/src/demo/java/com/github/andiim/plantscan/core/notifications/NotificationsModule.kt new file mode 100644 index 00000000..7027c698 --- /dev/null +++ b/core/notifications/src/demo/java/com/github/andiim/plantscan/core/notifications/NotificationsModule.kt @@ -0,0 +1,15 @@ +package com.github.andiim.plantscan.core.notifications + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +abstract class NotificationsModule { + @Binds + abstract fun bindNotifier( + notifier: Notifier, + ): Notifier +} \ No newline at end of file diff --git a/core/notifications/src/main/AndroidManifest.xml b/core/notifications/src/main/AndroidManifest.xml new file mode 100644 index 00000000..972f3b97 --- /dev/null +++ b/core/notifications/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/core/notifications/src/main/java/com/github/andiim/plantscan/core/notifications/NoOpNotifier.kt b/core/notifications/src/main/java/com/github/andiim/plantscan/core/notifications/NoOpNotifier.kt new file mode 100644 index 00000000..0466b9bb --- /dev/null +++ b/core/notifications/src/main/java/com/github/andiim/plantscan/core/notifications/NoOpNotifier.kt @@ -0,0 +1,7 @@ +package com.github.andiim.plantscan.core.notifications + +import javax.inject.Inject + +class NoOpNotifier @Inject constructor() : Notifier { + override fun postNotification() = Unit +} diff --git a/core/notifications/src/main/java/com/github/andiim/plantscan/core/notifications/Notifier.kt b/core/notifications/src/main/java/com/github/andiim/plantscan/core/notifications/Notifier.kt new file mode 100644 index 00000000..edbc8870 --- /dev/null +++ b/core/notifications/src/main/java/com/github/andiim/plantscan/core/notifications/Notifier.kt @@ -0,0 +1,5 @@ +package com.github.andiim.plantscan.core.notifications + +interface Notifier { + fun postNotification() +} diff --git a/core/notifications/src/main/java/com/github/andiim/plantscan/core/notifications/SystemTrayNotifier.kt b/core/notifications/src/main/java/com/github/andiim/plantscan/core/notifications/SystemTrayNotifier.kt new file mode 100644 index 00000000..5e63338b --- /dev/null +++ b/core/notifications/src/main/java/com/github/andiim/plantscan/core/notifications/SystemTrayNotifier.kt @@ -0,0 +1,25 @@ +package com.github.andiim.plantscan.core.notifications + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.app.ActivityCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SystemTrayNotifier @Inject constructor( + @ApplicationContext private val context: Context, +) : + Notifier { + override fun postNotification() = with(context) { + if (ActivityCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS, + ) != PackageManager.PERMISSION_GRANTED + ) { + return + } + } +} diff --git a/core/notifications/src/main/java/com/github/andiim/plantscan/core/notifications/di/NotifHolderModule.kt b/core/notifications/src/main/java/com/github/andiim/plantscan/core/notifications/di/NotifHolderModule.kt new file mode 100644 index 00000000..8fa02549 --- /dev/null +++ b/core/notifications/src/main/java/com/github/andiim/plantscan/core/notifications/di/NotifHolderModule.kt @@ -0,0 +1,98 @@ +package com.github.andiim.plantscan.core.notifications.di + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.github.andiim.core.notifications.R +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Qualifier +import javax.inject.Singleton + +private const val MAIN_NOTIFICATION_CHANNEL_NAME = "Verbose WorkManager Notifications" +private const val MAIN_NOTIFICATION_CHANNEL_DESCRIPTION = "Shows notification whenever work starts" +private const val NOTIFICATION_TITLE = "WorkRequest Starting" +private const val SECOND_NOTIFICATION_CHANNEL_NAME = "Secondary WorkManager Notifications" +private const val MAIN_CHANNEL_ID = "VERBOSE_NOTIFICATION" +private const val SECOND_CHANNEL_ID = "SECONDARY_NOTIFICATION" + +@Module +@InstallIn(SingletonComponent::class) +object NotificationModule { + @Singleton + @Provides + @MainNotificationCompatBuilder + fun provideNotificationBuilder( + @ApplicationContext context: Context, + ): NotificationCompat.Builder { + /*val intent = Intent(context, MyReceiver::class.java).apply { + putExtra("MESSAGE", "Clicked!") + } + + val flag = PendingIntent.FLAG_IMMUTABLE + val pendingIntent = PendingIntent.getBroadcast( + context, 0, intent, flag + )*/ + + return NotificationCompat.Builder(context, MAIN_CHANNEL_ID) + .setContentTitle(NOTIFICATION_TITLE) + .setContentText(MAIN_NOTIFICATION_CHANNEL_DESCRIPTION) + .setSmallIcon(R.drawable.icon_base) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setPublicVersion( + NotificationCompat.Builder(context, MAIN_CHANNEL_ID) + .setContentTitle(NOTIFICATION_TITLE) + .setContentText(MAIN_NOTIFICATION_CHANNEL_DESCRIPTION) + .build(), + ) + // addAction(0, "ACTION", pendingIntent) + // setContentIntent(clickPendingIntent) + } + + @Singleton + @Provides + @SecondNotificationCompatBuilder + fun provideNotificationCompatBuilder( + @ApplicationContext context: Context, + ): NotificationCompat.Builder { + return NotificationCompat.Builder(context, SECOND_CHANNEL_ID) + .setSmallIcon(R.drawable.icon_base) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setOngoing(true) + } + + @Singleton + @Provides + fun provideNotificationManager( + @ApplicationContext context: Context, + ): NotificationManagerCompat { + val notificationManager = NotificationManagerCompat.from(context) + val channel1 = NotificationChannel( + MAIN_CHANNEL_ID, + MAIN_NOTIFICATION_CHANNEL_NAME, + NotificationManager.IMPORTANCE_DEFAULT, + ) + val channel2 = NotificationChannel( + SECOND_CHANNEL_ID, + SECOND_NOTIFICATION_CHANNEL_NAME, + NotificationManager.IMPORTANCE_DEFAULT, + ) + notificationManager.createNotificationChannel(channel1) + notificationManager.createNotificationChannel(channel2) + return notificationManager + } +} + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class MainNotificationCompatBuilder + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class SecondNotificationCompatBuilder diff --git a/core/notifications/src/main/res/drawable/icon_base.xml b/core/notifications/src/main/res/drawable/icon_base.xml new file mode 100644 index 00000000..67b975ab --- /dev/null +++ b/core/notifications/src/main/res/drawable/icon_base.xml @@ -0,0 +1,1193 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/core/notifications/src/prod/java/com/github/andiim/plantscan/core/notifications/NotificationsModule.kt b/core/notifications/src/prod/java/com/github/andiim/plantscan/core/notifications/NotificationsModule.kt new file mode 100644 index 00000000..871dfe3f --- /dev/null +++ b/core/notifications/src/prod/java/com/github/andiim/plantscan/core/notifications/NotificationsModule.kt @@ -0,0 +1,15 @@ +package com.github.andiim.plantscan.core.notifications + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +abstract class NotificationsModule { + @Binds + abstract fun bindNotifier( + notifier: SystemTrayNotifier, + ): Notifier +} diff --git a/core/storage-upload/.gitignore b/core/storage-upload/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/storage-upload/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/storage-upload/build.gradle.kts b/core/storage-upload/build.gradle.kts new file mode 100644 index 00000000..589fa985 --- /dev/null +++ b/core/storage-upload/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.android.library.jacoco) + alias(libs.plugins.android.hilt) + alias(libs.plugins.secrets) +} + +android { + buildFeatures { + buildConfig = true + } + namespace = "com.github.andiim.plantscan.core.storageUpload" +} + +secrets { + defaultPropertiesFileName = "secrets.defaults.properties" +} + +dependencies { + implementation(project(":core:common")) + implementation(project(":core:notifications")) + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.storage) + implementation(libs.kotlin.coroutines.android) +} diff --git a/core/storage-upload/src/demo/java/com/github/andiim/plantscan/core/storageUpload/StorageModule.kt b/core/storage-upload/src/demo/java/com/github/andiim/plantscan/core/storageUpload/StorageModule.kt new file mode 100644 index 00000000..1655600b --- /dev/null +++ b/core/storage-upload/src/demo/java/com/github/andiim/plantscan/core/storageUpload/StorageModule.kt @@ -0,0 +1,18 @@ +package com.github.andiim.plantscan.core.storageUpload + +import com.google.firebase.ktx.Firebase +import com.google.firebase.storage.FirebaseStorage +import com.google.firebase.storage.ktx.storage +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class StorageModule { + @Binds + abstract fun bindsStorageHelper(storageHelper: StubStorageHelper): StorageHelper +} diff --git a/core/storage-upload/src/main/AndroidManifest.xml b/core/storage-upload/src/main/AndroidManifest.xml new file mode 100644 index 00000000..1818d8cd --- /dev/null +++ b/core/storage-upload/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/core/storage-upload/src/main/java/com/github/andiim/plantscan/core/storageUpload/StorageHelper.kt b/core/storage-upload/src/main/java/com/github/andiim/plantscan/core/storageUpload/StorageHelper.kt new file mode 100644 index 00000000..a037aa6f --- /dev/null +++ b/core/storage-upload/src/main/java/com/github/andiim/plantscan/core/storageUpload/StorageHelper.kt @@ -0,0 +1,12 @@ +package com.github.andiim.plantscan.core.storageUpload + +import android.graphics.Bitmap +import kotlinx.coroutines.flow.Flow + +interface StorageHelper { + fun upload( + image: Bitmap, + baseLocation: String, + onProgress: ((Double) -> Double)? = null, + ): Flow +} diff --git a/core/storage-upload/src/main/java/com/github/andiim/plantscan/core/storageUpload/StubStorageHelper.kt b/core/storage-upload/src/main/java/com/github/andiim/plantscan/core/storageUpload/StubStorageHelper.kt new file mode 100644 index 00000000..b34decc1 --- /dev/null +++ b/core/storage-upload/src/main/java/com/github/andiim/plantscan/core/storageUpload/StubStorageHelper.kt @@ -0,0 +1,16 @@ +package com.github.andiim.plantscan.core.storageUpload + +import android.graphics.Bitmap +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import javax.inject.Inject + +class StubStorageHelper @Inject constructor() : StorageHelper { + override fun upload( + image: Bitmap, + baseLocation: String, + onProgress: ((Double) -> Double)?, + ): Flow { + return flowOf("") + } +} diff --git a/core/storage-upload/src/prod/java/com/github/andiim/plantscan/core/storageUpload/FirebaseStorageHelper.kt b/core/storage-upload/src/prod/java/com/github/andiim/plantscan/core/storageUpload/FirebaseStorageHelper.kt new file mode 100644 index 00000000..3e42ee19 --- /dev/null +++ b/core/storage-upload/src/prod/java/com/github/andiim/plantscan/core/storageUpload/FirebaseStorageHelper.kt @@ -0,0 +1,29 @@ +package com.github.andiim.plantscan.core.storageUpload + +import android.graphics.Bitmap +import com.google.firebase.storage.FirebaseStorage +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.tasks.await +import java.io.ByteArrayOutputStream +import javax.inject.Inject + +class FirebaseStorageHelper @Inject constructor( + private val storage: FirebaseStorage, +) : StorageHelper { + companion object { + private const val COMPRESS_QUALITY = 100 + } + override fun upload( + image: Bitmap, + baseLocation: String, + onProgress: ((Double) -> Double)?, + ): Flow = flow { + val ref = storage.reference.child("$baseLocation.jpg") // refers to base + val outputStream = ByteArrayOutputStream() + image.compress(Bitmap.CompressFormat.JPEG, COMPRESS_QUALITY, outputStream) + val data = outputStream.toByteArray() + val downloadUrl = ref.putBytes(data).await().storage.downloadUrl.await() + emit(downloadUrl.toString()) + } +} diff --git a/core/storage-upload/src/prod/java/com/github/andiim/plantscan/core/storageUpload/StorageModule.kt b/core/storage-upload/src/prod/java/com/github/andiim/plantscan/core/storageUpload/StorageModule.kt new file mode 100644 index 00000000..9ec7cc9d --- /dev/null +++ b/core/storage-upload/src/prod/java/com/github/andiim/plantscan/core/storageUpload/StorageModule.kt @@ -0,0 +1,33 @@ +package com.github.andiim.plantscan.core.storageUpload + +import com.google.firebase.ktx.Firebase +import com.google.firebase.storage.FirebaseStorage +import com.google.firebase.storage.ktx.storage +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class StorageModule { + @Binds + abstract fun bindsStorageHelper( + storageHelperImpl: FirebaseStorageHelper, + ): StorageHelper + + companion object { + private const val HOST = "10.0.2.2" + private const val PORT = 9199 + + @Provides + @Singleton + fun provideFirebaseStorage(): FirebaseStorage = Firebase.storage.also { + if (BuildConfig.USE_EMULTAOR.toBoolean()) { + it.useEmulator(HOST, PORT) + } + } + } +} diff --git a/core/testing/.gitignore b/core/testing/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/testing/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/testing/README.md b/core/testing/README.md new file mode 100644 index 00000000..981ef843 --- /dev/null +++ b/core/testing/README.md @@ -0,0 +1 @@ +# :core:testing module \ No newline at end of file diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts new file mode 100644 index 00000000..377a89da --- /dev/null +++ b/core/testing/build.gradle.kts @@ -0,0 +1,32 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.android.library.compose) + alias(libs.plugins.android.hilt) +} + +android { + namespace = "com.github.andiim.plantscan.core.testing" +} + +dependencies { + api(libs.compose.ui.test) + api(libs.androidx.test.core) + api(libs.espresso.core) + api(libs.androidx.test.rules) + api(libs.androidx.test.runner) + api(libs.hilt.android) + api(libs.dagger.hilt.testing) + api(libs.junit) + api(libs.kotlin.coroutines.test) + api(libs.kotlin.coroutines.test.turbine) + + debugApi(libs.compose.ui.test.manifest) + + implementation(project(":core:common")) + implementation(project(":core:data")) + implementation(project(":core:domain")) + implementation(project(":core:model")) + implementation(project(":core:notifications")) + implementation(project(":core:analytics")) + implementation(libs.kotlinx.datetime) +} \ No newline at end of file diff --git a/core/testing/src/main/AndroidManifest.xml b/core/testing/src/main/AndroidManifest.xml new file mode 100644 index 00000000..568741e5 --- /dev/null +++ b/core/testing/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/core/testing/src/main/java/com/github/andiim/plantscan/core/rules/GrantPostNotificationsPermissionRule.kt b/core/testing/src/main/java/com/github/andiim/plantscan/core/rules/GrantPostNotificationsPermissionRule.kt new file mode 100644 index 00000000..8a2e2ed3 --- /dev/null +++ b/core/testing/src/main/java/com/github/andiim/plantscan/core/rules/GrantPostNotificationsPermissionRule.kt @@ -0,0 +1,13 @@ +package com.github.andiim.plantscan.core.rules + +import android.Manifest.permission.POST_NOTIFICATIONS +import android.os.Build.VERSION.SDK_INT +import android.os.Build.VERSION_CODES.TIRAMISU +import androidx.test.rule.GrantPermissionRule.grant +import org.junit.rules.TestRule + +/** + * [TestRule] granting [POST_NOTIFICATIONS] permission if running on [SDK_INT] greater than [TIRAMISU]. + */ +class GrantPostNotificationsPermissionRule : + TestRule by if (SDK_INT >= TIRAMISU) grant(POST_NOTIFICATIONS) else grant() diff --git a/core/testing/src/main/java/com/github/andiim/plantscan/core/testing/PsAppTestRunner.kt b/core/testing/src/main/java/com/github/andiim/plantscan/core/testing/PsAppTestRunner.kt new file mode 100644 index 00000000..9359cad8 --- /dev/null +++ b/core/testing/src/main/java/com/github/andiim/plantscan/core/testing/PsAppTestRunner.kt @@ -0,0 +1,19 @@ +package com.github.andiim.plantscan.core.testing + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import dagger.hilt.android.testing.HiltTestApplication + +/** + * A custom runner to set up the instrumented application class for tests. + */ +class PsAppTestRunner : AndroidJUnitRunner() { + override fun newApplication( + cl: ClassLoader?, + className: String?, + context: Context? + ): Application { + return super.newApplication(cl, HiltTestApplication::class.java.name, context) + } +} diff --git a/core/testing/src/main/java/com/github/andiim/plantscan/core/testing/data/PlantTestData.kt b/core/testing/src/main/java/com/github/andiim/plantscan/core/testing/data/PlantTestData.kt new file mode 100644 index 00000000..3c67a6f8 --- /dev/null +++ b/core/testing/src/main/java/com/github/andiim/plantscan/core/testing/data/PlantTestData.kt @@ -0,0 +1,16 @@ +package com.github.andiim.plantscan.core.testing.data + +import com.github.andiim.plantscan.core.model.data.Plant + +/* ktlint-disable max-line-length*/ +val plantTestData: List = listOf( + Plant( + id = "", + name = "", + species = "", + description = "", + thumbnail = "", + commonName = listOf(), + images = listOf() + ) +) diff --git a/core/testing/src/main/java/com/github/andiim/plantscan/core/testing/di/TestDispatcherModule.kt b/core/testing/src/main/java/com/github/andiim/plantscan/core/testing/di/TestDispatcherModule.kt new file mode 100644 index 00000000..1d38d0f8 --- /dev/null +++ b/core/testing/src/main/java/com/github/andiim/plantscan/core/testing/di/TestDispatcherModule.kt @@ -0,0 +1,17 @@ +package com.github.andiim.plantscan.core.testing.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object TestDispatcherModule { + @Provides + @Singleton + fun providesTestDispatcher(): TestDispatcher = UnconfinedTestDispatcher() +} diff --git a/core/testing/src/main/java/com/github/andiim/plantscan/core/testing/di/TestDispatchersModule.kt b/core/testing/src/main/java/com/github/andiim/plantscan/core/testing/di/TestDispatchersModule.kt new file mode 100644 index 00000000..df8afb1f --- /dev/null +++ b/core/testing/src/main/java/com/github/andiim/plantscan/core/testing/di/TestDispatchersModule.kt @@ -0,0 +1,29 @@ +package com.github.andiim.plantscan.core.testing.di + +import com.github.andiim.plantscan.core.network.AppDispatchers.Default +import com.github.andiim.plantscan.core.network.AppDispatchers.IO +import com.github.andiim.plantscan.core.network.Dispatcher +import com.github.andiim.plantscan.core.network.di.DispatchersModule +import dagger.Module +import dagger.Provides +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.test.TestDispatcher + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [DispatchersModule::class], +) +object TestDispatchersModule { + @Provides + @Dispatcher(IO) + fun providesIODispatcher(testDispatcher: TestDispatcher): CoroutineDispatcher = testDispatcher + + @Provides + @Dispatcher(Default) + fun providesDefaultDispatcher( + testDispatcher: TestDispatcher, + ): CoroutineDispatcher = testDispatcher +} diff --git a/core/testing/src/main/java/com/github/andiim/plantscan/core/testing/repository/TestUserDataRepository.kt b/core/testing/src/main/java/com/github/andiim/plantscan/core/testing/repository/TestUserDataRepository.kt new file mode 100644 index 00000000..33ce7a07 --- /dev/null +++ b/core/testing/src/main/java/com/github/andiim/plantscan/core/testing/repository/TestUserDataRepository.kt @@ -0,0 +1,57 @@ +package com.github.andiim.plantscan.core.testing.repository + +import com.github.andiim.plantscan.core.data.repository.UserDataRepository +import com.github.andiim.plantscan.core.model.data.DarkThemeConfig +import com.github.andiim.plantscan.core.model.data.UserData +import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.filterNotNull + +val emptyUserData = UserData( + isLogin = false, + userId = "", + darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM, + useDynamicColor = false, + shouldHideOnboarding = false, +) + +class TestUserDataRepository : UserDataRepository { + /** + * The backing hot flow for testing. + */ + private val _userData = MutableSharedFlow(replay = 1, onBufferOverflow = DROP_OLDEST) + private val currentUserData get() = _userData.replayCache.firstOrNull() ?: emptyUserData + override val userData: Flow = _userData.filterNotNull() + + override suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) { + currentUserData.let { current -> + _userData.tryEmit(current.copy(shouldHideOnboarding = shouldHideOnboarding)) + } + } + + override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) { + currentUserData.let { current -> + _userData.tryEmit(current.copy(darkThemeConfig = darkThemeConfig)) + } + } + + override suspend fun setDynamicColorPreference(useDynamicColor: Boolean) { + currentUserData.let { current -> + _userData.tryEmit(current.copy(useDynamicColor = useDynamicColor)) + } + } + + override suspend fun setLoginInfo(userId: String, isAnonymous: Boolean) { + currentUserData.let { current -> + _userData.tryEmit(current.copy(userId = userId, isLogin = !isAnonymous)) + } + } + + /** + * A test-only API to allow setting of user data directly. + */ + fun setUserData(userData: UserData) { + _userData.tryEmit(userData) + } +} diff --git a/core/testing/src/main/java/com/github/andiim/plantscan/core/testing/util/MainDispatcherRule.kt b/core/testing/src/main/java/com/github/andiim/plantscan/core/testing/util/MainDispatcherRule.kt new file mode 100644 index 00000000..e868a490 --- /dev/null +++ b/core/testing/src/main/java/com/github/andiim/plantscan/core/testing/util/MainDispatcherRule.kt @@ -0,0 +1,26 @@ +package com.github.andiim.plantscan.core.testing.util + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestRule +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +/** + * A JUnit [TestRule] that sets the Main dispatcher to [testDispatcher] + * for the duration of the test. + */ +class MainDispatcherRule( + val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(), +) : TestWatcher() { + override fun starting(description: Description) { + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} diff --git a/core/testing/src/main/java/com/github/andiim/plantscan/core/testing/util/TestAnalyticsHelper.kt b/core/testing/src/main/java/com/github/andiim/plantscan/core/testing/util/TestAnalyticsHelper.kt new file mode 100644 index 00000000..e034ba1a --- /dev/null +++ b/core/testing/src/main/java/com/github/andiim/plantscan/core/testing/util/TestAnalyticsHelper.kt @@ -0,0 +1,13 @@ +package com.github.andiim.plantscan.core.testing.util + +import com.github.andiim.plantscan.core.analytics.AnalyticsEvent +import com.github.andiim.plantscan.core.analytics.AnalyticsHelper + +class TestAnalyticsHelper : AnalyticsHelper { + private val events = mutableListOf() + override fun logEvent(event: AnalyticsEvent) { + events.add(event) + } + + fun hasLogged(event: AnalyticsEvent) = events.contains(event) +} diff --git a/core/testing/src/main/java/com/github/andiim/plantscan/core/testing/util/TestNetworkMonitor.kt b/core/testing/src/main/java/com/github/andiim/plantscan/core/testing/util/TestNetworkMonitor.kt new file mode 100644 index 00000000..70fa9330 --- /dev/null +++ b/core/testing/src/main/java/com/github/andiim/plantscan/core/testing/util/TestNetworkMonitor.kt @@ -0,0 +1,18 @@ +package com.github.andiim.plantscan.core.testing.util + +import com.github.andiim.plantscan.core.data.util.NetworkMonitor +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +// NETWORK MONITOR +class TestNetworkMonitor : NetworkMonitor { + private val connectivityFlow = MutableStateFlow(true) + override val isOnline: Flow = connectivityFlow + + /** + * A test-only API to set the connectivity state from tests. + */ + fun setConnected(isConnected: Boolean) { + connectivityFlow.value = isConnected + } +} diff --git a/core/ui/.gitignore b/core/ui/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/ui/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/ui/README.md b/core/ui/README.md new file mode 100644 index 00000000..6b84baf2 --- /dev/null +++ b/core/ui/README.md @@ -0,0 +1 @@ +# :core:ui module \ No newline at end of file diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts new file mode 100644 index 00000000..13da52f7 --- /dev/null +++ b/core/ui/build.gradle.kts @@ -0,0 +1,40 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.android.library.compose) + alias(libs.plugins.android.library.jacoco) +} + +android { + defaultConfig { + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + namespace = "com.github.andiim.plantscan.core.ui" +} + +dependencies { + api(libs.compose.foundation) + api(libs.compose.foundation.layout) + api(libs.compose.material) + api(libs.compose.materialIcons) + api(libs.compose.runtime) + api(libs.compose.runtimeLivedata) + api(libs.compose.ui.tooling.preview) + api(libs.compose.ui.util) + api(libs.metrics.performance) + api(libs.androidx.tracing.ktx) + api(libs.navigation.ui) + + debugApi(libs.compose.ui.tooling) + + implementation(project(":core:analytics")) + implementation(project(":core:designsystem")) + implementation(project(":core:domain")) + implementation(project(":core:model")) + implementation(libs.androidx.browser) + implementation(libs.androidx.core.ktx) + implementation(libs.coil) + implementation(libs.coil.compose) + implementation(libs.kotlinx.datetime) + + androidTestImplementation(project(":core:testing")) +} \ No newline at end of file diff --git a/core/ui/src/androidTest/java/com/github/andiim/ui/ExampleInstrumentedTest.kt b/core/ui/src/androidTest/java/com/github/andiim/ui/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..fe8e96cd --- /dev/null +++ b/core/ui/src/androidTest/java/com/github/andiim/ui/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.github.andiim.ui + +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.github.andiim.ui.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/core/ui/src/main/AndroidManifest.xml b/core/ui/src/main/AndroidManifest.xml new file mode 100644 index 00000000..568741e5 --- /dev/null +++ b/core/ui/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/core/ui/src/main/java/com/github/andiim/plantscan/core/ui/AnalyticsExtensions.kt b/core/ui/src/main/java/com/github/andiim/plantscan/core/ui/AnalyticsExtensions.kt new file mode 100644 index 00000000..33a6724e --- /dev/null +++ b/core/ui/src/main/java/com/github/andiim/plantscan/core/ui/AnalyticsExtensions.kt @@ -0,0 +1,41 @@ +package com.github.andiim.plantscan.core.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import com.github.andiim.plantscan.core.analytics.AnalyticsEvent +import com.github.andiim.plantscan.core.analytics.AnalyticsEvent.Param +import com.github.andiim.plantscan.core.analytics.AnalyticsEvent.ParamKeys +import com.github.andiim.plantscan.core.analytics.AnalyticsEvent.Types +import com.github.andiim.plantscan.core.analytics.AnalyticsHelper +import com.github.andiim.plantscan.core.analytics.LocalAnalyticsHelper + +fun AnalyticsHelper.logScreenView(screenName: String) { + logEvent( + AnalyticsEvent( + type = Types.SCREEN_VIEW, + extras = listOf( + Param(ParamKeys.SCREEN_NAME, screenName), + ), + ), + ) +} + +fun AnalyticsHelper.logPlantOpened(plantId: String) { + logEvent( + event = AnalyticsEvent( + type = "plant_opened", + extras = listOf( + Param("opened_plant", plantId), + ), + ), + ) +} + +@Composable +fun TrackScreenViewEvent( + screenName: String, + analyticsHelper: AnalyticsHelper = LocalAnalyticsHelper.current, +) = DisposableEffect(Unit) { + analyticsHelper.logScreenView(screenName) + onDispose { } +} diff --git a/core/ui/src/main/java/com/github/andiim/plantscan/core/ui/DevicePreviews.kt b/core/ui/src/main/java/com/github/andiim/plantscan/core/ui/DevicePreviews.kt new file mode 100644 index 00000000..fe28093e --- /dev/null +++ b/core/ui/src/main/java/com/github/andiim/plantscan/core/ui/DevicePreviews.kt @@ -0,0 +1,13 @@ +package com.github.andiim.plantscan.core.ui + +import androidx.compose.ui.tooling.preview.Preview + +/** + * Multipreview annotation that represents various device sizes. Add this annotation to a composable + * to render various devices. + */ +@Preview(name = "phone", device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480") +@Preview(name = "landscape", device = "spec:shape=Normal,width=640,height=360,unit=dp,dpi=480") +@Preview(name = "foldable", device = "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480") +@Preview(name = "tablet", device = "spec:shape=Normal,width=1280,height=800,unit=dp,dpi=480") +annotation class DevicePreviews diff --git a/core/ui/src/main/java/com/github/andiim/plantscan/core/ui/JankStatsExtensions.kt b/core/ui/src/main/java/com/github/andiim/plantscan/core/ui/JankStatsExtensions.kt new file mode 100644 index 00000000..d213fa4c --- /dev/null +++ b/core/ui/src/main/java/com/github/andiim/plantscan/core/ui/JankStatsExtensions.kt @@ -0,0 +1,77 @@ +package com.github.andiim.plantscan.core.ui + +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.DisposableEffectResult +import androidx.compose.runtime.DisposableEffectScope +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.platform.LocalView +import androidx.metrics.performance.PerformanceMetricsState +import androidx.metrics.performance.PerformanceMetricsState.Holder +import kotlinx.coroutines.CoroutineScope + +/** + * Retrieves [PerformanceMetricsState.Holder] from current [LocalView] and + * remembers it until the View changes. + * @see PerformanceMetricsState.getHolderForHierarchy + */ +@Composable +fun rememberMetricsStateHolder(): Holder { + val localView = LocalView.current + + return remember(localView) { + PerformanceMetricsState.getHolderForHierarchy(localView) + } +} + +/** + * Convenience function to work with [PerformanceMetricsState] state. The side effect is + * re-launched if any of the [keys] value is not equal to the previous composition. + * @see TrackDisposableJank if you need to work with DisposableEffect to cleanup added state. + */ +@Composable +fun TrackJank( + vararg keys: Any?, + reportMetric: suspend CoroutineScope.(state: Holder) -> Unit, +) { + val metrics = rememberMetricsStateHolder() + LaunchedEffect(metrics, *keys) { + reportMetric(metrics) + } +} + +/** + * Convenience function to work with [PerformanceMetricsState] state that needs to be cleaned up. + * The side effect is re-launched if any of the [keys] value is not equal to the previous composition. + */ +@Composable +fun TrackDisposableJank( + vararg keys: Any?, + reportMetric: DisposableEffectScope.(state: Holder) -> DisposableEffectResult, +) { + val metrics = rememberMetricsStateHolder() + DisposableEffect(metrics, *keys) { + reportMetric(this, metrics) + } +} + +/** + * Track jank while scrolling anything that's scrollable. + */ +@Composable +fun TrackScrollJank(scrollableState: ScrollableState, stateName: String) { + TrackJank(scrollableState) { metricsHolder -> + snapshotFlow { scrollableState.isScrollInProgress }.collect { isScrollInProgress -> + metricsHolder.state?.apply { + if (isScrollInProgress) { + putState(stateName, "Scrolling=true") + } else { + removeState(stateName) + } + } + } + } +} diff --git a/core/ui/src/main/java/com/github/andiim/plantscan/core/ui/PlantCard.kt b/core/ui/src/main/java/com/github/andiim/plantscan/core/ui/PlantCard.kt new file mode 100644 index 00000000..ec7ff616 --- /dev/null +++ b/core/ui/src/main/java/com/github/andiim/plantscan/core/ui/PlantCard.kt @@ -0,0 +1,156 @@ +package com.github.andiim.plantscan.core.ui + +import androidx.compose.foundation.Image +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.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +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.layout.ContentScale +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImagePainter +import coil.compose.rememberAsyncImagePainter +import com.github.andiim.plantscan.core.designsystem.R.drawable +import com.github.andiim.plantscan.core.designsystem.theme.PsTheme +import com.github.andiim.plantscan.core.model.data.Plant + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PlantCard( + plant: Plant, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val clickActionLabel = stringResource(R.string.car_tap_action) + val plantKnownName = "${plant.species}, ${plant.commonName.joinToString()}" + Card( + onClick = onClick, + shape = RoundedCornerShape(4.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + modifier = modifier.semantics { + onClick(label = clickActionLabel, action = null) + }, + ) { + Row { + PlantCardImage( + plant.thumbnail, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.size(8.dp)) + PlantCardContent( + name = plant.name, + knownName = plantKnownName, + modifier = Modifier.weight(2f), + ) + } + } +} + +@Composable +fun PlantCardImage( + imageUrl: String?, + modifier: Modifier = Modifier, +) { + var isLoading by remember { mutableStateOf(true) } + var isError by remember { mutableStateOf(false) } + val imageLoader = rememberAsyncImagePainter( + model = imageUrl, + onState = { state -> + isLoading = state is AsyncImagePainter.State.Loading + isError = state is AsyncImagePainter.State.Error + }, + ) + val isLocalInspection = LocalInspectionMode.current + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + if (isLoading) { + // Display a progress bar while loading + CircularProgressIndicator( + modifier = Modifier + .align(Alignment.Center) + .size(80.dp), + color = MaterialTheme.colorScheme.tertiary, + ) + } + Image( + modifier = Modifier + .fillMaxWidth() + .height(100.dp), + contentScale = ContentScale.Crop, + painter = if (isError.not() && isLocalInspection.not()) { + imageLoader + } else { + painterResource(drawable.orchid) + }, + contentDescription = null, // decorative image + ) + } +} + +@Composable +fun PlantCardContent( + name: String, + knownName: String, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier, verticalArrangement = Arrangement.Center) { + Text( + name, + style = MaterialTheme.typography.titleLarge, + ) + Text( + knownName, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.outline, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Preview("Plant Card") +@Composable +private fun Preview( + @PreviewParameter(PlantPreviewParameterProvider::class) plants: List, +) { + CompositionLocalProvider( + LocalInspectionMode provides true, + ) { + PsTheme { + Surface { + PlantCard( + plant = plants[0], + onClick = {}, + ) + } + } + } +} diff --git a/core/ui/src/main/java/com/github/andiim/plantscan/core/ui/PlantCardList.kt b/core/ui/src/main/java/com/github/andiim/plantscan/core/ui/PlantCardList.kt new file mode 100644 index 00000000..51b20e02 --- /dev/null +++ b/core/ui/src/main/java/com/github/andiim/plantscan/core/ui/PlantCardList.kt @@ -0,0 +1,30 @@ +package com.github.andiim.plantscan.core.ui + +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.ui.Modifier +import com.github.andiim.plantscan.core.analytics.LocalAnalyticsHelper +import com.github.andiim.plantscan.core.model.data.Plant + +/** + * Extension function for displaying a [List] of [PlantCard] backed by a list of [Plant]s. + */ +fun LazyListScope.plantCardItems( + items: List, + onClick: (String) -> Unit, + itemModifier: Modifier = Modifier, +) = items( + items = items, + key = { it.id }, + itemContent = { plant -> + val analyticsHelper = LocalAnalyticsHelper.current + PlantCard( + plant = plant, + onClick = { + analyticsHelper.logPlantOpened(plant.id) + onClick.invoke(plant.id) + }, + modifier = itemModifier, + ) + }, +) diff --git a/core/ui/src/main/java/com/github/andiim/plantscan/core/ui/PlantPreviewParameterProvider.kt b/core/ui/src/main/java/com/github/andiim/plantscan/core/ui/PlantPreviewParameterProvider.kt new file mode 100644 index 00000000..94ad50a5 --- /dev/null +++ b/core/ui/src/main/java/com/github/andiim/plantscan/core/ui/PlantPreviewParameterProvider.kt @@ -0,0 +1,34 @@ +package com.github.andiim.plantscan.core.ui + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.github.andiim.plantscan.core.model.data.Plant +import com.github.andiim.plantscan.core.model.data.PlantImage +import com.github.andiim.plantscan.core.ui.PreviewParameterData.plants + +class PlantPreviewParameterProvider : PreviewParameterProvider> { + override val values: Sequence> = sequenceOf(plants) +} + +object PreviewParameterData { + @Suppress("detekt:MaxLineLength") + val plants = listOf( + Plant( + id = "1", + name = "moth orchid", + species = "Phalaenopsis", + description = "a genus of about seventy species of plants in the family Orchidaceae. Orchids in this genus are monopodial epiphytes or lithophytes with long, coarse roots, short, leafy stems and long-lasting, flat flowers arranged in a flowering stem that often branches near the end. Orchids in this genus are native to India, Taiwan, China, Southeast Asia, New Guinea and Australia with the majority in Indonesia and the Philippines.", + thumbnail = "https://www.houseplantsexpert.com/wp-content/uploads/2022/09/phalaenopsis_pink.jpg", + commonName = listOf( + "Phal", + ), + images = listOf( + PlantImage( + id = "1", + url = "https://www.houseplantsexpert.com/wp-content/uploads/2022/09/phalaenopsis2.jpg", + attribution = "houseplantsexpert", + description = "flower", + ), + ), + ), + ) +} diff --git a/core/ui/src/main/java/com/github/andiim/plantscan/core/ui/TimeZoneBroadcastReceiver.kt b/core/ui/src/main/java/com/github/andiim/plantscan/core/ui/TimeZoneBroadcastReceiver.kt new file mode 100644 index 00000000..693f2386 --- /dev/null +++ b/core/ui/src/main/java/com/github/andiim/plantscan/core/ui/TimeZoneBroadcastReceiver.kt @@ -0,0 +1,33 @@ +package com.github.andiim.plantscan.core.ui + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter + +class TimeZoneBroadcastReceiver( + val onTimeZoneChanged: () -> Unit, +) : BroadcastReceiver() { + private var registered = false + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == Intent.ACTION_TIMEZONE_CHANGED) { + onTimeZoneChanged() + } + } + + fun register(context: Context) { + if (!registered) { + val filter = IntentFilter() + filter.addAction(Intent.ACTION_TIMEZONE_CHANGED) + context.registerReceiver(this, filter) + registered = true + } + } + + fun unregister(context: Context) { + if (registered) { + context.unregisterReceiver(this) + registered = false + } + } +} diff --git a/core/ui/src/main/java/com/github/andiim/plantscan/core/ui/components/DetectImage.kt b/core/ui/src/main/java/com/github/andiim/plantscan/core/ui/components/DetectImage.kt new file mode 100644 index 00000000..7a082e5f --- /dev/null +++ b/core/ui/src/main/java/com/github/andiim/plantscan/core/ui/components/DetectImage.kt @@ -0,0 +1,84 @@ +package com.github.andiim.plantscan.core.ui.components + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Dangerous +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import coil.compose.SubcomposeAsyncImage +import coil.request.ImageRequest +import com.github.andiim.plantscan.core.designsystem.R.drawable as dsDrawable +import com.github.andiim.plantscan.core.ui.R.string as uiString + +@Composable +fun DetectImage(imageData: Any, onImageClick: () -> Unit) { + val model = ImageRequest.Builder(LocalContext.current) + .data(imageData) + .crossfade(true).build() + DetectImage(model, onImageClick) +} + +@Composable +private fun DetectImage( + model: ImageRequest, + onImageClick: () -> Unit, +) { + SubcomposeAsyncImage( + model = model, + loading = { + if (LocalInspectionMode.current) { + ImageInspection() + } else { + Box(modifier = Modifier.height(30.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator(modifier = Modifier.width(24.dp)) + } + } + }, + error = { + Box(modifier = Modifier.height(30.dp), contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.Dangerous, + contentDescription = stringResource(uiString.fetch_image_error), + ) + } + }, + contentDescription = "image", + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .height(300.dp) + .animateContentSize() + .clickable { onImageClick() }, + ) +} + +@Composable +fun ImageInspection() { + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(dsDrawable.orchid), + contentScale = ContentScale.FillHeight, + contentDescription = null, + ) + } +} diff --git a/core/ui/src/main/java/com/github/andiim/plantscan/core/ui/components/DetectResultImage.kt b/core/ui/src/main/java/com/github/andiim/plantscan/core/ui/components/DetectResultImage.kt new file mode 100644 index 00000000..52fb1f00 --- /dev/null +++ b/core/ui/src/main/java/com/github/andiim/plantscan/core/ui/components/DetectResultImage.kt @@ -0,0 +1,66 @@ +package com.github.andiim.plantscan.core.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsTopHeight +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImagePainter +import coil.compose.rememberAsyncImagePainter +import coil.request.ImageRequest +import com.github.andiim.plantscan.core.designsystem.icon.PsIcons + +@Composable +fun DetectResultImage(imageData: Any, onShowPreview: () -> Unit) { + val painter = rememberAsyncImagePainter( + ImageRequest.Builder(LocalContext.current).data(imageData).build(), + ) + DetectResultImage(painter = painter, onShowPreview = onShowPreview) +} + +@Composable +private fun DetectResultImage(painter: AsyncImagePainter, onShowPreview: () -> Unit) { + Box( + modifier = Modifier + .fillMaxSize() + .background(color = Color.Black), + ) { + Image( + painter = painter, + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxSize(), + ) + + Column(modifier = Modifier.align(Alignment.TopStart)) { + Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) + IconButton( + onClick = onShowPreview, + modifier = Modifier + .padding(16.dp) + .background( + color = MaterialTheme.colorScheme.surface, + shape = CircleShape, + ), + ) { + Icon(PsIcons.Close, null, tint = Color.Black) + } + } + } +} diff --git a/core/ui/src/main/java/com/github/andiim/plantscan/core/ui/components/SuggestButton.kt b/core/ui/src/main/java/com/github/andiim/plantscan/core/ui/components/SuggestButton.kt new file mode 100644 index 00000000..7a12bb9f --- /dev/null +++ b/core/ui/src/main/java/com/github/andiim/plantscan/core/ui/components/SuggestButton.kt @@ -0,0 +1,32 @@ +package com.github.andiim.plantscan.core.ui.components + +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import com.github.andiim.plantscan.core.ui.R.string as uiString + +@Composable +fun SuggestButton(onClick: () -> Unit) { + val context = LocalContext.current + + val annotatedText = buildAnnotatedString { + withStyle(style = SpanStyle()) { append(context.getString(uiString.suggestion_button_label)) } + append(" ") + withStyle( + style = + SpanStyle(color = (MaterialTheme.colorScheme).primary, fontWeight = FontWeight.Bold), + ) { + append(context.getString(uiString.suggestion_action_label)) + } + } + + ClickableText( + text = annotatedText, + onClick = { onClick() }, + ) +} diff --git a/core/ui/src/main/java/com/github/andiim/plantscan/core/ui/extensions/StringExtensions.kt b/core/ui/src/main/java/com/github/andiim/plantscan/core/ui/extensions/StringExtensions.kt new file mode 100644 index 00000000..fe8d7cd2 --- /dev/null +++ b/core/ui/src/main/java/com/github/andiim/plantscan/core/ui/extensions/StringExtensions.kt @@ -0,0 +1,38 @@ +package com.github.andiim.plantscan.core.ui.extensions + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import com.github.andiim.plantscan.core.ui.TimeZoneBroadcastReceiver +import kotlinx.datetime.Instant +import kotlinx.datetime.toJavaInstant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale + +@Composable +fun Instant.toFormattedDate(): String { + var zoneId by remember { mutableStateOf(ZoneId.systemDefault()) } + val context = LocalContext.current + + DisposableEffect(context) { + val receiver = TimeZoneBroadcastReceiver( + onTimeZoneChanged = { zoneId = ZoneId.systemDefault() }, + ) + receiver.register(context) + onDispose { + receiver.unregister(context) + } + } + + return DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.MEDIUM) + .withLocale(Locale.getDefault()) + .withZone(zoneId) + .format(this.toJavaInstant()) +} diff --git a/core/ui/src/main/java/com/github/andiim/plantscan/core/ui/navigation/AppDestination.kt b/core/ui/src/main/java/com/github/andiim/plantscan/core/ui/navigation/AppDestination.kt new file mode 100644 index 00000000..1b1d56a9 --- /dev/null +++ b/core/ui/src/main/java/com/github/andiim/plantscan/core/ui/navigation/AppDestination.kt @@ -0,0 +1,5 @@ +package com.github.andiim.plantscan.core.ui.navigation + +interface AppDestination { + val route: String +} diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml new file mode 100644 index 00000000..2f90315c --- /dev/null +++ b/core/ui/src/main/res/values/strings.xml @@ -0,0 +1,11 @@ + + + Open Resource Link + Back + + + Fetch image failed + %1$s • %2$s + Not sure for the result? + give a suggestion + \ No newline at end of file diff --git a/feature/account/.gitignore b/feature/account/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/account/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/account/README.md b/feature/account/README.md new file mode 100644 index 00000000..e69de29b diff --git a/feature/account/build.gradle.kts b/feature/account/build.gradle.kts new file mode 100644 index 00000000..d9e59be1 --- /dev/null +++ b/feature/account/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + alias(libs.plugins.android.feature) + alias(libs.plugins.android.library.compose) + alias(libs.plugins.android.library.jacoco) +} + +android { + namespace = "com.github.andiim.plantscan.feature.account" +} + +dependencies { + implementation(project(":core:auth")) +} \ No newline at end of file diff --git a/feature/account/src/main/AndroidManifest.xml b/feature/account/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/feature/account/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature/account/src/main/java/com/github/andiim/plantscan/feature/account/AuthFormUiState.kt b/feature/account/src/main/java/com/github/andiim/plantscan/feature/account/AuthFormUiState.kt new file mode 100644 index 00000000..5cc4ddcb --- /dev/null +++ b/feature/account/src/main/java/com/github/andiim/plantscan/feature/account/AuthFormUiState.kt @@ -0,0 +1,20 @@ +package com.github.andiim.plantscan.feature.account + +import androidx.annotation.StringRes + +data class AuthFormUiState( + val email: String = "", + val password: String = "", + val repeatPassword: String = "", + val error: String? = null, +) + +sealed interface AuthUiState { + data object Loading : AuthUiState + data class Error( + val message: String? = null, + @StringRes val resMessage: Int? = null, + ) : AuthUiState + + data object Success : AuthUiState +} diff --git a/feature/account/src/main/java/com/github/andiim/plantscan/feature/account/AuthScreen.kt b/feature/account/src/main/java/com/github/andiim/plantscan/feature/account/AuthScreen.kt new file mode 100644 index 00000000..ebd4c5e8 --- /dev/null +++ b/feature/account/src/main/java/com/github/andiim/plantscan/feature/account/AuthScreen.kt @@ -0,0 +1,369 @@ +package com.github.andiim.plantscan.feature.account + +import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedVisibility +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.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsTopHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarDuration +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.ParagraphStyle +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import com.github.andiim.plantscan.core.designsystem.component.PsBackground +import com.github.andiim.plantscan.core.designsystem.component.PsButton +import com.github.andiim.plantscan.core.designsystem.extensions.fieldModifier +import com.github.andiim.plantscan.core.designsystem.extensions.withSemantics +import com.github.andiim.plantscan.core.designsystem.icon.PsIcons +import com.github.andiim.plantscan.core.designsystem.theme.PsTheme +import com.github.andiim.plantscan.feature.account.components.EmailField +import com.github.andiim.plantscan.feature.account.components.PasswordField +import com.github.andiim.plantscan.feature.account.components.RepeatPasswordField +import kotlinx.coroutines.launch + +@Composable +fun AuthRoute( + onBackPressed: () -> Unit, + authCallback: () -> Unit, + onShowSnackbar: suspend (String, String?, SnackbarDuration?) -> Boolean, + viewModel: AuthViewModel = hiltViewModel(), +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + + suspend fun onShowSnackbar(message: String) { + onShowSnackbar(message, "Dismiss", SnackbarDuration.Short) + } + + suspend fun onShowSnackbar(@StringRes res: Int) { + onShowSnackbar(context.getString(res)) + } + + suspend fun onShowSnackbar(message: Any?) { + when (message) { + is Int -> onShowSnackbar(message) + is String -> onShowSnackbar(message) + else -> throw IllegalArgumentException("doesn't support this type!") + } + } + + AuthScreen( + uiState = viewModel.formUiState, + onLogin = { + viewModel.onSignInClick(authCallback) { + scope.launch { onShowSnackbar(it) } + } + }, + onSignUp = { + viewModel.onSignUpClick(authCallback) { + scope.launch { onShowSnackbar(it) } + } + }, + onBackPressed = onBackPressed, + onEmailChange = viewModel::onEmailChange, + onPasswordChange = viewModel::onPasswordChange, + onForgotPasswordClick = { + scope.launch { + onShowSnackbar(context.getString(viewModel.onForgotPasswordClick())) + } + }, + onRepeatPasswordChange = viewModel::onRepeatPasswordChange, + ) +} + +enum class AuthState { + SIGN_IN, SIGN_UP, FORGOT_PASSWORD +} + +@Suppress("detekt:LongMethod") +@Composable +fun AuthScreen( + uiState: AuthFormUiState, + onLogin: () -> Unit, + onSignUp: () -> Unit, + onBackPressed: () -> Unit, + onEmailChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onForgotPasswordClick: () -> Unit, + onRepeatPasswordChange: (String) -> Unit, +) { + var authState by remember { mutableStateOf(AuthState.SIGN_IN) } + val submitTextValue = when (authState) { + AuthState.SIGN_IN -> R.string.label_sign_in + AuthState.SIGN_UP -> R.string.label_create_account + AuthState.FORGOT_PASSWORD -> R.string.label_send_email_for_forgot_password + } + + Box(Modifier.padding(vertical = 24.dp)) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + EmailField( + value = uiState.email, + onNewValue = onEmailChange, + modifier = Modifier + .fieldModifier() + .withSemantics("Email Field"), + ) + + AnimatedVisibility(visible = authState != AuthState.FORGOT_PASSWORD) { + PasswordColumn( + password = uiState.password, + repeatPassword = uiState.repeatPassword, + state = authState, + onForgotPasswordClick = { authState = AuthState.FORGOT_PASSWORD }, + onPasswordChange = onPasswordChange, + onRepeatPasswordChange = onRepeatPasswordChange, + ) + } + + PsButton( + text = submitTextValue, + contentPadding = ButtonDefaults.TextButtonContentPadding, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + onClick = { + when (authState) { + AuthState.SIGN_IN -> onLogin() + AuthState.SIGN_UP -> onSignUp() + AuthState.FORGOT_PASSWORD -> { + onForgotPasswordClick() + authState = AuthState.SIGN_IN + } + } + }, + ) + ChangerButton( + state = authState, + onClick = { + authState = when (authState) { + AuthState.SIGN_IN, + AuthState.FORGOT_PASSWORD, + -> AuthState.SIGN_UP + + AuthState.SIGN_UP -> AuthState.SIGN_IN + } + }, + ) + } + Column( + modifier = Modifier.align(Alignment.TopStart), + horizontalAlignment = Alignment.Start, + ) { + Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) + IconButton( + onClick = onBackPressed, + modifier = Modifier + .padding(16.dp) + .background( + color = MaterialTheme.colorScheme.surface, + shape = CircleShape, + ), + ) { + Icon(PsIcons.Back, null, tint = Color.Black) + } + } + TermsAndPrivacyStatementText( + modifier = + Modifier + .align(Alignment.BottomCenter) + .withSemantics(stringResource(R.string.terms_label_semantics)), + ) + } +} + +@Composable +fun PasswordColumn( + password: String, + repeatPassword: String, + state: AuthState, + onForgotPasswordClick: () -> Unit, + onPasswordChange: (String) -> Unit, + onRepeatPasswordChange: (String) -> Unit, +) { + Column { + PasswordField( + value = password, + onNewValue = onPasswordChange, + modifier = Modifier + .fieldModifier() + .withSemantics("Password Field"), + ) + AnimatedVisibility(visible = state == AuthState.SIGN_IN) { + ForgotPasswordButton( + onClick = onForgotPasswordClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + AnimatedVisibility(visible = state == AuthState.SIGN_UP) { + RepeatPasswordField( + value = repeatPassword, + onNewValue = onRepeatPasswordChange, + modifier = Modifier + .fieldModifier() + .withSemantics("Repeat Password Field"), + ) + } + } +} + +@Composable +private fun ForgotPasswordButton(onClick: () -> Unit, modifier: Modifier = Modifier) { + val context = LocalContext.current + + val annotatedText = buildAnnotatedString { + withStyle(style = ParagraphStyle(textAlign = TextAlign.End)) { + append(context.getString(R.string.login_forgot_password_label)) + } + } + + ClickableText( + text = annotatedText, + style = TextStyle(color = MaterialTheme.colorScheme.onSurface), + onClick = { onClick() }, + modifier = modifier, + ) +} + +@Composable +private fun ChangerButton(state: AuthState, onClick: () -> Unit) { + val context = LocalContext.current + val clickableText = when (state) { + AuthState.SIGN_IN -> R.string.no_account_question_label + else -> R.string.have_account_question_label + } + val label = when (state) { + AuthState.SIGN_IN -> R.string.label_sign_in + else -> R.string.label_sign_up + } + val annotatedText = buildAnnotatedString { + withStyle(style = SpanStyle()) { append(context.getString(clickableText)) } + append(" ") + withStyle( + style = + SpanStyle(color = (MaterialTheme.colorScheme).primary, fontWeight = FontWeight.Bold), + ) { + append(context.getString(label)) + } + } + + ClickableText( + text = annotatedText, + style = TextStyle(color = MaterialTheme.colorScheme.onSurface), + onClick = { onClick() }, + ) +} + +private const val URI_A = "uri_a" +private const val URI_B = "uri_b" +private const val TERMS_URI = "https://support-orchid.web.app/terms.html" +private const val PRIVACY_URI = "https://support-orchid.web.app/privacy.html" + +@Composable +private fun TermsAndPrivacyStatementText( + modifier: Modifier = Modifier, +) { + val uriHandler = LocalUriHandler.current + val annotatedText = buildAnnotatedString { + withStyle(style = SpanStyle()) { append(stringResource(R.string.terms_label)) } + pushStringAnnotation(tag = URI_A, annotation = TERMS_URI) + withStyle( + style = + SpanStyle(color = (MaterialTheme.colorScheme).primary, fontWeight = FontWeight.Bold), + ) { + append(" ${stringResource(R.string.terms_text_button)} ") + } + pop() + append(" ${stringResource(R.string.and_separator)} ") + pushStringAnnotation(tag = URI_B, annotation = PRIVACY_URI) + withStyle( + style = + SpanStyle(color = (MaterialTheme.colorScheme).primary, fontWeight = FontWeight.Bold), + ) { + append(" ${stringResource(R.string.privacy_policy_text_button)} ") + } + pop() + } + + ClickableText( + text = annotatedText, + modifier = modifier, + style = TextStyle( + textAlign = TextAlign.Center, + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurface, + ), + onClick = { offset -> + annotatedText + .getStringAnnotations(tag = URI_A, start = offset, end = offset) + .firstOrNull() + ?.let { uriHandler.openUri(it.item) } + + annotatedText + .getStringAnnotations(tag = URI_B, start = offset, end = offset) + .firstOrNull() + ?.let { uriHandler.openUri(it.item) } + }, + ) +} + +@Preview +@Composable +private fun Preview_LoginContent() { + PsTheme { + PsBackground { + AuthScreen( + uiState = AuthFormUiState(), + onEmailChange = {}, + onPasswordChange = {}, + onRepeatPasswordChange = {}, + onForgotPasswordClick = {}, + onBackPressed = {}, + onLogin = {}, + onSignUp = {}, + ) + } + } +} diff --git a/feature/account/src/main/java/com/github/andiim/plantscan/feature/account/AuthViewModel.kt b/feature/account/src/main/java/com/github/andiim/plantscan/feature/account/AuthViewModel.kt new file mode 100644 index 00000000..e57b46eb --- /dev/null +++ b/feature/account/src/main/java/com/github/andiim/plantscan/feature/account/AuthViewModel.kt @@ -0,0 +1,102 @@ +package com.github.andiim.plantscan.feature.account + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.andiim.plantscan.core.auth.AuthHelper +import com.github.andiim.plantscan.core.data.repository.UserDataRepository +import com.github.andiim.plantscan.core.domain.GetUserLoginInfoUseCase +import com.github.andiim.plantscan.feature.account.extensions.isValidEmail +import com.github.andiim.plantscan.feature.account.extensions.passwordMatches +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class AuthViewModel @Inject constructor( + private val userDataRepository: UserDataRepository, + private val authHelper: AuthHelper, + private val getUserLoginInfoUseCase: GetUserLoginInfoUseCase, +) : ViewModel() { + var formUiState by mutableStateOf(AuthFormUiState()) + private set + + fun onEmailChange(newValue: String) { + formUiState = formUiState.copy(email = newValue) + } + + fun onPasswordChange(newValue: String) { + formUiState = formUiState.copy(password = newValue) + } + + fun onRepeatPasswordChange(newValue: String) { + formUiState = formUiState.copy(repeatPassword = newValue) + } + + @Suppress("detekt:TooGenericExceptionCaught") + fun onSignInClick(onSuccess: () -> Unit, onError: (Any?) -> Unit) { + if (!formUiState.email.isValidEmail()) { + onError(R.string.email_error) + } + if (formUiState.password.isBlank()) { + onError(R.string.error_empty_password) + } + viewModelScope.launch { + try { + authHelper.authenticate(formUiState.email, formUiState.password).collect() + getUserLoginInfoUseCase().collectLatest { info -> + userDataRepository.setLoginInfo( + userId = info.userId, + isAnonymous = info.isAnonymous, + ) + } + onSuccess() + } catch (e: Exception) { + onError(e.message) + } + } + } + + @Suppress("detekt:TooGenericExceptionCaught") + fun onSignUpClick(onSuccess: () -> Unit, onError: (Any?) -> Unit) { + if (!formUiState.email.isValidEmail()) { + onError(R.string.email_error) + } + if (formUiState.password.isBlank()) { + onError(R.string.error_empty_password) + } + if (!formUiState.password.passwordMatches(formUiState.repeatPassword)) { + onError(R.string.error_password_match) + } + + viewModelScope.launch { + try { + authHelper.linkAccount(formUiState.email, formUiState.password).collect { + val info = getUserLoginInfoUseCase().first() + userDataRepository.setLoginInfo( + userId = info.userId, + isAnonymous = info.isAnonymous, + ) + } + onSuccess() + } catch (e: Exception) { + onError(e.message) + } + } + } + + fun onForgotPasswordClick(): Int { + if (!formUiState.email.isValidEmail()) { + return R.string.email_error + } + viewModelScope.launch { + authHelper.sendRecoveryEmail(formUiState.email).collect() + } + return R.string.hint_recovery_email_sent + } +} diff --git a/feature/account/src/main/java/com/github/andiim/plantscan/feature/account/components/TextField.kt b/feature/account/src/main/java/com/github/andiim/plantscan/feature/account/components/TextField.kt new file mode 100644 index 00000000..2ff7ffaf --- /dev/null +++ b/feature/account/src/main/java/com/github/andiim/plantscan/feature/account/components/TextField.kt @@ -0,0 +1,139 @@ +package com.github.andiim.plantscan.feature.account.components + +import android.content.res.Configuration +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.github.andiim.plantscan.core.designsystem.component.PsBackground +import com.github.andiim.plantscan.core.designsystem.icon.PsIcons +import com.github.andiim.plantscan.core.designsystem.theme.PsTheme +import com.github.andiim.plantscan.feature.account.R + +@Composable +fun BasicField( + @StringRes text: Int, + value: String, + onNewValue: (String) -> Unit, + modifier: Modifier = Modifier, +) { + OutlinedTextField( + singleLine = true, + modifier = modifier, + value = value, + onValueChange = { onNewValue(it) }, + placeholder = { Text(stringResource(text)) }, + ) +} + +@Composable +fun EmailField(value: String, onNewValue: (String) -> Unit, modifier: Modifier = Modifier) { + OutlinedTextField( + singleLine = true, + modifier = modifier, + value = value, + onValueChange = { onNewValue(it) }, + placeholder = { Text(stringResource(R.string.label_email)) }, + leadingIcon = { Icon(imageVector = Icons.Default.Email, contentDescription = "Email") }, + ) +} + +@Composable +fun PasswordField(value: String, onNewValue: (String) -> Unit, modifier: Modifier = Modifier) { + PasswordField(value, R.string.label_password, onNewValue, modifier) +} + +@Composable +fun RepeatPasswordField( + value: String, + onNewValue: (String) -> Unit, + modifier: Modifier = Modifier, +) { + PasswordField(value, R.string.label_repeat_password, onNewValue, modifier) +} + +@Composable +private fun PasswordField( + value: String, + @StringRes placeholder: Int, + onNewValue: (String) -> Unit, + modifier: Modifier = Modifier, +) { + var isVisible by remember { mutableStateOf(false) } + val visualTransformation = + if (isVisible) VisualTransformation.None else PasswordVisualTransformation() + val description = + if (isVisible) { + stringResource(R.string.hide_password) + } else { + stringResource(R.string.show_password) + } + + OutlinedTextField( + singleLine = true, + modifier = modifier, + value = value, + onValueChange = { onNewValue(it) }, + placeholder = { Text(text = stringResource(placeholder)) }, + leadingIcon = { Icon(imageVector = Icons.Default.Lock, contentDescription = "Lock") }, + trailingIcon = { + IconButton(onClick = { isVisible = !isVisible }) { + Icon( + imageVector = if (isVisible) PsIcons.Visible else PsIcons.InVisible, + contentDescription = description, + ) + } + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Next, + ), + visualTransformation = visualTransformation, + ) +} + +@Preview(name = "Night Mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "Day Mode", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Composable +private fun TextViewPreview() { + PsTheme { + PsBackground { + Column( + verticalArrangement = Arrangement.SpaceEvenly, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(16.dp), + ) { + BasicField(text = R.string.label_email, value = "", onNewValue = {}) + Spacer(modifier = Modifier.padding(8.dp)) + EmailField(value = "", onNewValue = {}) + Spacer(modifier = Modifier.padding(8.dp)) + PasswordField(value = "", onNewValue = {}) + Spacer(modifier = Modifier.padding(8.dp)) + RepeatPasswordField(value = "", onNewValue = {}) + } + } + } +} diff --git a/library-android/src/main/java/com/github/andiim/plantscan/library/android/extensions/StringExtension.kt b/feature/account/src/main/java/com/github/andiim/plantscan/feature/account/extensions/StringExtensions.kt similarity index 59% rename from library-android/src/main/java/com/github/andiim/plantscan/library/android/extensions/StringExtension.kt rename to feature/account/src/main/java/com/github/andiim/plantscan/feature/account/extensions/StringExtensions.kt index f7d8ad01..fa80f5f9 100644 --- a/library-android/src/main/java/com/github/andiim/plantscan/library/android/extensions/StringExtension.kt +++ b/feature/account/src/main/java/com/github/andiim/plantscan/feature/account/extensions/StringExtensions.kt @@ -1,4 +1,4 @@ -package com.github.andiim.plantscan.library.android.extensions +package com.github.andiim.plantscan.feature.account.extensions import android.util.Patterns import java.util.regex.Pattern @@ -11,15 +11,10 @@ fun String.isValidEmail(): Boolean { } fun String.isValidPassword(): Boolean { - return this.isNotBlank() && - this.length >= MIN_PASS_LENGTH && - Pattern.compile(PASS_PATTERN).matcher(this).matches() + return this.isNotBlank() && this.length >= MIN_PASS_LENGTH && Pattern.compile(PASS_PATTERN) + .matcher(this).matches() } fun String.passwordMatches(repeated: String): Boolean { return this == repeated } - -fun String.idFromParameter(): String { - return this.substring(1, this.length - 1) -} diff --git a/feature/account/src/main/java/com/github/andiim/plantscan/feature/account/navigation/AuthNavigation.kt b/feature/account/src/main/java/com/github/andiim/plantscan/feature/account/navigation/AuthNavigation.kt new file mode 100644 index 00000000..78596a0d --- /dev/null +++ b/feature/account/src/main/java/com/github/andiim/plantscan/feature/account/navigation/AuthNavigation.kt @@ -0,0 +1,42 @@ +package com.github.andiim.plantscan.feature.account.navigation + +import androidx.compose.material3.SnackbarDuration +import androidx.navigation.NavController +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.github.andiim.plantscan.core.ui.navigation.AppDestination +import com.github.andiim.plantscan.feature.account.AuthRoute + +fun NavController.navigateToAuth() { + this.navigate(Auth.route) { + popUpTo(graph.findStartDestination().id) { + saveState = true + } + // Avoid multiple copies of the same destination when + // reselecting the same item + launchSingleTop = true + // Restore state when reselecting a previously selected item + restoreState = true + } +} + +object Auth : AppDestination { + override val route: String = "auth" +} + +fun NavGraphBuilder.authScreen( + onBackPressed: () -> Unit, + authCallback: () -> Unit, + onShowSnackbar: suspend (String, String?, SnackbarDuration?) -> Boolean, +) { + composable( + Auth.route, + ) { + AuthRoute( + onBackPressed = onBackPressed, + authCallback = authCallback, + onShowSnackbar = onShowSnackbar, + ) + } +} diff --git a/feature/account/src/main/res/values/strings.xml b/feature/account/src/main/res/values/strings.xml new file mode 100644 index 00000000..c22a80dd --- /dev/null +++ b/feature/account/src/main/res/values/strings.xml @@ -0,0 +1,26 @@ + + + Create account + Sign in + Sign Up + Email + Please insert a valid email. + Password + Password cannot be empty. + Your password should have at least six digits and include one digit, one lower case letter and one upper case letter. + Passwords do not match. + Repeat password + Hide Password + Show Password + Forgot Password? + Already have an account? + Don\'t have an account? + By continuing, you agree to our + Terms + and + Privacy Policy + Terms Label + Send + Check your inbox for the recovery email. + An error occured + \ No newline at end of file diff --git a/feature/camera/.gitignore b/feature/camera/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/camera/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/camera/README.md b/feature/camera/README.md new file mode 100644 index 00000000..e69de29b diff --git a/feature/camera/build.gradle.kts b/feature/camera/build.gradle.kts new file mode 100644 index 00000000..1483b04a --- /dev/null +++ b/feature/camera/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + alias(libs.plugins.android.feature) + alias(libs.plugins.android.library.compose) + alias(libs.plugins.android.library.jacoco) +} + +android { + buildFeatures { + viewBinding = true + } + namespace = "com.github.andiim.plantscan.feature.camera" +} + +dependencies { + api(libs.kotlinx.coroutines.guava) + api(libs.kotlin.coroutines.android) + implementation(libs.timber) + implementation(libs.bundles.camera) + implementation(libs.accompanist.permission) +} diff --git a/feature/camera/src/main/AndroidManifest.xml b/feature/camera/src/main/AndroidManifest.xml new file mode 100644 index 00000000..4c55b640 --- /dev/null +++ b/feature/camera/src/main/AndroidManifest.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/library-android/src/main/java/com/github/andiim/plantscan/library/android/extensions/BitmapExtensions.kt b/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/BitmapExtensions.kt similarity index 55% rename from library-android/src/main/java/com/github/andiim/plantscan/library/android/extensions/BitmapExtensions.kt rename to feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/BitmapExtensions.kt index 6b8ccfc7..c00cf673 100644 --- a/library-android/src/main/java/com/github/andiim/plantscan/library/android/extensions/BitmapExtensions.kt +++ b/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/BitmapExtensions.kt @@ -1,19 +1,15 @@ -package com.github.andiim.plantscan.library.android.extensions +package com.github.andiim.plantscan.feature.camera import android.graphics.Bitmap import android.graphics.Matrix /** - * Get from [YanneckReiss](https://github.com/YanneckReiss) - * * The rotationDegrees parameter is the rotation in degrees clockwise from the original orientation. */ fun Bitmap.rotateBitmap(rotationDegrees: Int): Bitmap { - val matrix = - Matrix().apply { + val matrix = Matrix().apply { postRotate(-rotationDegrees.toFloat()) postScale(-1f, -1f) - } - - return Bitmap.createBitmap(this, 0, 0, width, height, matrix, true) + } + return Bitmap.createBitmap(this, 0, 0, width, height, matrix, true) } diff --git a/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/Constants.kt b/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/Constants.kt new file mode 100644 index 00000000..70c97328 --- /dev/null +++ b/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/Constants.kt @@ -0,0 +1,12 @@ +package com.github.andiim.plantscan.feature.camera + +internal const val FILENAME = "yyyy-MM-dd-HH-mm-ss-SSS" +internal const val PHOTO_EXTENSION = ".jpg" + +internal const val SPRING_STIFFNESS_ALPHA_OUT = 100f +internal const val SPRING_STIFFNESS = 800f +internal const val SPRING_DAMPING_RATIO = 0.35f +internal const val SCALE_TARGET = 1.5f +internal const val INITIAL = 0f +internal const val SCALE_X = 1.5f +internal const val SCALE_Y = 1.5f diff --git a/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/component/CameraControls.kt b/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/component/CameraControls.kt new file mode 100644 index 00000000..a745c0ec --- /dev/null +++ b/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/component/CameraControls.kt @@ -0,0 +1,83 @@ +package com.github.andiim.plantscan.feature.camera.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.sharp.PhotoLibrary +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.github.andiim.plantscan.core.designsystem.theme.PsTheme +import com.github.andiim.plantscan.feature.camera.R +import com.github.andiim.plantscan.feature.camera.model.CameraUIAction + +@Composable +fun CameraControls(cameraUIAction: (CameraUIAction) -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color.Black) + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + ChangeCameraBtn( + modifier = Modifier.size(64.dp), + onClick = { cameraUIAction(CameraUIAction.OnSwitchCameraClick) }, + ) + ShutterButton( + modifier = Modifier + .size(64.dp) + .padding(1.dp) + .border(1.dp, Color.White, CircleShape), + onClick = { cameraUIAction(CameraUIAction.OnCameraClick) }, + ) + CameraControl( + Icons.Sharp.PhotoLibrary, + R.string.open_gallery, + modifier = Modifier.size(64.dp), + onClick = { cameraUIAction(CameraUIAction.OnGalleryViewClick) }, + ) + } +} + +@Composable +fun CameraControl( + imageVector: ImageVector, + contentDescId: Int, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + IconButton( + onClick = onClick, + modifier = modifier, + ) { + Icon( + imageVector, + contentDescription = stringResource(id = contentDescId), + modifier = modifier, + tint = Color.White, + ) + } +} + +@Preview +@Composable +fun CameraControls_Preview() { + PsTheme { + CameraControls(cameraUIAction = {}) + } +} diff --git a/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/component/CameraPreviewView.kt b/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/component/CameraPreviewView.kt new file mode 100644 index 00000000..2627a467 --- /dev/null +++ b/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/component/CameraPreviewView.kt @@ -0,0 +1,171 @@ +package com.github.andiim.plantscan.feature.camera.component + +import android.annotation.SuppressLint +import androidx.camera.core.Camera +import androidx.camera.core.CameraSelector +import androidx.camera.view.PreviewView +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.dynamicanimation.animation.SpringForce.DAMPING_RATIO_NO_BOUNCY +import androidx.lifecycle.LifecycleOwner +import com.github.andiim.plantscan.core.designsystem.component.PsTextButton +import com.github.andiim.plantscan.feature.camera.INITIAL +import com.github.andiim.plantscan.feature.camera.SCALE_TARGET +import com.github.andiim.plantscan.feature.camera.SPRING_DAMPING_RATIO +import com.github.andiim.plantscan.feature.camera.SPRING_STIFFNESS +import com.github.andiim.plantscan.feature.camera.SPRING_STIFFNESS_ALPHA_OUT +import com.github.andiim.plantscan.feature.camera.extensions.cameraExtensionsName +import com.github.andiim.plantscan.feature.camera.model.CameraExtensionItem +import com.github.andiim.plantscan.feature.camera.model.CameraUIAction +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +@SuppressLint("RestrictedApi") +@Composable +fun CameraPreviewView( + camera: Camera?, + lensFacing: Int = CameraSelector.LENS_FACING_BACK, + extensionMode: Int, + availableExtensions: List, + onPreviewStart: (LifecycleOwner, PreviewView) -> Unit, + cameraUIAction: (CameraUIAction) -> Unit, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + var offset by remember { mutableStateOf(Offset(0f, 0f)) } + val scale = remember { Animatable(INITIAL) } + val alpha = remember { Animatable(INITIAL) } + val coroutineScope = rememberCoroutineScope() + + suspend fun blinkAnimation() { + val scaleSpec = spring(SPRING_DAMPING_RATIO, SPRING_STIFFNESS) + val alphaSpec = spring(DAMPING_RATIO_NO_BOUNCY, SPRING_STIFFNESS_ALPHA_OUT) + coroutineScope { + launch { + scale.animateTo(SCALE_TARGET, animationSpec = scaleSpec) + scale.animateTo(INITIAL, animationSpec = scaleSpec) + } + launch { + alpha.animateTo(1f, animationSpec = alphaSpec) + alpha.animateTo(INITIAL, animationSpec = alphaSpec) + } + } + } + + val previewView = remember { + PreviewView(context).apply { + scaleType = PreviewView.ScaleType.FILL_CENTER + } + } + LaunchedEffect(lensFacing, camera) { + onPreviewStart.invoke(lifecycleOwner, previewView) + } + + Box(modifier = Modifier.fillMaxSize()) { + AndroidView( + factory = { previewView }, + modifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + detectTransformGestures { _, _, zoom, _ -> + coroutineScope.launch { + cameraUIAction(CameraUIAction.Scale(zoom)) + } + } + } + .pointerInput(Unit) { + detectTapGestures { + offset = it + val meteringPointFactory = previewView.meteringPointFactory + val focusPoint = meteringPointFactory.createPoint(it.x, it.y) + + coroutineScope.launch { + blinkAnimation() + cameraUIAction(CameraUIAction.Focus(focusPoint)) + } + } + }, + ) + + FocusPoint( + scale = scale.value, + alpha = alpha.value, + modifier = Modifier + .offset { offset.toIntOffset() } + .size(64.dp), + ) + + Column( + modifier = Modifier.align(Alignment.BottomCenter), + verticalArrangement = Arrangement.Bottom, + ) { + ShowExtensions( + mode = extensionMode, + data = availableExtensions, + onExtClick = { cameraUIAction(CameraUIAction.SelectCameraExtension(it)) }, + ) + CameraControls(cameraUIAction) + } + } +} + +@Composable +fun ShowExtensions( + mode: Int, + data: List, + onExtClick: (Int) -> Unit, +) { + val context = LocalContext.current + val list = data.map { + CameraExtensionItem( + it, + context.getString(cameraExtensionsName[it]!!), + mode == it, + ) + } + LazyRow { + items(list) { + PsTextButton(onClick = { onExtClick(it.extensionMode) }) { + if (it.selected) { + Box(modifier = Modifier.background(color = Color.Blue)) { + Text(it.name, color = Color.White) + } + } else { + Text(text = it.name) + } + } + } + } +} + +private fun Offset.toIntOffset() = IntOffset(x.roundToInt(), y.roundToInt()) diff --git a/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/component/ChangeCameraBtn.kt b/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/component/ChangeCameraBtn.kt new file mode 100644 index 00000000..6fff1a2b --- /dev/null +++ b/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/component/ChangeCameraBtn.kt @@ -0,0 +1,68 @@ +package com.github.andiim.plantscan.feature.camera.component + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.LinearLayout +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.github.andiim.plantscan.core.designsystem.extensions.withSemantics +import com.github.andiim.plantscan.core.designsystem.theme.PsTheme +import com.github.andiim.plantscan.feature.camera.R + +private const val HALF_CLOCKWISE = 180f +private const val ANIMATION_DURATION = 300L +private const val INITIAL_ROTATION = 0f + +@Composable +fun ChangeCameraBtn( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val ctx = LocalContext.current + AndroidView( + modifier = modifier.withSemantics(ctx.getString(R.string.flip_camera)), + factory = { context -> + ImageView(context).apply { + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + setImageResource(R.drawable.ic_flip_camera_android) + imageTintList = context.getColorStateList(R.color.button) + }.also { imageView -> + imageView.setOnClickListener { + imageView.animate().apply { + rotation(-HALF_CLOCKWISE) + duration = ANIMATION_DURATION + setListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + imageView.rotation = INITIAL_ROTATION + } + }, + ) + } + onClick() + } + } + }, + ) +} + +@Preview +@Composable +fun RotatingIconButtonPreview() { + PsTheme { + ChangeCameraBtn( + modifier = Modifier.size(64.dp), + onClick = {}, + ) + } +} diff --git a/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/component/FocusPoint.kt b/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/component/FocusPoint.kt new file mode 100644 index 00000000..ab8af230 --- /dev/null +++ b/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/component/FocusPoint.kt @@ -0,0 +1,57 @@ +package com.github.andiim.plantscan.feature.camera.component + +import android.util.TypedValue +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import com.github.andiim.plantscan.core.designsystem.component.PsBackground +import com.github.andiim.plantscan.core.designsystem.theme.PsTheme +import kotlin.math.min + +@Composable +fun FocusPoint( + scale: Float, + alpha: Float, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + + Canvas(modifier = modifier.fillMaxSize()) { + val canvasWidth = size.width + val canvasHeight = size.height + val strokeWidth = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + 3f, + context.resources.displayMetrics, + ) + val radius = (min(canvasWidth, canvasHeight) / 2) - (strokeWidth / 2) + scale(scale = scale, pivot = Offset(0f, 0f)) { + drawCircle( + color = Color.White, + radius = radius, + style = Stroke( + width = strokeWidth, + ), + alpha = alpha, + center = Offset(0f, 0f), + ) + } + } +} + +@Preview +@Composable +fun FocusPoint_Preview() { + PsTheme(darkTheme = true) { + PsBackground { + FocusPoint(scale = 1f, alpha = 1f) + } + } +} diff --git a/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/component/FocusPointDrawable.kt b/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/component/FocusPointDrawable.kt new file mode 100644 index 00000000..524e6a09 --- /dev/null +++ b/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/component/FocusPointDrawable.kt @@ -0,0 +1,59 @@ +package com.github.andiim.plantscan.feature.camera.component + +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.ColorFilter +import android.graphics.Paint +import android.graphics.PixelFormat +import android.graphics.Rect +import android.graphics.drawable.Drawable +import kotlin.math.min + +class FocusPointDrawable : Drawable() { + private val paint = + Paint().apply { + isAntiAlias = true + style = Paint.Style.STROKE + color = Color.WHITE + } + + private var radius: Float = 0f + private var centerX: Float = 0f + private var centerY: Float = 0f + + fun setStrokeWidth(strokeWidth: Float): Boolean = + if (paint.strokeWidth == strokeWidth) { + false + } else { + paint.strokeWidth = strokeWidth + true + } + + override fun onBoundsChange(bounds: Rect) { + val width = bounds.width() + val height = bounds.height() + radius = min(width, height) / 2f - paint.strokeWidth / 2f + centerX = width / 2f + centerY = height / 2f + } + + override fun draw(canvas: Canvas) { + if (radius == 0f) return + + canvas.drawCircle(centerX, centerY, radius, paint) + } + + override fun setAlpha(alpha: Int) { + paint.alpha = alpha + } + + override fun setColorFilter(colorFiter: ColorFilter?) { + paint.colorFilter = colorFilter + } + + @Deprecated( + "Deprecated in Java", + ReplaceWith("PixelFormat.TRANSLUCENT", "android.graphics.PixelFormat") + ) + override fun getOpacity(): Int = PixelFormat.TRANSLUCENT +} diff --git a/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/component/ShutterButton.kt b/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/component/ShutterButton.kt new file mode 100644 index 00000000..a3769ef5 --- /dev/null +++ b/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/component/ShutterButton.kt @@ -0,0 +1,48 @@ +package com.github.andiim.plantscan.feature.camera.component + +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.LinearLayout +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.github.andiim.plantscan.core.designsystem.extensions.withSemantics +import com.github.andiim.plantscan.core.designsystem.theme.PsTheme +import com.github.andiim.plantscan.feature.camera.R + +@Composable +fun ShutterButton( + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + val ctx = LocalContext.current + AndroidView( + modifier = modifier.withSemantics(ctx.getString(R.string.shutter)), + factory = { context -> + ImageView(context).apply { + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + setImageResource(R.drawable.ic_camera_shutter) + imageTintList = context.getColorStateList(R.color.button) + setOnClickListener { onClick() } + } + }, + ) +} + +@Preview +@Composable +fun ShutterButton_Preview() { + PsTheme { + ShutterButton( + modifier = Modifier.size(64.dp), + onClick = {}, + ) + } +} diff --git a/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/di/CameraProvider.kt b/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/di/CameraProvider.kt new file mode 100644 index 00000000..2912e571 --- /dev/null +++ b/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/di/CameraProvider.kt @@ -0,0 +1,45 @@ +package com.github.andiim.plantscan.feature.camera.di + +import android.content.Context +import android.util.Log +import androidx.camera.camera2.Camera2Config +import androidx.camera.core.CameraXConfig +import androidx.camera.extensions.ExtensionsManager +import androidx.camera.lifecycle.ProcessCameraProvider +import com.github.andiim.plantscan.core.network.AppDispatchers.IO +import com.github.andiim.plantscan.core.network.Dispatcher +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.asExecutor +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object CameraProvider { + @Provides + @Singleton + fun provideCameraXConfig( + @Dispatcher(IO) ioDispatchers: CoroutineDispatcher, + ): CameraXConfig = + CameraXConfig.Builder.fromConfig(Camera2Config.defaultConfig()) + .setCameraExecutor(ioDispatchers.asExecutor()) + .setMinimumLoggingLevel(Log.ERROR) + .build() + + @Provides + @Singleton + fun providesCamera( + @ApplicationContext context: Context, + ): ProcessCameraProvider = ProcessCameraProvider.getInstance(context).get() + + @Singleton + @Provides + fun providesCameraExtensions( + @ApplicationContext context: Context, + provider: ProcessCameraProvider, + ): ExtensionsManager = ExtensionsManager.getInstanceAsync(context, provider).get() +} diff --git a/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/extensions/ImageCaptureExt.kt b/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/extensions/ImageCaptureExt.kt new file mode 100644 index 00000000..fc64eaac --- /dev/null +++ b/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/extensions/ImageCaptureExt.kt @@ -0,0 +1,14 @@ +package com.github.andiim.plantscan.feature.camera.extensions + +import androidx.camera.extensions.ExtensionMode +import com.github.andiim.plantscan.feature.camera.R + +internal val cameraExtensionsName = + mapOf( + ExtensionMode.AUTO to R.string.camera_mode_auto, + ExtensionMode.NIGHT to R.string.camera_mode_night, + ExtensionMode.HDR to R.string.camera_mode_hdr, + ExtensionMode.FACE_RETOUCH to R.string.camera_mode_face_retouch, + ExtensionMode.BOKEH to R.string.camera_mode_bokeh, + ExtensionMode.NONE to R.string.camera_mode_none, + ) diff --git a/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/model/CameraExtensionItem.kt b/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/model/CameraExtensionItem.kt new file mode 100644 index 00000000..f8e04c19 --- /dev/null +++ b/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/model/CameraExtensionItem.kt @@ -0,0 +1,13 @@ +package com.github.andiim.plantscan.feature.camera.model + +import androidx.camera.extensions.ExtensionMode +/** + * Defines the item model for a camera extension displayed by the adapter. + * + * @see CameraExtensionsSelectorAdapter + */ +data class CameraExtensionItem( + @ExtensionMode.Mode val extensionMode: Int, + val name: String, + val selected: Boolean = false, +) diff --git a/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/model/CameraUIAction.kt b/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/model/CameraUIAction.kt new file mode 100644 index 00000000..9ffa1438 --- /dev/null +++ b/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/model/CameraUIAction.kt @@ -0,0 +1,13 @@ +package com.github.andiim.plantscan.feature.camera.model + +import androidx.camera.core.MeteringPoint +import androidx.camera.extensions.ExtensionMode + +sealed class CameraUIAction { + data object OnCameraClick : CameraUIAction() + data object OnGalleryViewClick : CameraUIAction() + data object OnSwitchCameraClick : CameraUIAction() + data class SelectCameraExtension(@ExtensionMode.Mode val extension: Int) : CameraUIAction() + data class Scale(val scaleFactor: Float) : CameraUIAction() + data class Focus(val meteringPoint: MeteringPoint) : CameraUIAction() +} diff --git a/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/model/CameraUiState.kt b/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/model/CameraUiState.kt new file mode 100644 index 00000000..1b9e7b76 --- /dev/null +++ b/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/model/CameraUiState.kt @@ -0,0 +1,41 @@ +package com.github.andiim.plantscan.feature.camera.model + +import android.net.Uri +import androidx.camera.core.Camera +import androidx.camera.core.CameraSelector +import androidx.camera.extensions.ExtensionMode + +/** + * Defines the current UI state of the camera during pre-capture. The state encapsulates the + * available camera extensions, the available camera lenses to toggle, the current camera lens, the + * current extension mode, and the state of the camera. + */ +data class CameraUiState( + val camera: Camera? = null, + val cameraState: CameraState = CameraState.NOT_READY, + val availableExtensions: List = emptyList(), + val availableCameraLens: List = listOf(CameraSelector.LENS_FACING_BACK), + @CameraSelector.LensFacing val cameraLens: Int = CameraSelector.LENS_FACING_BACK, + @ExtensionMode.Mode val extensionMode: Int = ExtensionMode.NONE, +) + +/** + * Defines the current state of the camera. + */ +enum class CameraState { + /** Camera hasn't been initialized. */ + NOT_READY, + + /** Camera is open and presenting a preview stream. */ + READY, + + /** Camera is initialized but the preview has been stopped. */ + PREVIEW_STOPPED +} + +sealed class CaptureState { + data object CaptureNotReady : CaptureState() + data class ImageObtained(val uri: Uri?) : CaptureState() + + data class CaptureFailed(val exception: Exception) : CaptureState() +} diff --git a/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/navigation/CameraNavigation.kt b/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/navigation/CameraNavigation.kt new file mode 100644 index 00000000..12d19f40 --- /dev/null +++ b/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/navigation/CameraNavigation.kt @@ -0,0 +1,32 @@ +package com.github.andiim.plantscan.feature.camera.navigation + +import androidx.compose.material3.SnackbarDuration +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.github.andiim.plantscan.core.ui.navigation.AppDestination +import com.github.andiim.plantscan.feature.camera.photoCapture.CameraRoute + +fun NavController.navigateToCamera() { + this.navigate(Camera.route) { + launchSingleTop = true + } +} + +fun NavGraphBuilder.cameraScreen( + onBackClick: () -> Unit, + onImageCaptured: (String) -> Unit, + onShowSnackbar: suspend (String, String?, SnackbarDuration?) -> Boolean, +) { + composable(Camera.route) { + CameraRoute( + onBackClick = onBackClick, + onImageCaptured = onImageCaptured, + onShowSnackbar = onShowSnackbar, + ) + } +} + +object Camera : AppDestination { + override val route: String = "camera" +} diff --git a/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/noPermission/NoPermissionScreen.kt b/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/noPermission/NoPermissionScreen.kt new file mode 100644 index 00000000..27365841 --- /dev/null +++ b/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/noPermission/NoPermissionScreen.kt @@ -0,0 +1,118 @@ +package com.github.andiim.plantscan.feature.camera.noPermission + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.github.andiim.plantscan.core.designsystem.icon.PsIcons +import com.github.andiim.plantscan.core.designsystem.theme.PsTheme +import com.github.andiim.plantscan.core.ui.TrackScreenViewEvent +import com.github.andiim.plantscan.feature.camera.R + +@Composable +fun NoPermissionScreen( + shouldShowRationale: Boolean, + onBackClick: () -> Unit, + onRequestPermission: () -> Unit, + onGalleryLauncherOpened: () -> Unit, +) { + NoPermissionContent( + shouldShowRationale = shouldShowRationale, + onBackClick = onBackClick, + onRequestPermission = onRequestPermission, + onGalleryLauncherOpened = onGalleryLauncherOpened, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NoPermissionContent( + shouldShowRationale: Boolean, + onBackClick: () -> Unit, + onRequestPermission: () -> Unit, + onGalleryLauncherOpened: () -> Unit, + modifier: Modifier = Modifier, +) { + TrackScreenViewEvent(screenName = "Blocked Camera") + Box( + modifier = modifier + .fillMaxSize() + .animateContentSize(), + ) { + TopAppBar( + title = {}, + navigationIcon = { + IconButton( + onClick = onBackClick, + ) { + Icon( + PsIcons.Back, + contentDescription = stringResource(R.string.back_from_camera), + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + ), + modifier = Modifier.align(Alignment.TopStart), + ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + .align(Alignment.Center), + ) { + Text( + text = stringResource( + if (shouldShowRationale) { + R.string.camera_permission_denied_error_text + } else { + R.string.camera_permission_error_text + }, + ), + ) + Button(onClick = onRequestPermission) { + Icon(imageVector = PsIcons.Camera, contentDescription = null) + Text(text = stringResource(R.string.request_permission_text)) + } + Button(onClick = onGalleryLauncherOpened) { + Icon(imageVector = PsIcons.Gallery, contentDescription = null) + Text(text = stringResource(R.string.open_gallery)) + } + } + } +} + +@Preview +@Composable +fun Preview_NoPermissionContent() { + PsTheme { + Surface { + NoPermissionContent( + shouldShowRationale = false, + onBackClick = {}, + onRequestPermission = {}, + onGalleryLauncherOpened = {}, + ) + } + } +} diff --git a/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/photoCapture/CameraScreen.kt b/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/photoCapture/CameraScreen.kt new file mode 100644 index 00000000..ac7d6a7b --- /dev/null +++ b/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/photoCapture/CameraScreen.kt @@ -0,0 +1,212 @@ +package com.github.andiim.plantscan.feature.camera.photoCapture + +import android.Manifest +import android.content.Context +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia +import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia.ImageOnly +import androidx.camera.core.MeteringPoint +import androidx.camera.view.PreviewView +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsTopHeight +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarDuration +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.github.andiim.plantscan.core.designsystem.icon.PsIcons +import com.github.andiim.plantscan.core.ui.TrackScreenViewEvent +import com.github.andiim.plantscan.feature.camera.component.CameraPreviewView +import com.github.andiim.plantscan.feature.camera.model.CameraUIAction +import com.github.andiim.plantscan.feature.camera.model.CameraUiState +import com.github.andiim.plantscan.feature.camera.model.CaptureState +import com.github.andiim.plantscan.feature.camera.noPermission.NoPermissionScreen +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionState +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun CameraRoute( + onBackClick: () -> Unit, + onShowSnackbar: suspend (String, String?, SnackbarDuration?) -> Boolean, + onImageCaptured: (String) -> Unit, + viewModel: CameraViewModel = hiltViewModel(), +) { + val cameraPermissionState: PermissionState = rememberPermissionState(Manifest.permission.CAMERA) + var galleryLauncherOpened by remember { mutableStateOf(false) } + val galleryLauncher = + rememberLauncherForActivityResult(PickVisualMedia()) { uri: Uri? -> + if (uri != null) onImageCaptured(uri.toString()) + } + + val cameraState: CameraUiState by viewModel.uiState.collectAsStateWithLifecycle() + val captureState by viewModel.captureState.collectAsStateWithLifecycle() + + LaunchedEffect(galleryLauncherOpened) { + if (galleryLauncherOpened) { + delay(DELAY_MILLIS) + galleryLauncher + .launch(PickVisualMediaRequest(ImageOnly)) + .also { + galleryLauncherOpened = false + } + } + } + + if (cameraPermissionState.status.isGranted) { + SideEffect { + viewModel.initializeCamera() + } + CameraScreen( + onBackPressed = onBackClick, + cameraUiState = cameraState, + captureState = captureState, + onCameraStartPreview = viewModel::startPreview, + onCameraSwitch = viewModel::switchCamera, + onCameraCapture = viewModel::capturePhoto, + onCameraZoom = viewModel::scale, + onCameraFocus = viewModel::focus, + onModeChanged = viewModel::setExtensionMode, + onImageCaptured = { uri -> + onImageCaptured(uri) + viewModel.resetCaptureState() + }, + onShowSnackbar = onShowSnackbar, + onGalleryLauncherOpened = { + galleryLauncherOpened = true + }, + ) + } else { + NoPermissionScreen( + shouldShowRationale = cameraPermissionState.status.shouldShowRationale, + onBackClick = onBackClick, + onGalleryLauncherOpened = { galleryLauncherOpened = true }, + onRequestPermission = { + cameraPermissionState.launchPermissionRequest() + }, + ) + } +} + +private const val DELAY_MILLIS = 700L + +@Suppress("detekt:LongParameterList", "LongMethod") +@Composable +fun CameraScreen( + cameraUiState: CameraUiState, + captureState: CaptureState, + onBackPressed: () -> Unit, + onCameraStartPreview: (LifecycleOwner, PreviewView) -> Unit, + onCameraSwitch: () -> Unit, + onCameraCapture: (Context) -> Unit, + onCameraZoom: (Float) -> Unit, + onCameraFocus: (MeteringPoint) -> Unit, + onModeChanged: (Int) -> Unit, + onImageCaptured: (String) -> Unit, + onGalleryLauncherOpened: () -> Unit, + onShowSnackbar: suspend (String, String?, SnackbarDuration?) -> Boolean, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + TrackScreenViewEvent(screenName = "Camera") + LaunchedEffect(captureState) { + when (captureState) { + CaptureState.CaptureNotReady -> Unit + is CaptureState.ImageObtained -> { + captureState.uri?.let { + onImageCaptured(it.toString()) + } + } + + is CaptureState.CaptureFailed -> { + scope.launch { + onShowSnackbar( + captureState.toString(), + null, + SnackbarDuration.Short, + ) + } + } + } + } + + Box(modifier = modifier) { + CameraPreviewView( + camera = cameraUiState.camera, + lensFacing = cameraUiState.cameraLens, + extensionMode = cameraUiState.extensionMode, + availableExtensions = cameraUiState.availableExtensions, + onPreviewStart = onCameraStartPreview, + ) { cameraUIAction -> + when (cameraUIAction) { + is CameraUIAction.OnCameraClick -> { + onCameraCapture(context) + } + + is CameraUIAction.OnSwitchCameraClick -> onCameraSwitch.invoke() + is CameraUIAction.OnGalleryViewClick -> onGalleryLauncherOpened.invoke() + + is CameraUIAction.Scale -> { + onCameraZoom(cameraUIAction.scaleFactor) + } + + is CameraUIAction.Focus -> { + onCameraFocus(cameraUIAction.meteringPoint) + } + + is CameraUIAction.SelectCameraExtension -> { + onModeChanged(cameraUIAction.extension) + } + } + } + + Column( + modifier = Modifier.align(Alignment.TopStart), + horizontalAlignment = Alignment.Start, + ) { + Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) + IconButton( + onClick = onBackPressed, + modifier = Modifier + .padding(16.dp) + .background( + color = MaterialTheme.colorScheme.surface, + shape = CircleShape, + ), + ) { + Icon(PsIcons.Back, null, tint = Color.Black) + } + } + } +} diff --git a/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/photoCapture/CameraViewModel.kt b/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/photoCapture/CameraViewModel.kt new file mode 100644 index 00000000..54c65697 --- /dev/null +++ b/feature/camera/src/main/java/com/github/andiim/plantscan/feature/camera/photoCapture/CameraViewModel.kt @@ -0,0 +1,284 @@ +package com.github.andiim.plantscan.feature.camera.photoCapture + +import android.content.ContentResolver +import android.content.ContentValues +import android.content.Context +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import androidx.camera.core.Camera +import androidx.camera.core.CameraSelector +import androidx.camera.core.FocusMeteringAction +import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCaptureException +import androidx.camera.core.MeteringPoint +import androidx.camera.core.Preview +import androidx.camera.core.UseCaseGroup +import androidx.camera.core.resolutionselector.AspectRatioStrategy +import androidx.camera.core.resolutionselector.ResolutionSelector +import androidx.camera.extensions.ExtensionMode +import androidx.camera.extensions.ExtensionsManager +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.andiim.plantscan.core.network.AppDispatchers +import com.github.andiim.plantscan.core.network.Dispatcher +import com.github.andiim.plantscan.feature.camera.FILENAME +import com.github.andiim.plantscan.feature.camera.PHOTO_EXTENSION +import com.github.andiim.plantscan.feature.camera.R +import com.github.andiim.plantscan.feature.camera.model.CameraState +import com.github.andiim.plantscan.feature.camera.model.CameraUiState +import com.github.andiim.plantscan.feature.camera.model.CaptureState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Locale +import javax.inject.Inject + +@HiltViewModel +class CameraViewModel @Inject constructor( + @Dispatcher(AppDispatchers.Default) private val defaultDispatcher: CoroutineDispatcher, + private val provider: ProcessCameraProvider, + private val manager: ExtensionsManager, +) : ViewModel() { + private var resolver: ContentResolver? = null + var camera: Camera? = null + private val resolution = ResolutionSelector.Builder() + .setAllowedResolutionMode( + ResolutionSelector.PREFER_CAPTURE_RATE_OVER_HIGHER_RESOLUTION, + ) + .setAspectRatioStrategy(AspectRatioStrategy.RATIO_4_3_FALLBACK_AUTO_STRATEGY) + .build() + + private val imageCapture: ImageCapture = + ImageCapture.Builder().setResolutionSelector(resolution).build() + private val preview = + Preview.Builder().setResolutionSelector(resolution).build() + + private val _uiState: MutableStateFlow = MutableStateFlow(CameraUiState()) + private val _captureState: MutableStateFlow = + MutableStateFlow(CaptureState.CaptureNotReady) + + val uiState = _uiState.asStateFlow() + val captureState = _captureState.asStateFlow() + + /** + * Initializes the camera and checks which extensions are available for the selected camera lens + * face. If no extensions are available then the selected extension will be set to None and the + * available extensions list will also contain None. Because this operation is async, clients + * should wait for uiState to emit CameraState.READY. Once the camera is ready the client + * can start the preview. + */ + fun initializeCamera() { + viewModelScope.launch { + val currentUiState = _uiState.value + + // get the camera selector for the select lens face + val cameraSelector = cameraLensToSelector(currentUiState.cameraLens) + val availableCameraLens = + listOf( + CameraSelector.LENS_FACING_BACK, + CameraSelector.LENS_FACING_FRONT, + ).filter { lensFacing -> + provider.hasCamera(cameraLensToSelector(lensFacing)) + } + + // get the supported extensions for the selected camera lens by filtering the full list + // of extensions and checking each one if it's available + val availableExtensions = + listOf( + ExtensionMode.AUTO, + ExtensionMode.BOKEH, + ExtensionMode.HDR, + ExtensionMode.NIGHT, + ExtensionMode.FACE_RETOUCH, + ) + .filter { extensionMode -> + manager.isExtensionAvailable(cameraSelector, extensionMode) + } + + // prepare the new camera UI state which is now in the READY state and contains the list + // of available extensions, available lens faces. + val newuiState = + currentUiState.copy( + cameraState = CameraState.READY, + availableExtensions = listOf(ExtensionMode.NONE) + availableExtensions, + availableCameraLens = availableCameraLens, + extensionMode = + if (availableExtensions.isEmpty()) { + ExtensionMode.NONE + } else { + currentUiState.extensionMode + }, + ) + _uiState.emit(newuiState) + } + } + + /** + * Starts the preview stream. The camera state should be in the READY or PREVIEW_STOPPED state + * when calling this operation. This process will bind the preview and image capture uses cases to + * the camera provider. + */ + fun startPreview(lifecycleOwner: LifecycleOwner, previewView: PreviewView) { + val currentCameraUiState = _uiState.value + val cameraSelector = + if (currentCameraUiState.extensionMode == ExtensionMode.NONE) { + cameraLensToSelector(currentCameraUiState.cameraLens) + } else { + manager.getExtensionEnabledCameraSelector( + cameraLensToSelector(currentCameraUiState.cameraLens), + currentCameraUiState.extensionMode, + ) + } + val useCaseGroup = + UseCaseGroup.Builder() + .setViewPort(previewView.viewPort!!) + .addUseCase(imageCapture) + .addUseCase(preview) + .build() + provider.unbindAll() + camera = provider.bindToLifecycle(lifecycleOwner, cameraSelector, useCaseGroup) + preview.setSurfaceProvider(previewView.surfaceProvider) + + _uiState.value = _uiState.value.copy(camera = camera, cameraState = CameraState.READY) + } + + fun switchCamera() { + val currentCameraUiState = _uiState.value + if (currentCameraUiState.cameraState == CameraState.READY) { + // To switch the camera lens, there has to be at least 2 camera lenses + if (currentCameraUiState.availableCameraLens.size == 1) return + + val camLensFacing = currentCameraUiState.cameraLens + // Toggle the lens facing + val newCameraUiState = + if (camLensFacing == CameraSelector.LENS_FACING_BACK) { + currentCameraUiState.copy(cameraLens = CameraSelector.LENS_FACING_FRONT) + } else { + currentCameraUiState.copy(cameraLens = CameraSelector.LENS_FACING_BACK) + } + + _uiState.value = newCameraUiState.copy( + cameraState = CameraState.NOT_READY, + ) + + _captureState.value = CaptureState.CaptureNotReady + } + } + + /** + * Captures the photo and saves it to the pictures directory that's inside the app-specific + * directory on external storage. Upon successful capture, the captureUiState flow will emit + * CaptureFinished with the URI to the captured photo. If the capture operation failed then + * captureUiState flow will emit CaptureFailed with the exception containing more details on the + * reason for failure. + */ + fun capturePhoto(context: Context) { + val metadata = + ImageCapture.Metadata().apply { + // Mirror image when using the front camera + isReversedHorizontal = + _uiState.value.cameraLens == CameraSelector.LENS_FACING_FRONT + } + resolver = context.contentResolver + val dirname = context.getString(R.string.app_name) + val outputFileOptions = + getOutputFileOptions(dirname, resolver!!, metadata) // resolver is exactly created! + imageCapture.takePicture( + outputFileOptions, + defaultDispatcher.asExecutor(), + object : ImageCapture.OnImageSavedCallback { + override fun onImageSaved(output: ImageCapture.OutputFileResults) { + val savedUri = output.savedUri + _captureState.value = CaptureState.ImageObtained(savedUri) + } + + override fun onError(exception: ImageCaptureException) { + _captureState.value = CaptureState.CaptureFailed(exception) + } + }, + ) + } + + private fun getOutputFileOptions( + dirname: String, + resolver: ContentResolver, + metadata: ImageCapture.Metadata, + ): ImageCapture.OutputFileOptions { + val nowTimeStamp: Long = System.currentTimeMillis() + val imageName = SimpleDateFormat(FILENAME, Locale.US).format(nowTimeStamp) + val saveCollection: Uri = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> MediaStore.Images.Media.getContentUri( + MediaStore.VOLUME_EXTERNAL_PRIMARY, + ) + + else -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI + } + val contentValues: ContentValues = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, "$imageName$PHOTO_EXTENSION") + put(MediaStore.Images.Media.MIME_TYPE, "image/jpg") + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + put(MediaStore.MediaColumns.DATE_TAKEN, nowTimeStamp) + put( + MediaStore.MediaColumns.RELATIVE_PATH, + Environment.DIRECTORY_DCIM + "/" + dirname, + ) + put(MediaStore.MediaColumns.IS_PENDING, 1) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + put(MediaStore.Images.Media.DATE_TAKEN, nowTimeStamp) + put(MediaStore.Images.Media.DATE_ADDED, nowTimeStamp) + put(MediaStore.Images.Media.DATE_MODIFIED, nowTimeStamp) + put(MediaStore.Images.Media.AUTHOR, "Your Name") + put(MediaStore.Images.Media.DESCRIPTION, "Your description") + } + } + return ImageCapture.OutputFileOptions.Builder( + resolver, + saveCollection, + contentValues, + ).setMetadata(metadata).build() + } + + /** Sets the current extension mode. This will force the camera to rebind the use cases. */ + fun setExtensionMode(@ExtensionMode.Mode extensionMode: Int) { + _uiState.value = _uiState.value.copy( + cameraState = CameraState.NOT_READY, + extensionMode = extensionMode, + ) + } + + fun focus(meteringPoint: MeteringPoint) { + val camera = camera ?: return + + val meteringAction = FocusMeteringAction.Builder(meteringPoint).build() + camera.cameraControl.startFocusAndMetering(meteringAction) + } + + fun scale(scaleFactor: Float) { + val camera = camera ?: return + val currentZoomRatio: Float = camera.cameraInfo.zoomState.value?.zoomRatio ?: 1f + camera.cameraControl.setZoomRatio(scaleFactor * currentZoomRatio) + } + + private fun cameraLensToSelector(@CameraSelector.LensFacing lensFacing: Int): CameraSelector = + when (lensFacing) { + CameraSelector.LENS_FACING_FRONT -> CameraSelector.DEFAULT_FRONT_CAMERA + CameraSelector.LENS_FACING_BACK -> CameraSelector.DEFAULT_BACK_CAMERA + else -> throw IllegalArgumentException("Invalid lens facing type: $lensFacing") + } + + fun resetCaptureState() { + _captureState.value = CaptureState.CaptureNotReady + } +} diff --git a/feature/camera/src/main/res/color/button.xml b/feature/camera/src/main/res/color/button.xml new file mode 100644 index 00000000..53a09b28 --- /dev/null +++ b/feature/camera/src/main/res/color/button.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/feature/camera/src/main/res/drawable/ic_camera_shutter.xml b/feature/camera/src/main/res/drawable/ic_camera_shutter.xml new file mode 100644 index 00000000..395cb5a4 --- /dev/null +++ b/feature/camera/src/main/res/drawable/ic_camera_shutter.xml @@ -0,0 +1,9 @@ + + + diff --git a/feature/camera/src/main/res/drawable/ic_flip_camera_android.xml b/feature/camera/src/main/res/drawable/ic_flip_camera_android.xml new file mode 100644 index 00000000..cd65003c --- /dev/null +++ b/feature/camera/src/main/res/drawable/ic_flip_camera_android.xml @@ -0,0 +1,9 @@ + + + diff --git a/feature/camera/src/main/res/values/colors.xml b/feature/camera/src/main/res/values/colors.xml new file mode 100644 index 00000000..028dd597 --- /dev/null +++ b/feature/camera/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #FFFFFFFF + \ No newline at end of file diff --git a/feature/camera/src/main/res/values/strings.xml b/feature/camera/src/main/res/values/strings.xml new file mode 100644 index 00000000..851ff269 --- /dev/null +++ b/feature/camera/src/main/res/values/strings.xml @@ -0,0 +1,19 @@ + + + Please grant the permission to use the camera to use the core functionality of this app. + Camera permission required for this feature to be available. Please grant the permission. + Grant permission + Back + Flip Camera + Shutter + Open Gallery + PlantScan + + + None + Auto + Bokeh + HDR + Night + Face Retouch + \ No newline at end of file diff --git a/feature/camera/src/main/res/xml/file_paths.xml b/feature/camera/src/main/res/xml/file_paths.xml new file mode 100644 index 00000000..8767216d --- /dev/null +++ b/feature/camera/src/main/res/xml/file_paths.xml @@ -0,0 +1,11 @@ + + + + + + + \ No newline at end of file diff --git a/feature/detect-detail/.gitignore b/feature/detect-detail/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/detect-detail/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/detect-detail/build.gradle.kts b/feature/detect-detail/build.gradle.kts new file mode 100644 index 00000000..e8233134 --- /dev/null +++ b/feature/detect-detail/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + alias(libs.plugins.android.feature) + alias(libs.plugins.android.library.compose) + alias(libs.plugins.android.library.jacoco) +} + +android { + namespace = "com.github.andiim.plantscan.feature.detect.detail" +} + +dependencies { + implementation(libs.kotlinx.datetime) +} diff --git a/feature/detect-detail/src/main/AndroidManifest.xml b/feature/detect-detail/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/feature/detect-detail/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature/detect-detail/src/main/java/com/github/andiim/plantscan/feature/detect/detail/DetectDetailScreen.kt b/feature/detect-detail/src/main/java/com/github/andiim/plantscan/feature/detect/detail/DetectDetailScreen.kt new file mode 100644 index 00000000..6ccba18f --- /dev/null +++ b/feature/detect-detail/src/main/java/com/github/andiim/plantscan/feature/detect/detail/DetectDetailScreen.kt @@ -0,0 +1,116 @@ +package com.github.andiim.plantscan.feature.detect.detail + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsTopHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.Text +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.Alignment +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.github.andiim.plantscan.core.designsystem.component.PsTopAppBar +import com.github.andiim.plantscan.core.designsystem.icon.PsIcons +import com.github.andiim.plantscan.core.ui.components.DetectImage +import com.github.andiim.plantscan.core.ui.components.DetectResultImage +import com.github.andiim.plantscan.core.ui.components.SuggestButton +import com.github.andiim.plantscan.core.ui.extensions.toFormattedDate + +@Composable +fun DetectDetailRoute( + onBackClick: () -> Unit, + onSuggestClick: () -> Unit, + viewModel: DetectDetailViewModel = hiltViewModel(), +) { + val result by viewModel.detail.collectAsStateWithLifecycle() + DetectDetailScreen( + onBackClick = onBackClick, + onSuggestClick = onSuggestClick, + uiState = result, + ) +} + +private const val PERCENTAGE = 100 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DetectDetailScreen( + uiState: DetectDetailUiState, + onBackClick: () -> Unit, + onSuggestClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val scrollState = rememberLazyListState() + var showPreview by remember { mutableStateOf(false) } + Box(modifier = modifier) { + LazyColumn(state = scrollState, horizontalAlignment = Alignment.CenterHorizontally) { + item { + Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) + } + when (uiState) { + DetectDetailUiState.Loading -> item { + CircularProgressIndicator() + } + + is DetectDetailUiState.Success -> { + item { + val date = uiState.history.timestamp.toFormattedDate() + PsTopAppBar( + titleRes = R.string.detect_screen_title, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon(PsIcons.Back, null) + } + }, + ) + DetectImage( + imageData = uiState.history.image, + onImageClick = { showPreview = !showPreview }, + ) + ListItem( + headlineContent = { Text(text = "Timestamp") }, + trailingContent = { + Text(text = date) + }, + ) + } + uiState.history.detections.forEach { data -> + item { + val acc = data.confidence.times(PERCENTAGE) + ListItem( + headlineContent = { Text(text = data.objectClass) }, + trailingContent = { + Text(text = "%.2f%%".format(acc)) + }, + ) + } + } + + item { SuggestButton(onClick = onSuggestClick) } + } + } + } + + if (showPreview && uiState is DetectDetailUiState.Success) { + DetectResultImage( + imageData = uiState.history.image, + onShowPreview = { + showPreview = !showPreview + }, + ) + } + } +} diff --git a/feature/detect-detail/src/main/java/com/github/andiim/plantscan/feature/detect/detail/DetectDetailViewModel.kt b/feature/detect-detail/src/main/java/com/github/andiim/plantscan/feature/detect/detail/DetectDetailViewModel.kt new file mode 100644 index 00000000..ac54522b --- /dev/null +++ b/feature/detect-detail/src/main/java/com/github/andiim/plantscan/feature/detect/detail/DetectDetailViewModel.kt @@ -0,0 +1,33 @@ +package com.github.andiim.plantscan.feature.detect.detail + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.andiim.plantscan.core.domain.GetDetectionDetailsUseCase +import com.github.andiim.plantscan.core.model.data.DetectionHistory +import com.github.andiim.plantscan.feature.detect.detail.navigation.HistoryArgs +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +@HiltViewModel +class DetectDetailViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + getDetail: GetDetectionDetailsUseCase, +) : ViewModel() { + private val historyId = HistoryArgs(savedStateHandle).id + val detail = getDetail(historyId).map { + DetectDetailUiState.Success(it) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = DetectDetailUiState.Loading, + ) +} + +sealed interface DetectDetailUiState { + data object Loading : DetectDetailUiState + data class Success(val history: DetectionHistory) : DetectDetailUiState +} diff --git a/feature/detect-detail/src/main/java/com/github/andiim/plantscan/feature/detect/detail/navigation/DetectDetailNavigation.kt b/feature/detect-detail/src/main/java/com/github/andiim/plantscan/feature/detect/detail/navigation/DetectDetailNavigation.kt new file mode 100644 index 00000000..62470be1 --- /dev/null +++ b/feature/detect-detail/src/main/java/com/github/andiim/plantscan/feature/detect/detail/navigation/DetectDetailNavigation.kt @@ -0,0 +1,48 @@ +package com.github.andiim.plantscan.feature.detect.detail.navigation + +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.github.andiim.plantscan.core.ui.navigation.AppDestination +import com.github.andiim.plantscan.feature.detect.detail.DetectDetailRoute +import java.net.URLDecoder +import java.net.URLEncoder + +private val urlCharacterEncoding: String = Charsets.UTF_8.name() + +internal class HistoryArgs(val id: String) { + constructor(savedStateHandle: SavedStateHandle) : this( + URLDecoder.decode( + checkNotNull(savedStateHandle[DetectDetail.historyArg]), + urlCharacterEncoding, + ), + ) +} + +fun NavController.navigateToDetectionDetail(uri: String) { + val encodedUri = URLEncoder.encode(uri, urlCharacterEncoding) + this.navigate("${DetectDetail.route}/$encodedUri") { + launchSingleTop = true + } +} + +object DetectDetail : AppDestination { + override val route: String = "detect_route" + const val historyArg = "uri" + val routeWithArgs = "$route/{$historyArg}" + val arguments = listOf( + navArgument(historyArg) { type = NavType.StringType }, + ) +} + +fun NavGraphBuilder.detectDetailScreen( + onBackClick: () -> Unit, + onSuggestClick: () -> Unit, +) { + composable(route = DetectDetail.routeWithArgs, arguments = DetectDetail.arguments) { + DetectDetailRoute(onBackClick = onBackClick, onSuggestClick = onSuggestClick) + } +} diff --git a/feature/detect-detail/src/main/res/values/strings.xml b/feature/detect-detail/src/main/res/values/strings.xml new file mode 100644 index 00000000..55096067 --- /dev/null +++ b/feature/detect-detail/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + + Not sure for the result? + give a suggestion + Result + Fetch image failed + Can\'t detect or not available in dataset! + \ No newline at end of file diff --git a/feature/detect/.gitignore b/feature/detect/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/detect/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/detect/README.md b/feature/detect/README.md new file mode 100644 index 00000000..e69de29b diff --git a/feature/detect/build.gradle.kts b/feature/detect/build.gradle.kts new file mode 100644 index 00000000..904f9764 --- /dev/null +++ b/feature/detect/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + alias(libs.plugins.android.feature) + alias(libs.plugins.android.library.compose) + alias(libs.plugins.android.library.jacoco) + id("kotlin-parcelize") +} + +android { + namespace = "com.github.andiim.plantscan.feature.detect" +} + +dependencies { + implementation(project(":core:storage-upload")) + implementation(libs.kotlinx.datetime) + implementation(libs.timber) +} diff --git a/feature/detect/src/main/AndroidManifest.xml b/feature/detect/src/main/AndroidManifest.xml new file mode 100644 index 00000000..568741e5 --- /dev/null +++ b/feature/detect/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/detect/src/main/java/com/github/andiim/plantscan/feature/detect/DetectViewModel.kt b/feature/detect/src/main/java/com/github/andiim/plantscan/feature/detect/DetectViewModel.kt new file mode 100644 index 00000000..c9b7990e --- /dev/null +++ b/feature/detect/src/main/java/com/github/andiim/plantscan/feature/detect/DetectViewModel.kt @@ -0,0 +1,174 @@ +package com.github.andiim.plantscan.feature.detect + +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Rect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.net.toUri +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.andiim.plantscan.core.bitmap.asBase64 +import com.github.andiim.plantscan.core.bitmap.getBitmap +import com.github.andiim.plantscan.core.data.repository.DetectRepository +import com.github.andiim.plantscan.core.domain.GetUserIdUsecase +import com.github.andiim.plantscan.core.model.data.ObjectDetection +import com.github.andiim.plantscan.core.model.data.Prediction +import com.github.andiim.plantscan.core.result.Result +import com.github.andiim.plantscan.core.result.asResult +import com.github.andiim.plantscan.feature.detect.navigation.DetectArgs +import com.github.andiim.plantscan.feature.detect.service.UploadService +import com.github.andiim.plantscan.feature.detect.service.model.DetectionResult +import com.github.andiim.plantscan.feature.detect.service.model.buildDetectionDataFromPrediction +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class DetectViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val getUserLoginInfo: GetUserIdUsecase, + private val detectRepository: DetectRepository, +) : ViewModel() { + private val args: DetectArgs = DetectArgs(savedStateHandle) + val uri = args.uri.toUri() + + var status by mutableStateOf(DetectStatus.Preview) + private set + var showDialog by mutableStateOf(false) + private set + + var uiState by mutableStateOf(DetectUiState.Loading) + private set + + fun detect(context: Context) { + showDialog = true + val img = context.getBitmap(uri) + viewModelScope.launch { + detectRepository.detect(img.asBase64()).asResult().collectLatest { + when (it) { + Result.Loading -> { + uiState = DetectUiState.Loading + } + + is Result.Success -> { + Timber.d("Log in ${it.data.time}") + val imgResult = findObjects(it.data.predictions).applyToImage(img) + val result = it.data.apply { + image = image.copy(base64 = imgResult.asBase64()) + } + val detections = + it.data.predictions.map(::buildDetectionDataFromPrediction) + + result.predictions + .find { item -> + item == result + .predictions + .maxByOrNull { det -> det.confidence } + }?.let { predict -> + val intent = Intent(context, UploadService::class.java) + val historyData = DetectionResult( + imgB64 = imgResult.asBase64(), + userId = getUserLoginInfo().ifEmpty { "Anonymous" }, + accuracy = predict.confidence, + detections = detections, + ) + intent.putExtra(UploadService.EXTRA_DETECTION, historyData) + context.startService(intent) + } + + uiState = DetectUiState.Success(result) + showDialog = false + } + + is Result.Error -> { + uiState = DetectUiState.Error(it.exception?.message) + Timber.tag("Camera Error").e(it.exception, "detect: %s", null) + } + } + } + status = DetectStatus.Result + } + } + + private fun findObjects(predictions: List): List = + predictions.map { results -> + with(results) { + val text = "$jsonMemberClass, ${confidence.times(100).toInt()}%" + val rect = Rect( + (x - (width / 2)).toInt(), + (y - (height / 2)).toInt(), + (x + (width / 2)).toInt(), + (y + (height / 2)).toInt(), + ) + BoxWithText(rect, text) + } + } + + private fun List.applyToImage( + image: Bitmap, + strokeWidth: Float = 8f, + maxFontSize: Float = 96f, + ): Bitmap { + if (isEmpty()) return image + val outputBitmap = image.copy(Bitmap.Config.ARGB_8888, true) + val canvas = Canvas(outputBitmap) + val pen = Paint().apply { + textAlign = Paint.Align.LEFT + } + + this.forEach { + // draw bounding box + pen.color = Color.RED + pen.strokeWidth = strokeWidth + pen.style = Paint.Style.STROKE + val box = it.box + canvas.drawRect(box, pen) + + val tagSize = Rect(0, 0, 0, 0) + + // calculate the right font size + pen.style = Paint.Style.FILL_AND_STROKE + pen.color = Color.YELLOW + pen.strokeWidth = 2F + + pen.textSize = maxFontSize + pen.getTextBounds(it.text, 0, it.text.length, tagSize) + val fontSize: Float = pen.textSize * box.width() / tagSize.width() + + // adjust the font size so texts are inside the bounding box + if (fontSize < pen.textSize) pen.textSize = fontSize + + var margin = (box.width() - tagSize.width()) / 2.0F + if (margin < 0F) margin = 0F + canvas.drawText(it.text, box.left + margin, box.top + tagSize.height().times(1F), pen) + } + return outputBitmap + } + + fun deleteImageFromUri(context: Context) { + val contentResolver = context.contentResolver + contentResolver.delete(uri, null, null) + } +} + +data class BoxWithText(val box: Rect, val text: String) + +sealed interface DetectStatus { + data object Preview : DetectStatus + data object Result : DetectStatus +} + +sealed interface DetectUiState { + data object Loading : DetectUiState + data class Success(val detection: ObjectDetection) : DetectUiState + data class Error(val message: String? = null) : DetectUiState +} diff --git a/feature/detect/src/main/java/com/github/andiim/plantscan/feature/detect/DetektScreen.kt b/feature/detect/src/main/java/com/github/andiim/plantscan/feature/detect/DetektScreen.kt new file mode 100644 index 00000000..53244f7f --- /dev/null +++ b/feature/detect/src/main/java/com/github/andiim/plantscan/feature/detect/DetektScreen.kt @@ -0,0 +1,229 @@ +package com.github.andiim.plantscan.feature.detect + +import android.net.Uri +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.layout.windowInsetsTopHeight +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import coil.compose.rememberAsyncImagePainter +import coil.request.ImageRequest +import coil.size.Size +import com.github.andiim.plantscan.core.bitmap.asImageFromBase64 +import com.github.andiim.plantscan.core.designsystem.component.PsButton +import com.github.andiim.plantscan.core.designsystem.component.PsTopAppBar +import com.github.andiim.plantscan.core.designsystem.icon.PsIcons +import com.github.andiim.plantscan.core.model.data.ObjectDetection +import com.github.andiim.plantscan.core.ui.components.DetectImage +import com.github.andiim.plantscan.core.ui.components.DetectResultImage +import com.github.andiim.plantscan.core.ui.components.SuggestButton + +@Composable +fun DetectRoute( + onBackClick: () -> Unit, + onSuggestClick: () -> Unit, + viewModel: DetectViewModel = hiltViewModel(), + onShowSnackbar: suspend (String, String?, SnackbarDuration?) -> Boolean, +) { + val context = LocalContext.current + with(viewModel) { + when (status) { + DetectStatus.Preview -> { + DetectPreview( + uri = uri, + isDialogShow = showDialog, + onBackPressed = { + onBackClick() + viewModel.deleteImageFromUri(context) + }, + onDetectClick = { detect(context) }, + ) + } + + DetectStatus.Result -> { + when (val state = uiState) { + DetectUiState.Loading -> Unit + is DetectUiState.Error -> LaunchedEffect(state.message) { + onShowSnackbar(state.message.toString(), null, null) + onBackClick() + } + + is DetectUiState.Success -> { + DetectScreen( + onBackClick = onBackClick, + onSuggestClick = onSuggestClick, + result = state.detection, + ) + } + } + } + } + } +} + +@Composable +fun DetectPreview( + isDialogShow: Boolean, + uri: Uri, + onBackPressed: () -> Unit, + onDetectClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(LocalContext.current) + .data(uri) + .size(Size.ORIGINAL).build(), + ) + Box( + modifier = modifier + .fillMaxSize() + .background(Color.Black), + ) { + Image( + painter = painter, + null, + contentScale = ContentScale.FillWidth, + modifier = Modifier + .fillMaxSize() + .align(Alignment.Center), + ) + Column(modifier = Modifier.align(Alignment.TopStart)) { + Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) + IconButton( + onClick = onBackPressed, + modifier = Modifier + .padding(16.dp) + .background( + color = MaterialTheme.colorScheme.surface, + shape = CircleShape, + ), + ) { + Icon(PsIcons.Back, null, tint = Color.Black) + } + } + + Column(modifier = Modifier.align(Alignment.BottomCenter)) { + PsButton(onClick = onDetectClick) { + Text(text = stringResource(R.string.detect_this_image)) + } + Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) + } + + if (isDialogShow) { + AlertDialogLoading() + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AlertDialogLoading() { + AlertDialog(onDismissRequest = {}) { + Surface( + modifier = Modifier + .wrapContentWidth() + .wrapContentHeight(), + shape = MaterialTheme.shapes.large, + tonalElevation = AlertDialogDefaults.TonalElevation, + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CircularProgressIndicator() + Text("Detecting...") + } + } + } +} + +private const val PERCENTAGE = 100 + +@Suppress("detekt:LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DetectScreen( + result: ObjectDetection, + onBackClick: () -> Unit, + onSuggestClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val scrollState = rememberLazyListState() + var showPreview by remember { mutableStateOf(false) } + val imgBitmap = result.image.base64.asImageFromBase64() + + Box(modifier = modifier) { + LazyColumn(state = scrollState, horizontalAlignment = Alignment.CenterHorizontally) { + item { + Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) + PsTopAppBar( + titleRes = R.string.detect_screen_title, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon(PsIcons.Back, null) + } + }, + ) + DetectImage(imageData = imgBitmap, onImageClick = { showPreview = !showPreview }) + } + + if (result.predictions.isEmpty()) { + item { Text(text = stringResource(R.string.predict_is_empty)) } + } + result.predictions.forEach { data -> + item { + val acc = data.confidence.times(PERCENTAGE) + ListItem( + headlineContent = { Text(text = data.jsonMemberClass) }, + trailingContent = { Text(text = "%.2f%%".format(acc)) }, + ) + } + } + + item { SuggestButton(onClick = onSuggestClick) } + } + + if (showPreview) { + DetectResultImage( + imageData = imgBitmap, + onShowPreview = { showPreview = !showPreview }, + ) + } + } +} diff --git a/feature/detect/src/main/java/com/github/andiim/plantscan/feature/detect/navigation/DetectNavigation.kt b/feature/detect/src/main/java/com/github/andiim/plantscan/feature/detect/navigation/DetectNavigation.kt new file mode 100644 index 00000000..fc25a379 --- /dev/null +++ b/feature/detect/src/main/java/com/github/andiim/plantscan/feature/detect/navigation/DetectNavigation.kt @@ -0,0 +1,54 @@ +package com.github.andiim.plantscan.feature.detect.navigation + +import androidx.compose.material3.SnackbarDuration +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.github.andiim.plantscan.core.ui.navigation.AppDestination +import com.github.andiim.plantscan.feature.detect.DetectRoute +import java.net.URLDecoder +import java.net.URLEncoder + +private val urlCharacterEncoding: String = Charsets.UTF_8.name() + +internal class DetectArgs(val uri: String) { + constructor(savedStateHandle: SavedStateHandle) : this( + URLDecoder.decode( + checkNotNull(savedStateHandle[Detect.detectArg]), + urlCharacterEncoding, + ), + ) +} + +fun NavController.navigateToDetection(uri: String) { + val encodedUri = URLEncoder.encode(uri, urlCharacterEncoding) + this.navigate("${Detect.route}/$encodedUri") { + launchSingleTop = true + } +} + +object Detect : AppDestination { + override val route: String = "detect_route" + const val detectArg = "uri" + val routeWithArgs = "$route/{$detectArg}" + val arguments = listOf( + navArgument(detectArg) { type = NavType.StringType }, + ) +} + +fun NavGraphBuilder.detectScreen( + onBackClick: () -> Unit, + onSuggestClick: () -> Unit, + onShowSnackbar: suspend (String, String?, SnackbarDuration?) -> Boolean, +) { + composable(route = Detect.routeWithArgs, arguments = Detect.arguments) { + DetectRoute( + onBackClick = onBackClick, + onSuggestClick = onSuggestClick, + onShowSnackbar = onShowSnackbar, + ) + } +} diff --git a/feature/detect/src/main/java/com/github/andiim/plantscan/feature/detect/service/UploadService.kt b/feature/detect/src/main/java/com/github/andiim/plantscan/feature/detect/service/UploadService.kt new file mode 100644 index 00000000..335d2489 --- /dev/null +++ b/feature/detect/src/main/java/com/github/andiim/plantscan/feature/detect/service/UploadService.kt @@ -0,0 +1,96 @@ +package com.github.andiim.plantscan.feature.detect.service + +import android.app.Service +import android.content.Intent +import android.os.Build +import android.os.IBinder +import com.github.andiim.plantscan.core.bitmap.asImageFromBase64 +import com.github.andiim.plantscan.core.domain.PostDetectionRecord +import com.github.andiim.plantscan.core.model.data.DetectionHistory +import com.github.andiim.plantscan.core.model.data.LabelPredict +import com.github.andiim.plantscan.core.storageUpload.StorageHelper +import com.github.andiim.plantscan.feature.detect.service.model.DetectionResult +import com.github.andiim.plantscan.feature.detect.service.model.mapToLabelPredict +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.datetime.Clock +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import timber.log.Timber +import javax.inject.Inject + +@AndroidEntryPoint +class UploadService : Service() { + @Inject + lateinit var record: PostDetectionRecord + + @Inject + lateinit var storageHelper: StorageHelper + + private var serviceJob = Job() + + private var scope = CoroutineScope(Dispatchers.Main + serviceJob) + override fun onBind(intent: Intent?): IBinder? = null + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + scope.launch { + val data = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(EXTRA_DETECTION, DetectionResult::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(EXTRA_DETECTION) + } + + @Suppress("detekt:TooGenericExceptionCaught") + try { + data?.let { result -> + val timestamp = Clock.System.now() + val utcDatetime = timestamp.toLocalDateTime(TimeZone.UTC).date + val image = result.imgB64.asImageFromBase64() + val baseLocation = "history/$utcDatetime/${timestamp}_${data.userId}" + + val imageLink = storageHelper.upload(image, baseLocation).first() + val detections = data.detections.map(HashMap::mapToLabelPredict) + val recordId = record( + DetectionHistory( + id = "${Clock.System.now()}_${data.userId}", + timestamp = Clock.System.now(), + plantRef = detections.first { + it.confidence == detections.maxOf(LabelPredict::confidence) + }.objectClass, + userId = data.userId, + accuracy = data.accuracy, + image = imageLink, + detections = detections, + ), + ).first() + Timber.d("Recorded $recordId") + sendBroadcast( + Intent(ACTION_RETURN_SERVICE) + .putExtra(EXTRA_RETURN_DETECTION, recordId), + ) + } + } catch (e: Exception) { + Timber.e("onStartCommand: Error", e) + } + stopSelf() + } + + return START_NOT_STICKY + } + + override fun onDestroy() { + super.onDestroy() + serviceJob.cancel() + Timber.d("Finish Record") + } + + companion object { + const val ACTION_RETURN_SERVICE = "action_return_service" + const val EXTRA_DETECTION = "extra_detection" + const val EXTRA_RETURN_DETECTION = "extra_return_detection" + } +} diff --git a/feature/detect/src/main/java/com/github/andiim/plantscan/feature/detect/service/model/DetectionResult.kt b/feature/detect/src/main/java/com/github/andiim/plantscan/feature/detect/service/model/DetectionResult.kt new file mode 100644 index 00000000..38147b95 --- /dev/null +++ b/feature/detect/src/main/java/com/github/andiim/plantscan/feature/detect/service/model/DetectionResult.kt @@ -0,0 +1,36 @@ +package com.github.andiim.plantscan.feature.detect.service.model + +import android.os.Parcelable +import com.github.andiim.plantscan.core.model.data.LabelPredict +import com.github.andiim.plantscan.core.model.data.Prediction +import kotlinx.parcelize.Parcelize +import java.time.Instant + +@Parcelize +data class DetectionResult( + val id: String? = null, + val timestamp: Instant? = null, + val imgB64: String, + val userId: String, + val accuracy: Float, + val detections: List>, +) : Parcelable + +private const val OBJECT_CLASS = "objectClass" +private const val CONFIDENCE = "confidence" +fun buildDetectionData(objectClass: String, confidence: Float): HashMap { + return hashMapOf( + OBJECT_CLASS to objectClass, + CONFIDENCE to confidence.toString(), + ) +} + +fun buildDetectionDataFromPrediction(prediction: Prediction): HashMap = + buildDetectionData(prediction.jsonMemberClass, prediction.confidence) + +fun HashMap.mapToLabelPredict(): LabelPredict { + require(this.size == 2) { "Must contain 2 data" } + val classObject = this[OBJECT_CLASS]!! + val confidence = this[CONFIDENCE]!!.toFloat() + return LabelPredict(classObject, confidence) +} diff --git a/feature/detect/src/main/res/drawable/orchid.webp b/feature/detect/src/main/res/drawable/orchid.webp new file mode 100644 index 00000000..6a9825c2 Binary files /dev/null and b/feature/detect/src/main/res/drawable/orchid.webp differ diff --git a/feature/detect/src/main/res/values/strings.xml b/feature/detect/src/main/res/values/strings.xml new file mode 100644 index 00000000..e7d4ac94 --- /dev/null +++ b/feature/detect/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + + Result + + Can\'t detect or not available in dataset! + Detect this image + Data can\'t be null + \ No newline at end of file diff --git a/feature/findplant/.gitignore b/feature/findplant/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/findplant/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/findplant/README.md b/feature/findplant/README.md new file mode 100644 index 00000000..e69de29b diff --git a/feature/findplant/build.gradle.kts b/feature/findplant/build.gradle.kts new file mode 100644 index 00000000..7bc52329 --- /dev/null +++ b/feature/findplant/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + alias(libs.plugins.android.feature) + alias(libs.plugins.android.library.compose) + alias(libs.plugins.android.library.jacoco) +} + +android { + namespace = "com.github.andiim.plantscan.feature.findplant" +} + +dependencies { +} diff --git a/feature/findplant/src/main/AndroidManifest.xml b/feature/findplant/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/feature/findplant/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature/findplant/src/main/java/com/github/andiim/plantscan/feature/findplant/FindPlantScreen.kt b/feature/findplant/src/main/java/com/github/andiim/plantscan/feature/findplant/FindPlantScreen.kt new file mode 100644 index 00000000..e56d2fe5 --- /dev/null +++ b/feature/findplant/src/main/java/com/github/andiim/plantscan/feature/findplant/FindPlantScreen.kt @@ -0,0 +1,428 @@ +package com.github.andiim.plantscan.feature.findplant + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.SearchBar +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.isTraversalGroup +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.github.andiim.plantscan.core.designsystem.component.PsButton +import com.github.andiim.plantscan.core.designsystem.component.RoundIconButton +import com.github.andiim.plantscan.core.designsystem.component.scrollbar.DraggableScrollbar +import com.github.andiim.plantscan.core.designsystem.component.scrollbar.rememberDraggableScroller +import com.github.andiim.plantscan.core.designsystem.component.scrollbar.scrollbarState +import com.github.andiim.plantscan.core.designsystem.icon.PsIcons +import com.github.andiim.plantscan.core.designsystem.theme.PsTheme +import com.github.andiim.plantscan.core.model.data.Plant +import com.github.andiim.plantscan.core.ui.DevicePreviews +import com.github.andiim.plantscan.core.ui.PlantCard +import com.github.andiim.plantscan.core.ui.TrackScreenViewEvent +import com.github.andiim.plantscan.feature.findplant.R.string as AppText + +@Composable +internal fun FindPlantRoute( + onPlantClick: (String) -> Unit, + onCameraClick: () -> Unit, + onPlantsClick: () -> Unit, + modifier: Modifier = Modifier, + viewModel: FindPlantViewModel = hiltViewModel(), +) { + val recentSearchQueriesUiState by viewModel.recentSearchQueriesUiState.collectAsStateWithLifecycle() + val searchResultUiState by viewModel.searchResultUiState.collectAsStateWithLifecycle() + val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() + FindPlantScreen( + modifier = modifier, + onClearRecentSearches = viewModel::clearRecentSearches, + onSearchTriggered = viewModel::onSearchTriggered, + onQueryChanged = viewModel::onSearchQueryChanged, + searchQuery = searchQuery, + onPlantClick = onPlantClick, + onPlantsClick = onPlantsClick, + onCameraClick = onCameraClick, + recentSearchUiState = recentSearchQueriesUiState, + searchResultUiState = searchResultUiState, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun FindPlantScreen( + modifier: Modifier = Modifier, + onClearRecentSearches: () -> Unit = {}, + onSearchTriggered: (String) -> Unit = {}, + onQueryChanged: (String) -> Unit = {}, + searchQuery: String = "", + onPlantClick: (String) -> Unit = {}, + onCameraClick: () -> Unit = {}, + onPlantsClick: () -> Unit = {}, + recentSearchUiState: RecentSearchQueriesUiState = RecentSearchQueriesUiState.Loading, + searchResultUiState: SearchResultUiState = SearchResultUiState.Loading, +) { + var active by rememberSaveable { mutableStateOf(false) } + + TrackScreenViewEvent(screenName = "FindPlant") + Box( + modifier = modifier + .fillMaxSize() + .semantics { isTraversalGroup = true }, + ) { + SearchContent( + query = searchQuery, + onQueryChange = onQueryChanged, + onSearch = onSearchTriggered, + active = active, + onActiveChange = { status -> active = status }, + onItemClick = onPlantClick, + onClearRecentSearches = onClearRecentSearches, + recentSearchUiState = recentSearchUiState, + searchResultUiState = searchResultUiState, + modifier = Modifier.align(Alignment.TopCenter), + ) + + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + TooltipBox( + positionProvider = TooltipDefaults.rememberRichTooltipPositionProvider(), + tooltip = { + PlainTooltip { + Text(stringResource(R.string.camera_button_tooltip)) + } + }, + state = rememberTooltipState(), + ) { + RoundIconButton( + imageVector = PsIcons.Camera, + contentDescription = stringResource(R.string.camera_button_tooltip), + onClick = onCameraClick, + ) + } + } + + val fraction = 0.8f + PsButton( + onClick = onPlantsClick, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth(fraction) + .padding(horizontal = 16.dp), + ) { + Text(text = "To list") + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Suppress("detekt:LongMethod") +@Composable +private fun SearchContent( + query: String, + onQueryChange: (String) -> Unit, + onSearch: (String) -> Unit, + active: Boolean, + onActiveChange: (Boolean) -> Unit, + onClearRecentSearches: () -> Unit, + onItemClick: (String) -> Unit, + modifier: Modifier = Modifier, + recentSearchUiState: RecentSearchQueriesUiState = RecentSearchQueriesUiState.Loading, + searchResultUiState: SearchResultUiState = SearchResultUiState.Loading, +) { + SearchBar( + query = query, + onQueryChange = onQueryChange, + onSearch = onSearch, + active = active, + onActiveChange = onActiveChange, + leadingIcon = { + Icon(PsIcons.Search, contentDescription = stringResource(R.string.testing)) + }, + trailingIcon = { + if (active) { + IconButton( + onClick = { + onActiveChange(false) + onQueryChange("") + }, + ) { + Icon( + PsIcons.Close, + contentDescription = stringResource(AppText.search_icon_close_description), + ) + } + } + }, + modifier = modifier, + ) { + Text( + text = "Still under construction ...", + style = MaterialTheme.typography.displaySmall, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) + when (searchResultUiState) { + SearchResultUiState.Loading, + SearchResultUiState.LoadFailed, + -> Unit + + SearchResultUiState.SearchNotReady -> SearchNotReadyBody() + SearchResultUiState.EmptyQuery -> { + if (recentSearchUiState is RecentSearchQueriesUiState.Success) { + RecentSearchesBody( + onClearRecentSearches = onClearRecentSearches, + onRecentSearchClicked = { + onQueryChange(it) + onSearch(it) + }, + recentSearchQueries = SearchQueries(recentSearchUiState.recentQueries.map { it.query }), + ) + } + } + + is SearchResultUiState.Success -> { + if (searchResultUiState.isEmpty()) { + EmptySearchResultBody(searchQuery = query) + if (recentSearchUiState is RecentSearchQueriesUiState.Success) { + RecentSearchesBody( + onClearRecentSearches = onClearRecentSearches, + onRecentSearchClicked = { + onQueryChange(it) + onSearch(it) + }, + recentSearchQueries = SearchQueries(recentSearchUiState.recentQueries.map { it.query }), + ) + } + } else { + SearchResultBody( + onPlantClick = onItemClick, + plants = Plants(searchResultUiState.plants), + ) + } + } + } + Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) + } +} + +@Composable +private fun SearchNotReadyBody() { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 48.dp), + ) { + Text( + text = "", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier.padding(vertical = 14.dp), + ) + } +} + +@Immutable +data class SearchQueries(val items: List) + +@Composable +fun RecentSearchesBody( + modifier: Modifier = Modifier, + onClearRecentSearches: () -> Unit = {}, + onRecentSearchClicked: (String) -> Unit = {}, + recentSearchQueries: SearchQueries = SearchQueries(listOf()), +) { + Column(modifier = modifier) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = buildAnnotatedString { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(stringResource(R.string.recent_searches)) + } + }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + + if (recentSearchQueries.items.isNotEmpty()) { + IconButton( + onClick = onClearRecentSearches, + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Icon( + imageVector = PsIcons.Close, + contentDescription = stringResource(AppText.search_clear_search_text), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + + LazyColumn(modifier = Modifier.padding(horizontal = 16.dp)) { + items(recentSearchQueries.items) { recentSearch -> + Text( + text = recentSearch, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier + .padding(vertical = 16.dp) + .clickable { + onRecentSearchClicked(recentSearch) + } + .fillMaxWidth(), + ) + } + } + } + } +} + +@Composable +fun EmptySearchResultBody( + searchQuery: String, + modifier: Modifier = Modifier, +) { + val message = stringResource(id = AppText.search_icon_close_description, searchQuery) + val start = message.indexOf(searchQuery) + Text( + modifier = modifier, + text = AnnotatedString( + text = message, + spanStyles = listOf( + AnnotatedString.Range( + SpanStyle(fontWeight = FontWeight.Bold), + start = start, + end = start + searchQuery.length, + ), + ), + ), + ) +} + +@Immutable +data class Plants(val items: List) + +@Composable +private fun SearchResultBody( + plants: Plants, + onPlantClick: (String) -> Unit, +) { + val state = rememberLazyGridState() + Box( + modifier = Modifier.fillMaxSize(), + ) { + LazyVerticalGrid( + columns = GridCells.Adaptive(300.dp), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier + .fillMaxSize() + .testTag("search:plant"), + state = state, + ) { + if (plants.items.isNotEmpty()) { + item( + span = { + GridItemSpan(maxLineSpan) + }, + ) { + Text( + text = buildAnnotatedString { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append("Plants") + } + }, + ) + } + plants.items.forEach { plant -> + val plantId = plant.name + item( + key = "plant-$plantId", + span = { + GridItemSpan(maxLineSpan) + }, + ) { + PlantCard(plant = plant, onClick = { onPlantClick(plantId) }) + } + } + } + } + val itemsAvailable = plants.items.size + val scrollbarState = state.scrollbarState( + itemsAvailable = itemsAvailable, + ) + state.DraggableScrollbar( + modifier = Modifier + .fillMaxHeight() + .windowInsetsPadding(WindowInsets.systemBars) + .padding(horizontal = 2.dp) + .align(Alignment.CenterEnd), + state = scrollbarState, + orientation = Orientation.Vertical, + onThumbMoved = state.rememberDraggableScroller(itemsAvailable = itemsAvailable), + ) + } +} + +@DevicePreviews +@Composable +fun Preview_CameraContent() { + PsTheme { + Surface { + FindPlantScreen( + onSearchTriggered = {}, + onQueryChanged = {}, + onPlantClick = {}, + onCameraClick = { /*TODO*/ }, + onPlantsClick = { /*TODO*/ }, + ) + } + } +} diff --git a/feature/findplant/src/main/java/com/github/andiim/plantscan/feature/findplant/FindPlantViewModel.kt b/feature/findplant/src/main/java/com/github/andiim/plantscan/feature/findplant/FindPlantViewModel.kt new file mode 100644 index 00000000..393250a5 --- /dev/null +++ b/feature/findplant/src/main/java/com/github/andiim/plantscan/feature/findplant/FindPlantViewModel.kt @@ -0,0 +1,108 @@ +package com.github.andiim.plantscan.feature.findplant + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.andiim.plantscan.core.analytics.AnalyticsEvent +import com.github.andiim.plantscan.core.analytics.AnalyticsEvent.Param +import com.github.andiim.plantscan.core.analytics.AnalyticsHelper +import com.github.andiim.plantscan.core.data.repository.RecentSearchRepository +import com.github.andiim.plantscan.core.domain.GetRecentSearchQueriesUseCase +import com.github.andiim.plantscan.core.domain.GetSearchContentsCountUseCase +import com.github.andiim.plantscan.core.domain.GetSearchContentsUseCase +import com.github.andiim.plantscan.core.result.Result +import com.github.andiim.plantscan.core.result.asResult +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class FindPlantViewModel @Inject constructor( + getSearchContentsUseCase: GetSearchContentsUseCase, + getSearchContentsCountUseCase: GetSearchContentsCountUseCase, + recentSearchQueriesUseCase: GetRecentSearchQueriesUseCase, + private val recentSearchRepository: RecentSearchRepository, + private val savedStateHandle: SavedStateHandle, + private val analyticsHelper: AnalyticsHelper, +) : ViewModel() { + val searchQuery: StateFlow = savedStateHandle.getStateFlow(SEARCH_QUERY, "") + val searchResultUiState: StateFlow = + getSearchContentsCountUseCase().flatMapLatest { totalCount -> + if (totalCount < SEARCH_MIN_FTS_ENTITY_COUNT) { + flowOf(SearchResultUiState.SearchNotReady) + } else { + searchQuery.flatMapLatest { query -> + if (query.length < SEARCH_QUERY_MIN_LENGTH) { + flowOf(SearchResultUiState.EmptyQuery) + } else { + getSearchContentsUseCase(query).asResult().map { + when (it) { + is Result.Success -> { + SearchResultUiState.Success( + plants = it.data.plants, + ) + } + + Result.Loading -> SearchResultUiState.Loading + is Result.Error -> SearchResultUiState.LoadFailed + } + } + } + } + } + }.stateIn( + scope = viewModelScope, + started = WhileSubscribed(5_000), + initialValue = SearchResultUiState.Loading, + ) + val recentSearchQueriesUiState: StateFlow = + recentSearchQueriesUseCase().map(RecentSearchQueriesUiState::Success) + .stateIn( + scope = viewModelScope, + started = WhileSubscribed(5_000), + initialValue = RecentSearchQueriesUiState.Loading, + ) + + fun onSearchQueryChanged(query: String) { + savedStateHandle[SEARCH_QUERY] = query + } + + /** + * Called when the search option is explicitly triggered by the user. For example, when the + * search icon is tapped in the IME or when the enter key is pressed in the search text field. + * + * The search results are displayed on the fly as the user types, but to explicitly save the + * search query in the text field, defining this method. + * + * @param query the invoke string + */ + fun onSearchTriggered(query: String) { + viewModelScope.launch { + recentSearchRepository.insertOrReplaceRecentSearch(query) + } + analyticsHelper.logEvent( + AnalyticsEvent( + type = SEARCH_QUERY, + extras = listOf( + Param(SEARCH_QUERY, query), + ), + ), + ) + } + + fun clearRecentSearches() { + viewModelScope.launch { + recentSearchRepository.clearRecentSearches() + } + } +} + +private const val SEARCH_QUERY_MIN_LENGTH = 2 +private const val SEARCH_MIN_FTS_ENTITY_COUNT = 1 +private const val SEARCH_QUERY = "searchQuery" diff --git a/feature/findplant/src/main/java/com/github/andiim/plantscan/feature/findplant/RecentSearchQueriesUiState.kt b/feature/findplant/src/main/java/com/github/andiim/plantscan/feature/findplant/RecentSearchQueriesUiState.kt new file mode 100644 index 00000000..98575c5c --- /dev/null +++ b/feature/findplant/src/main/java/com/github/andiim/plantscan/feature/findplant/RecentSearchQueriesUiState.kt @@ -0,0 +1,10 @@ +package com.github.andiim.plantscan.feature.findplant + +import com.github.andiim.plantscan.core.data.model.RecentSearchQuery + +sealed interface RecentSearchQueriesUiState { + data object Loading : RecentSearchQueriesUiState + data class Success( + val recentQueries: List = emptyList(), + ) : RecentSearchQueriesUiState +} diff --git a/feature/findplant/src/main/java/com/github/andiim/plantscan/feature/findplant/SearchResultUiState.kt b/feature/findplant/src/main/java/com/github/andiim/plantscan/feature/findplant/SearchResultUiState.kt new file mode 100644 index 00000000..d96d8063 --- /dev/null +++ b/feature/findplant/src/main/java/com/github/andiim/plantscan/feature/findplant/SearchResultUiState.kt @@ -0,0 +1,27 @@ +package com.github.andiim.plantscan.feature.findplant + +import com.github.andiim.plantscan.core.model.data.Plant + +sealed interface SearchResultUiState { + data object Loading : SearchResultUiState + + /** + * The state query is empty or too short. To distinguish the state between the + * (initial state or when the search query is cleared) vs the state where no search + * result is returned, explicitly define the empty query state. + */ + data object EmptyQuery : SearchResultUiState + data object LoadFailed : SearchResultUiState + + data class Success( + val plants: List = emptyList(), + ) : SearchResultUiState { + fun isEmpty(): Boolean = plants.isEmpty() + } + + /** + * A state where the search contents are not ready. This happens when the *Fts tables are not + * populated yet. + */ + data object SearchNotReady : SearchResultUiState +} diff --git a/feature/findplant/src/main/java/com/github/andiim/plantscan/feature/findplant/navigation/FindPlantNavigation.kt b/feature/findplant/src/main/java/com/github/andiim/plantscan/feature/findplant/navigation/FindPlantNavigation.kt new file mode 100644 index 00000000..65eaab0f --- /dev/null +++ b/feature/findplant/src/main/java/com/github/andiim/plantscan/feature/findplant/navigation/FindPlantNavigation.kt @@ -0,0 +1,45 @@ +package com.github.andiim.plantscan.feature.findplant.navigation + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import androidx.navigation.navigation +import com.github.andiim.plantscan.core.ui.navigation.AppDestination +import com.github.andiim.plantscan.feature.findplant.FindPlantRoute + +fun NavController.navigateToFindPlant(navOptions: NavOptions? = null) { + this.navigate(FindPlantGraph.route, navOptions) +} + +object FindPlantGraph : AppDestination { + override val route: String = "find_plant_graph" +} + +object FindPlant : AppDestination { + override val route: String = "find_plant_route" +} + +fun NavGraphBuilder.findPlantGraph( + onItemClick: (String) -> Unit, + onCameraClick: () -> Unit, + onPlantsClick: () -> Unit, + nestedGraphs: NavGraphBuilder.() -> Unit, +) { + navigation( + route = FindPlantGraph.route, + startDestination = FindPlant.route, + ) { + composable(FindPlant.route) { + FindPlantRoute( + modifier = Modifier.fillMaxSize(), + onPlantClick = onItemClick, + onCameraClick = onCameraClick, + onPlantsClick = onPlantsClick, + ) + } + nestedGraphs() + } +} diff --git a/feature/findplant/src/main/res/values/strings.xml b/feature/findplant/src/main/res/values/strings.xml new file mode 100644 index 00000000..96f10213 --- /dev/null +++ b/feature/findplant/src/main/res/values/strings.xml @@ -0,0 +1,14 @@ + + + Find plant + Search Plant + Search Icon + Close search bar + Search using camera detection + Find by plant type + Sorry, there is no plant with name \"%1$s\" + Testing + Clear searches + Recent searches + Detect with camera + \ No newline at end of file diff --git a/feature/history/.gitignore b/feature/history/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/history/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/history/README.md b/feature/history/README.md new file mode 100644 index 00000000..e69de29b diff --git a/feature/history/build.gradle.kts b/feature/history/build.gradle.kts new file mode 100644 index 00000000..6d94d098 --- /dev/null +++ b/feature/history/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + alias(libs.plugins.android.feature) + alias(libs.plugins.android.library.compose) + alias(libs.plugins.android.library.jacoco) +} + +android { + namespace = "com.github.andiim.plantscan.feature.history" +} + +dependencies { + implementation(libs.kotlinx.datetime) + implementation(libs.timber) +} diff --git a/feature/history/src/main/AndroidManifest.xml b/feature/history/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/feature/history/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature/history/src/main/java/com/github/andiim/plantscan/feature/history/HistoryScreen.kt b/feature/history/src/main/java/com/github/andiim/plantscan/feature/history/HistoryScreen.kt new file mode 100644 index 00000000..4f11b910 --- /dev/null +++ b/feature/history/src/main/java/com/github/andiim/plantscan/feature/history/HistoryScreen.kt @@ -0,0 +1,133 @@ +package com.github.andiim.plantscan.feature.history + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.github.andiim.plantscan.core.model.data.DetectionHistory +import com.github.andiim.plantscan.core.ui.TrackScreenViewEvent +import com.github.andiim.plantscan.core.ui.TrackScrollJank +import com.github.andiim.plantscan.core.ui.extensions.toFormattedDate +import com.github.andiim.plantscan.feature.history.navigation.History + +@Composable +internal fun HistoryRoute( + onItemClick: (String) -> Unit, + modifier: Modifier = Modifier, + viewModel: HistoryViewModel = hiltViewModel(), +) { + val historyUiState by viewModel.historyUiState.collectAsState() + TrackScreenViewEvent(screenName = History.route) + HistoryScreen( + historyState = historyUiState, + onItemClick = onItemClick, + modifier = modifier, + ) +} + +@Composable +internal fun HistoryScreen( + historyState: HistoryUiState, + onItemClick: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val scrollState = rememberLazyListState() + TrackScrollJank(scrollableState = scrollState, stateName = "detectionHistory") + Box(modifier = modifier) { + LazyColumn( + state = scrollState, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + when (historyState) { + HistoryUiState.Loading -> handleLoading() + is HistoryUiState.Error -> handleError( + "${historyState.throwable}", + modifier.align(Alignment.Center), + ) + + is HistoryUiState.Success -> handleSuccess( + historyState.data, + onItemClick = onItemClick, + modifier = Modifier.align(Alignment.Center), + ) + } + } + } +} + +private fun LazyListScope.handleLoading() { + item { + Box(modifier = Modifier.fillParentMaxSize()) { + CircularProgressIndicator( + modifier = Modifier + .padding(8.dp) + .testTag("loadingWheel"), + ) + } + } +} + +private fun LazyListScope.handleError(message: String, modifier: Modifier) { + item { Text(text = message, modifier = modifier.fillParentMaxSize()) } +} + +@OptIn(ExperimentalMaterial3Api::class) +private fun LazyListScope.handleSuccess( + detections: List, + onItemClick: (String) -> Unit, + modifier: Modifier = Modifier, +) { + if (detections.isNotEmpty()) { + items( + items = detections, + key = { "${it.id}${it.plantRef}" }, + itemContent = { history -> + val acc = history.accuracy * 100 + val time = history.timestamp.toFormattedDate() + Card( + onClick = { onItemClick(history.id!!) }, + shape = RectangleShape, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.tertiary), + elevation = CardDefaults.cardElevation(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + ListItem( + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + headlineContent = { Text(history.plantRef) }, + supportingContent = { Text(time) }, + trailingContent = { Text("%.2f%%".format(acc)) }, + ) + } + }, + ) + } else { + item { Text(text = "Empty List!", modifier = modifier.fillParentMaxSize()) } + } +} diff --git a/feature/history/src/main/java/com/github/andiim/plantscan/feature/history/HistoryUiState.kt b/feature/history/src/main/java/com/github/andiim/plantscan/feature/history/HistoryUiState.kt new file mode 100644 index 00000000..c85b6676 --- /dev/null +++ b/feature/history/src/main/java/com/github/andiim/plantscan/feature/history/HistoryUiState.kt @@ -0,0 +1,11 @@ +package com.github.andiim.plantscan.feature.history + +import com.github.andiim.plantscan.core.model.data.DetectionHistory + +sealed interface HistoryUiState { + data object Loading : HistoryUiState + data class Error(val throwable: Throwable?) : HistoryUiState + data class Success( + val data: List = emptyList(), + ) : HistoryUiState +} diff --git a/feature/history/src/main/java/com/github/andiim/plantscan/feature/history/HistoryViewModel.kt b/feature/history/src/main/java/com/github/andiim/plantscan/feature/history/HistoryViewModel.kt new file mode 100644 index 00000000..f8c764e4 --- /dev/null +++ b/feature/history/src/main/java/com/github/andiim/plantscan/feature/history/HistoryViewModel.kt @@ -0,0 +1,31 @@ +package com.github.andiim.plantscan.feature.history + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.andiim.plantscan.core.domain.GetHistoryUseCase +import com.github.andiim.plantscan.core.domain.GetUserIdUsecase +import com.github.andiim.plantscan.core.result.Result +import com.github.andiim.plantscan.core.result.asResult +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +@HiltViewModel +class HistoryViewModel @Inject constructor( + getHistoryUseCase: GetHistoryUseCase, + currentUserId: GetUserIdUsecase, +) : ViewModel() { + val historyUiState = getHistoryUseCase(currentUserId()).asResult().map { + when (it) { + Result.Loading -> HistoryUiState.Loading + is Result.Success -> HistoryUiState.Success(data = it.data) + is Result.Error -> HistoryUiState.Error(it.exception) + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = HistoryUiState.Loading, + ) +} diff --git a/feature/history/src/main/java/com/github/andiim/plantscan/feature/history/navigation/HistoryScreenNavigation.kt b/feature/history/src/main/java/com/github/andiim/plantscan/feature/history/navigation/HistoryScreenNavigation.kt new file mode 100644 index 00000000..03945a09 --- /dev/null +++ b/feature/history/src/main/java/com/github/andiim/plantscan/feature/history/navigation/HistoryScreenNavigation.kt @@ -0,0 +1,22 @@ +package com.github.andiim.plantscan.feature.history.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import com.github.andiim.plantscan.core.ui.navigation.AppDestination +import com.github.andiim.plantscan.feature.history.HistoryRoute + +fun NavController.navigateToHistory(navOptions: NavOptions? = null) { + this.navigate(History.route, navOptions) +} + +object History : AppDestination { + override val route: String = "history_route" +} + +fun NavGraphBuilder.historyScreen(onDetailClick: (String) -> Unit) { + composable(route = History.route) { + HistoryRoute(onItemClick = onDetailClick) + } +} diff --git a/feature/history/src/main/res/values/strings.xml b/feature/history/src/main/res/values/strings.xml new file mode 100644 index 00000000..ccb5a7f0 --- /dev/null +++ b/feature/history/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + History + \ No newline at end of file diff --git a/feature/plant/.gitignore b/feature/plant/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/plant/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/plant/build.gradle.kts b/feature/plant/build.gradle.kts new file mode 100644 index 00000000..c9f083dd --- /dev/null +++ b/feature/plant/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + alias(libs.plugins.android.feature) + alias(libs.plugins.android.library.compose) + alias(libs.plugins.android.library.jacoco) +} + +android { + namespace = "com.github.andiim.plantscan.feature.plant" +} + +dependencies { +} diff --git a/feature/plant/src/main/AndroidManifest.xml b/feature/plant/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/feature/plant/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature/plant/src/main/java/com/github/andiim/plantscan/feature/plant/PlantScreen.kt b/feature/plant/src/main/java/com/github/andiim/plantscan/feature/plant/PlantScreen.kt new file mode 100644 index 00000000..ed698934 --- /dev/null +++ b/feature/plant/src/main/java/com/github/andiim/plantscan/feature/plant/PlantScreen.kt @@ -0,0 +1,189 @@ +package com.github.andiim.plantscan.feature.plant + +import android.util.Log +import androidx.compose.foundation.gestures.Orientation +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.windowInsetsTopHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.github.andiim.plantscan.core.designsystem.component.PsBackground +import com.github.andiim.plantscan.core.designsystem.component.scrollbar.DraggableScrollbar +import com.github.andiim.plantscan.core.designsystem.component.scrollbar.rememberDraggableScroller +import com.github.andiim.plantscan.core.designsystem.component.scrollbar.scrollbarState +import com.github.andiim.plantscan.core.designsystem.icon.PsIcons +import com.github.andiim.plantscan.core.designsystem.theme.PsTheme +import com.github.andiim.plantscan.core.ui.TrackScreenViewEvent +import com.github.andiim.plantscan.core.ui.TrackScrollJank +import com.github.andiim.plantscan.core.ui.plantCardItems +import org.jetbrains.annotations.VisibleForTesting + +@Composable +internal fun PlantRoute( + onBackClick: () -> Unit, + onPlantClick: (String) -> Unit, + modifier: Modifier = Modifier, + viewModel: PlantViewModel = hiltViewModel(), +) { + val plantUiState: PlantUiState by viewModel.items.collectAsStateWithLifecycle() + Log.d("TAG", "PlantRoute: $plantUiState") + TrackScreenViewEvent(screenName = "Plants") + PlantScreen( + plantUiState = plantUiState, + onBackClick = onBackClick, + onPlantClick = onPlantClick, + modifier = modifier, + ) +} + +@VisibleForTesting +@Composable +internal fun PlantScreen( + plantUiState: PlantUiState, + onPlantClick: (String) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val state = rememberLazyListState() + TrackScrollJank(scrollableState = state, stateName = "plant:screen") + Box(modifier = modifier) { + LazyColumn( + state = state, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + ) { + item { + Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) + } + when (plantUiState) { + PlantUiState.Loading -> item { + CircularProgressIndicator(modifier = modifier) + } + + is PlantUiState.Error -> {} + is PlantUiState.Success -> { + item { + PlantToolbar( + onBackClick = onBackClick, + ) + } + plantBody( + plants = plantUiState, + onPlantClick = onPlantClick, + ) + } + } + item { + Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) + } + } + val itemsAvailable = plantItemSize(plantUiState) + val scrollbarState = state.scrollbarState(itemsAvailable = itemsAvailable) + state.DraggableScrollbar( + modifier = Modifier + .fillMaxHeight() + .windowInsetsPadding(WindowInsets.systemBars) + .padding(horizontal = 2.dp) + .align(Alignment.CenterEnd), + state = scrollbarState, + orientation = Orientation.Vertical, + onThumbMoved = state.rememberDraggableScroller(itemsAvailable = itemsAvailable), + ) + } +} + +private fun plantItemSize( + plantUiState: PlantUiState, +) = when (plantUiState) { + is PlantUiState.Error -> 0 // Nothing + PlantUiState.Loading -> 1 // Loading bar + is PlantUiState.Success -> 2 + plantUiState.plants.size // Toolbar, header +} + +@Composable +private fun PlantToolbar( + modifier: Modifier = Modifier, + onBackClick: () -> Unit = {}, +) { + Row( + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.fillMaxWidth() + ) { + IconButton(onClick = onBackClick) { + Icon( + imageVector = PsIcons.Back, + contentDescription = stringResource(R.string.back), + ) + } + } +} + +private fun LazyListScope.plantBody( + plants: PlantUiState, + onPlantClick: (String) -> Unit, + +) { + plantResourceCards(plants, onPlantClick) +} + +private fun LazyListScope.plantResourceCards( + plants: PlantUiState, + onPlantClick: (String) -> Unit, +) { + when (plants) { + is PlantUiState.Success -> { + plantCardItems( + items = plants.plants, + onClick = onPlantClick, + itemModifier = Modifier.padding(8.dp), + ) + } + + PlantUiState.Loading -> item { + CircularProgressIndicator() + } + + is PlantUiState.Error -> item { + Text("Error") + } + } +} + +@Preview +@Composable +fun PlantScreen_Preview() { + PsTheme { + PsBackground { + PlantScreen( + plantUiState = PlantUiState.Loading, + onBackClick = {}, + onPlantClick = {}, + ) + } + } +} diff --git a/feature/plant/src/main/java/com/github/andiim/plantscan/feature/plant/PlantViewModel.kt b/feature/plant/src/main/java/com/github/andiim/plantscan/feature/plant/PlantViewModel.kt new file mode 100644 index 00000000..6dcbbb86 --- /dev/null +++ b/feature/plant/src/main/java/com/github/andiim/plantscan/feature/plant/PlantViewModel.kt @@ -0,0 +1,42 @@ +package com.github.andiim.plantscan.feature.plant + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.andiim.plantscan.core.data.repository.PlantRepository +import com.github.andiim.plantscan.core.model.data.Plant +import com.github.andiim.plantscan.core.result.Result +import com.github.andiim.plantscan.core.result.asResult +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +@HiltViewModel +class PlantViewModel @Inject constructor( + repository: PlantRepository, +) : ViewModel() { + val items: StateFlow = repository.getPlants() + .asResult() + .map { result -> + when (result) { + is Result.Error -> { + PlantUiState.Error(result.exception) + } + Result.Loading -> PlantUiState.Loading + is Result.Success -> PlantUiState.Success(result.data) + } + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = PlantUiState.Loading, + ) +} + +sealed interface PlantUiState { + data class Success(val plants: List) : PlantUiState + data class Error(val throwable: Throwable?) : PlantUiState + data object Loading : PlantUiState +} diff --git a/feature/plant/src/main/java/com/github/andiim/plantscan/feature/plant/navigation/PlantNavigation.kt b/feature/plant/src/main/java/com/github/andiim/plantscan/feature/plant/navigation/PlantNavigation.kt new file mode 100644 index 00000000..188bc187 --- /dev/null +++ b/feature/plant/src/main/java/com/github/andiim/plantscan/feature/plant/navigation/PlantNavigation.kt @@ -0,0 +1,24 @@ +package com.github.andiim.plantscan.feature.plant.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.github.andiim.plantscan.core.ui.navigation.AppDestination +import com.github.andiim.plantscan.feature.plant.PlantRoute + +fun NavController.navigateToPlants() { + this.navigate(Plant.route) { launchSingleTop = true } +} + +object Plant : AppDestination { + override val route: String = "plant_route" +} + +fun NavGraphBuilder.plantScreen( + onBackClick: () -> Unit, + onPlantClick: (String) -> Unit, +) { + composable(Plant.route) { + PlantRoute(onBackClick = onBackClick, onPlantClick = onPlantClick) + } +} diff --git a/feature/plant/src/main/res/values/strings.xml b/feature/plant/src/main/res/values/strings.xml new file mode 100644 index 00000000..e4e621a6 --- /dev/null +++ b/feature/plant/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Back + \ No newline at end of file diff --git a/feature/plantDetail/.gitignore b/feature/plantDetail/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/plantDetail/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/plantDetail/build.gradle.kts b/feature/plantDetail/build.gradle.kts new file mode 100644 index 00000000..c6ce7ebb --- /dev/null +++ b/feature/plantDetail/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + alias(libs.plugins.android.feature) + alias(libs.plugins.android.library.compose) + alias(libs.plugins.android.library.jacoco) +} + +android { + namespace = "com.github.andiim.plantscan.feature.plantdetail" +} + +dependencies { +} diff --git a/feature/plantDetail/src/main/AndroidManifest.xml b/feature/plantDetail/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/feature/plantDetail/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature/plantDetail/src/main/java/com/github/andiim/plantscan/feature/plantdetail/PlantDetailScreen.kt b/feature/plantDetail/src/main/java/com/github/andiim/plantscan/feature/plantdetail/PlantDetailScreen.kt new file mode 100644 index 00000000..a6ea0a1f --- /dev/null +++ b/feature/plantDetail/src/main/java/com/github/andiim/plantscan/feature/plantdetail/PlantDetailScreen.kt @@ -0,0 +1,214 @@ +package com.github.andiim.plantscan.feature.plantdetail + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +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.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsTopHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Dangerous +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.SubcomposeAsyncImage +import coil.request.ImageRequest +import com.github.andiim.plantscan.core.designsystem.component.PsBackground +import com.github.andiim.plantscan.core.designsystem.icon.PsIcons +import com.github.andiim.plantscan.core.designsystem.theme.PsTheme +import com.github.andiim.plantscan.core.model.data.Plant +import com.github.andiim.plantscan.core.ui.TrackScreenViewEvent +import com.github.andiim.plantscan.core.designsystem.R.drawable as Drawable +import com.github.andiim.plantscan.core.ui.R.string as UiString + +@Composable +fun PlantDetailRoute( + onBackClick: () -> Unit, + modifier: Modifier = Modifier, + viewModel: PlantDetailViewModel = hiltViewModel(), +) { + val uiState: PlantUiState by viewModel.plantUiState.collectAsStateWithLifecycle() + TrackScreenViewEvent(screenName = "Plant: ${viewModel.plantId}") + PlantDetailScreen( + uiState = uiState, + onBackClick = onBackClick, + modifier = modifier, + ) +} + +@Composable +fun PlantDetailScreen( + uiState: PlantUiState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val state = rememberLazyListState() + Box(modifier = modifier) { + LazyColumn( + state = state, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + item { + Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) + } + when (uiState) { + PlantUiState.Loading -> item { + CircularProgressIndicator() + } + + is PlantUiState.Success -> { + item { + Toolbar(onBackClick = onBackClick) + } + // Body + detailBody(uiState.data) + } + } + } + } +} + +@Composable +fun Toolbar( + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .padding(8.dp), + ) { + IconButton( + onClick = onBackClick, + modifier = modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.background) + .size(32.dp), + ) { + Icon(imageVector = PsIcons.Back, contentDescription = null) + } + } +} + +private fun LazyListScope.detailBody( + plant: Plant, +) { + item { + DetailContent( + name = plant.name, + thumbnail = plant.thumbnail, + species = plant.species, + description = plant.description, + commonName = plant.commonName, + ) + } + // TODO: Taxonomy and images +} + +@Composable +private fun DetailContent( + name: String, + thumbnail: String, + species: String, + description: String, + commonName: List, + modifier: Modifier = Modifier, +) { + SubcomposeAsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(thumbnail) + .crossfade(true) + .build(), + loading = { + if (LocalInspectionMode.current) { + Image( + painter = painterResource(Drawable.orchid), + contentScale = ContentScale.FillWidth, + contentDescription = null, + ) + } else { + Box( + modifier = modifier.height(250.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(modifier = modifier.width(24.dp)) + } + } + }, + error = { + Box( + modifier = modifier.height(250.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.Dangerous, + contentDescription = stringResource(UiString.fetch_image_error), + ) + } + }, + contentDescription = "$name image", + contentScale = ContentScale.FillWidth, + modifier = modifier + .heightIn(max = 250.dp) + .fillMaxWidth(), + ) + + Text( + "$name, a species of $species", + modifier = Modifier + .fillMaxWidth() + .padding(4.dp), + style = MaterialTheme.typography.headlineSmall, + ) + if (commonName.isNotEmpty()) Text("Also Known As: ${commonName.joinToString()}") + HorizontalDivider() + Text( + description, + modifier.padding(top = 24.dp, start = 2.dp, end = 2.dp), + textAlign = TextAlign.Justify, + style = MaterialTheme.typography.bodyLarge, + ) +} + +@Preview +@Composable +fun PlantDetail_Preview() { + PsTheme { + PsBackground { + PlantDetailScreen(uiState = PlantUiState.Loading, onBackClick = {}) + } + } +} diff --git a/feature/plantDetail/src/main/java/com/github/andiim/plantscan/feature/plantdetail/PlantDetailViewModel.kt b/feature/plantDetail/src/main/java/com/github/andiim/plantscan/feature/plantdetail/PlantDetailViewModel.kt new file mode 100644 index 00000000..671b9648 --- /dev/null +++ b/feature/plantDetail/src/main/java/com/github/andiim/plantscan/feature/plantdetail/PlantDetailViewModel.kt @@ -0,0 +1,35 @@ +package com.github.andiim.plantscan.feature.plantdetail + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.andiim.plantscan.core.data.repository.PlantRepository +import com.github.andiim.plantscan.core.model.data.Plant +import com.github.andiim.plantscan.feature.plantdetail.navigation.PlantArgs +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +@HiltViewModel +class PlantDetailViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + plantRepository: PlantRepository, +) : ViewModel() { + val plantId: String = PlantArgs(savedStateHandle).plantId + + val plantUiState: StateFlow = plantRepository.getPlantById(plantId) + .map(PlantUiState::Success) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = PlantUiState.Loading, + ) +} + +sealed interface PlantUiState { + data object Loading : PlantUiState + data class Success(val data: Plant) : PlantUiState +} diff --git a/feature/plantDetail/src/main/java/com/github/andiim/plantscan/feature/plantdetail/navigation/PlantDetailNavigation.kt b/feature/plantDetail/src/main/java/com/github/andiim/plantscan/feature/plantdetail/navigation/PlantDetailNavigation.kt new file mode 100644 index 00000000..1f53c7bf --- /dev/null +++ b/feature/plantDetail/src/main/java/com/github/andiim/plantscan/feature/plantdetail/navigation/PlantDetailNavigation.kt @@ -0,0 +1,51 @@ +package com.github.andiim.plantscan.feature.plantdetail.navigation + +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.github.andiim.plantscan.core.ui.navigation.AppDestination +import com.github.andiim.plantscan.feature.plantdetail.PlantDetailRoute +import java.net.URLDecoder +import java.net.URLEncoder + +private val urlCharacterEncoding: String = Charsets.UTF_8.name() + +internal class PlantArgs(val plantId: String) { + constructor(savedStateHandle: SavedStateHandle) : + this( + URLDecoder.decode( + checkNotNull(savedStateHandle[PlantDetail.plantArg]), + urlCharacterEncoding, + ), + ) +} + +object PlantDetail : AppDestination { + override val route: String = "plant" + const val plantArg = "plantId" + val routeWithArgs = "$route/{$plantArg}" + val arguments = listOf( + navArgument(plantArg) { type = NavType.StringType }, + ) +} + +fun NavController.navigateToPlantDetail(plantId: String) { + val encodedId = URLEncoder.encode(plantId, urlCharacterEncoding) + this.navigate("${PlantDetail.route}/$encodedId") { + launchSingleTop = true + } +} + +fun NavGraphBuilder.plantDetailScreen( + onBackClick: () -> Unit, +) { + composable( + route = PlantDetail.routeWithArgs, + arguments = PlantDetail.arguments, + ) { + PlantDetailRoute(onBackClick = onBackClick) + } +} diff --git a/feature/settings/.gitignore b/feature/settings/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/settings/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts new file mode 100644 index 00000000..1fdacb14 --- /dev/null +++ b/feature/settings/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + alias(libs.plugins.android.feature) + alias(libs.plugins.android.library.compose) + alias(libs.plugins.android.library.jacoco) +} + +android { + namespace = "com.github.andiim.plantscan.feature.settings" +} + +dependencies { + implementation(project(":core:auth")) + implementation(libs.androidx.appcompat) + implementation(libs.google.oss.licenses){ + exclude(group = "androidx.appcompat") + } +} diff --git a/feature/settings/src/main/AndroidManifest.xml b/feature/settings/src/main/AndroidManifest.xml new file mode 100644 index 00000000..2f740f35 --- /dev/null +++ b/feature/settings/src/main/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/feature/settings/src/main/java/com/github/andiim/plantscan/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/github/andiim/plantscan/feature/settings/SettingsScreen.kt new file mode 100644 index 00000000..2891c3fc --- /dev/null +++ b/feature/settings/src/main/java/com/github/andiim/plantscan/feature/settings/SettingsScreen.kt @@ -0,0 +1,254 @@ +package com.github.andiim.plantscan.feature.settings + +import android.content.Intent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.github.andiim.plantscan.core.designsystem.component.PsTextButton +import com.github.andiim.plantscan.core.designsystem.icon.PsIcons +import com.github.andiim.plantscan.core.designsystem.theme.supportsDynamicTheming +import com.github.andiim.plantscan.core.model.data.DarkThemeConfig +import com.github.andiim.plantscan.feature.settings.R.string +import com.github.andiim.plantscan.feature.settings.components.SettingsButton +import com.github.andiim.plantscan.feature.settings.components.SettingsButtonWithAlert +import com.github.andiim.plantscan.feature.settings.components.SettingsChooser +import com.google.android.gms.oss.licenses.OssLicensesMenuActivity + +@Composable +fun SettingsRoute( + routeToAuth: () -> Unit, + viewModel: SettingsViewModel = hiltViewModel(), +) { + val uiState by viewModel.settingsUiState.collectAsStateWithLifecycle() + SettingsScreen( + uiState = uiState, + onAuthAction = routeToAuth, + onSignOutAction = viewModel::signOut, + onDeleteAccountAction = viewModel::deleteAccount, + onChangeDynamicColorPreference = viewModel::updateDynamicColorPreference, + onChangeDarkThemeConfig = viewModel::updateDarkThemeConfig, + ) +} + +@Composable +fun SettingsScreen( + uiState: SettingsUiState, + onAuthAction: () -> Unit, + onSignOutAction: () -> Unit, + onDeleteAccountAction: () -> Unit, + onChangeDynamicColorPreference: (useDynamicColor: Boolean) -> Unit, + modifier: Modifier = Modifier, + supportDynamicColor: Boolean = supportsDynamicTheming(), + onChangeDarkThemeConfig: (darkThemeConfig: DarkThemeConfig) -> Unit, +) { + LazyColumn(modifier.padding(horizontal = 16.dp)) { + when (uiState) { + SettingsUiState.Loading -> { + item { + Text( + stringResource(string.loading), + modifier = Modifier.padding(vertical = 16.dp), + ) + } + } + + is SettingsUiState.Success -> settingsPanel( + settings = uiState.settings, + supportDynamicColor = supportDynamicColor, + onAuthClick = onAuthAction, + onSignOutClick = onSignOutAction, + onDeleteAccountClick = onDeleteAccountAction, + onChangeDynamicColorPreference = onChangeDynamicColorPreference, + onChangeDarkThemeConfig = onChangeDarkThemeConfig, + ) + } + item { + HorizontalDivider(Modifier.padding(top = 8.dp)) + } + item { + LinksPanel() + } + } +} + +fun LazyListScope.settingsPanel( + settings: UserEditableSettings, + supportDynamicColor: Boolean, + onAuthClick: () -> Unit, + onSignOutClick: () -> Unit, + onDeleteAccountClick: () -> Unit, + onChangeDynamicColorPreference: (useDynamicColor: Boolean) -> Unit, + onChangeDarkThemeConfig: (darkThemeConfig: DarkThemeConfig) -> Unit, +) { + item { + AnimatedVisibility(supportDynamicColor) { + SettingsChooser( + checked = settings.useDynamicColor, + title = stringResource(string.dynamic_color_preference), + onCheckedChange = onChangeDynamicColorPreference, + ) + } + } + darkModeSettings(settings, onChangeDarkThemeConfig) + accountButtons(settings, onAuthClick, onSignOutClick, onDeleteAccountClick) +} + +private fun LazyListScope.darkModeSettings( + settings: UserEditableSettings, + onChangeDarkThemeConfig: (darkThemeConfig: DarkThemeConfig) -> Unit, +) { + item { + SettingsDialogSectionTitle(text = stringResource(string.dark_mode_preference)) + Column(Modifier.selectableGroup()) { + SettingsDialogThemeChooserRow( + text = stringResource(string.dark_mode_config_system_default), + selected = settings.darkThemeConfig == DarkThemeConfig.FOLLOW_SYSTEM, + onClick = { onChangeDarkThemeConfig(DarkThemeConfig.FOLLOW_SYSTEM) }, + ) + SettingsDialogThemeChooserRow( + text = stringResource(string.dark_mode_config_light), + selected = settings.darkThemeConfig == DarkThemeConfig.LIGHT, + onClick = { onChangeDarkThemeConfig(DarkThemeConfig.LIGHT) }, + ) + SettingsDialogThemeChooserRow( + text = stringResource(string.dark_mode_config_dark), + selected = settings.darkThemeConfig == DarkThemeConfig.DARK, + onClick = { onChangeDarkThemeConfig(DarkThemeConfig.DARK) }, + ) + } + } +} + +private fun LazyListScope.accountButtons( + settings: UserEditableSettings, + onAuthClick: () -> Unit, + onSignOutClick: () -> Unit, + onDeleteAccountClick: () -> Unit, +) { + item { + SettingsDialogSectionTitle(text = stringResource(string.account)) + if (!settings.isLogin) { + SettingsButton( + icon = PsIcons.Account, + title = stringResource(string.label_sign_in_sign_up), + onClick = onAuthClick, + ) + } else { + SettingsButtonWithAlert( + icon = PsIcons.Exit, + title = stringResource(string.sign_out), + alertTitle = stringResource(string.title_sign_out), + alertDesc = stringResource(string.description_sign_out), + onClick = onSignOutClick, + modifier = Modifier.padding(vertical = 4.dp), + ) + SettingsButtonWithAlert( + icon = PsIcons.Delete, + title = stringResource(string.label_delete_account), + alertTitle = stringResource(string.title_delete_account), + alertDesc = stringResource(string.description_delete_account), + onClick = onDeleteAccountClick, + modifier = Modifier.padding(vertical = 4.dp), + ) + } + } +} + +@Composable +private fun SettingsDialogSectionTitle(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 16.dp, bottom = 8.dp), + ) +} + +@Composable +fun SettingsDialogThemeChooserRow( + text: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier + .fillMaxWidth() + .selectable( + selected = selected, + role = Role.RadioButton, + onClick = onClick, + ) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = selected, + onClick = null, + ) + Spacer(Modifier.width(8.dp)) + Text(text) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun LinksPanel() { + FlowRow( + horizontalArrangement = Arrangement.spacedBy( + space = 16.dp, + alignment = Alignment.CenterHorizontally, + ), + modifier = Modifier.fillMaxWidth(), + ) { + val uriHandler = LocalUriHandler.current + PsTextButton( + onClick = { uriHandler.openUri(PRIVACY_POLICY_URL) }, + ) { + Text(text = stringResource(string.privacy_policy)) + } + val context = LocalContext.current + PsTextButton( + onClick = { + context.startActivity(Intent(context, OssLicensesMenuActivity::class.java)) + }, + ) { + Text(text = stringResource(string.licenses)) + } + PsTextButton( + onClick = { uriHandler.openUri(FEEDBACK_URL) }, + ) { + Text(text = stringResource(string.feedback)) + } + } +} + +@Suppress("detekt:MaxLineLength") +private const val PRIVACY_POLICY_URL = "https://support-orchid.web.app/privacy.html" +private const val FEEDBACK_URL = + "https://github.com/Andi-IM/PlantScan/issues/new?assignees=&labels=&projects=&template=bug_report.md" diff --git a/feature/settings/src/main/java/com/github/andiim/plantscan/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/github/andiim/plantscan/feature/settings/SettingsViewModel.kt new file mode 100644 index 00000000..9aad0c4b --- /dev/null +++ b/feature/settings/src/main/java/com/github/andiim/plantscan/feature/settings/SettingsViewModel.kt @@ -0,0 +1,84 @@ +package com.github.andiim.plantscan.feature.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.andiim.plantscan.core.auth.AuthHelper +import com.github.andiim.plantscan.core.data.repository.UserDataRepository +import com.github.andiim.plantscan.core.domain.GetUserLoginInfoUseCase +import com.github.andiim.plantscan.core.model.data.DarkThemeConfig +import com.github.andiim.plantscan.feature.settings.SettingsUiState.Loading +import com.github.andiim.plantscan.feature.settings.SettingsUiState.Success +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SettingsViewModel @Inject constructor( + private val userDataRepository: UserDataRepository, + private val authHelper: AuthHelper, + getUserLoginInfoUseCase: GetUserLoginInfoUseCase, +) : ViewModel() { + + val settingsUiState = combine( + getUserLoginInfoUseCase(), + userDataRepository.userData, + ) { remote, data -> + Success( + settings = UserEditableSettings( + isLogin = !remote.isAnonymous, + currentUser = remote.userId, + useDynamicColor = data.useDynamicColor, + darkThemeConfig = data.darkThemeConfig, + ), + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = Loading, + ) + + fun updateDarkThemeConfig(darkThemeConfig: DarkThemeConfig) { + viewModelScope.launch { + userDataRepository.setDarkThemeConfig(darkThemeConfig) + } + } + + fun updateDynamicColorPreference(useDynamicColor: Boolean) { + viewModelScope.launch { + userDataRepository.setDynamicColorPreference(useDynamicColor) + } + } + + fun signOut() { + viewModelScope.launch { + authHelper.signOut().collect() + userDataRepository.setLoginInfo() + } + } + + fun deleteAccount() { + viewModelScope.launch { + authHelper.deleteAccount().collect() + userDataRepository.setLoginInfo() + } + } +} + +/** + * Represents the settings which the user can edit within the app. + */ +data class UserEditableSettings( + val isLogin: Boolean, + val currentUser: String? = null, + val useDynamicColor: Boolean, + val darkThemeConfig: DarkThemeConfig, +) + +sealed interface SettingsUiState { + data object Loading : SettingsUiState + data class Success(val settings: UserEditableSettings) : SettingsUiState +} diff --git a/feature/settings/src/main/java/com/github/andiim/plantscan/feature/settings/components/Button.kt b/feature/settings/src/main/java/com/github/andiim/plantscan/feature/settings/components/Button.kt new file mode 100644 index 00000000..2c66e5b0 --- /dev/null +++ b/feature/settings/src/main/java/com/github/andiim/plantscan/feature/settings/components/Button.kt @@ -0,0 +1,114 @@ +package com.github.andiim.plantscan.feature.settings.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import com.github.andiim.plantscan.feature.settings.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsButton( + icon: ImageVector, + title: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.onPrimary), + modifier = modifier, + onClick = onClick, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Text( + text = title, + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.onSurface, + ) + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } +} + +@Composable +fun SettingsButtonWithAlert( + icon: ImageVector, + title: String, + alertTitle: String, + alertDesc: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + var showWarningDialog by remember { mutableStateOf(false) } + + SettingsButton( + icon = icon, + title = title, + onClick = { showWarningDialog = true }, + modifier = modifier, + ) + + if (showWarningDialog) { + AlertDialog( + properties = DialogProperties(usePlatformDefaultWidth = true), + modifier = Modifier.testTag("Sign Out Warning Dialog"), + onDismissRequest = { showWarningDialog = false }, + title = { Text(alertTitle) }, + text = { Text(alertDesc) }, + dismissButton = { + Button( + onClick = { showWarningDialog = false }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) { + Text(stringResource(R.string.cancel)) + } + }, + confirmButton = { + Button( + onClick = { + onClick() + showWarningDialog = false + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + ) { + Text(stringResource(R.string.confirm)) + } + }, + ) + } +} diff --git a/feature/settings/src/main/java/com/github/andiim/plantscan/feature/settings/components/Switch.kt b/feature/settings/src/main/java/com/github/andiim/plantscan/feature/settings/components/Switch.kt new file mode 100644 index 00000000..89eca17c --- /dev/null +++ b/feature/settings/src/main/java/com/github/andiim/plantscan/feature/settings/components/Switch.kt @@ -0,0 +1,55 @@ +package com.github.andiim.plantscan.feature.settings.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.github.andiim.plantscan.core.designsystem.icon.PsIcons + +@Composable +fun SettingsChooser( + checked: Boolean, + title: String, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.onPrimary), + modifier = modifier, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Text(text = title, modifier = Modifier.weight(1f)) + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + thumbContent = if (checked) { + { + Icon( + imageVector = PsIcons.Check, + contentDescription = null, + modifier = Modifier.size(SwitchDefaults.IconSize), + ) + } + } else { + null + }, + ) + } + } +} diff --git a/feature/settings/src/main/java/com/github/andiim/plantscan/feature/settings/navigation/SettingsNavigation.kt b/feature/settings/src/main/java/com/github/andiim/plantscan/feature/settings/navigation/SettingsNavigation.kt new file mode 100644 index 00000000..a5fd618c --- /dev/null +++ b/feature/settings/src/main/java/com/github/andiim/plantscan/feature/settings/navigation/SettingsNavigation.kt @@ -0,0 +1,52 @@ +package com.github.andiim.plantscan.feature.settings.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import androidx.navigation.navigation +import com.github.andiim.plantscan.core.ui.navigation.AppDestination +import com.github.andiim.plantscan.feature.settings.SettingsRoute + +fun NavController.navigateToSettings(navOptions: NavOptions? = null) { + this.navigate(SettingsGraphPattern.route, navOptions) +} + +fun NavController.clearAndNavigateSettings() { + this.navigate(Settings.route) { + popUpTo(graph.findStartDestination().id) { + saveState = true + } + // Avoid multiple copies of the same destination when + // reselecting the same item + launchSingleTop = true + // Restore state when reselecting a previously selected item + restoreState = true + } +} + +object SettingsGraphPattern : AppDestination { + override val route: String = "settings_graph" +} + +object Settings : AppDestination { + override val route: String = "settings_route" +} + +fun NavGraphBuilder.settingsGraph( + onLoginClick: () -> Unit, + nestedGraphs: NavGraphBuilder.() -> Unit, +) { + navigation( + route = SettingsGraphPattern.route, + startDestination = Settings.route, + ) { + composable(Settings.route) { + SettingsRoute( + routeToAuth = onLoginClick, + ) + } + nestedGraphs() + } +} diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml new file mode 100644 index 00000000..d396593e --- /dev/null +++ b/feature/settings/src/main/res/values/strings.xml @@ -0,0 +1,23 @@ + + + Settings + Loading… + Privacy policy + Licenses + Feedback + Use Dynamic Color + Dark Mode + System default + Light + Dark + Account + Sign in / Sign up + Cancel + Confirm + Sign out + Sign out? + You will have to sign in again to see your detection history. + Delete my account + Delete account? + You will lose all your tasks and your account will be deleted. This action is irreversible. + \ No newline at end of file diff --git a/feature/suggest/.gitignore b/feature/suggest/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/suggest/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/suggest/build.gradle.kts b/feature/suggest/build.gradle.kts new file mode 100644 index 00000000..9ce48bbf --- /dev/null +++ b/feature/suggest/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + alias(libs.plugins.android.feature) + alias(libs.plugins.android.library.compose) + alias(libs.plugins.android.library.jacoco) + id("kotlin-parcelize") +} + +android { + namespace = "com.github.andiim.plantscan.feature.suggest" +} + +dependencies { + implementation(project(":core:storage-upload")) + implementation(libs.kotlinx.datetime) +} diff --git a/feature/suggest/src/main/AndroidManifest.xml b/feature/suggest/src/main/AndroidManifest.xml new file mode 100644 index 00000000..568741e5 --- /dev/null +++ b/feature/suggest/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/suggest/src/main/java/com/github/andiim/plantscan/feature/suggest/SuggestScreen.kt b/feature/suggest/src/main/java/com/github/andiim/plantscan/feature/suggest/SuggestScreen.kt new file mode 100644 index 00000000..03aa4cee --- /dev/null +++ b/feature/suggest/src/main/java/com/github/andiim/plantscan/feature/suggest/SuggestScreen.kt @@ -0,0 +1,323 @@ +package com.github.andiim.plantscan.feature.suggest + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia.ImageOnly +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.github.andiim.plantscan.core.designsystem.component.PsBackground +import com.github.andiim.plantscan.core.designsystem.component.PsTopAppBar +import com.github.andiim.plantscan.core.designsystem.icon.PsIcons +import com.github.andiim.plantscan.core.designsystem.theme.PsTheme +import com.github.andiim.plantscan.core.model.data.Suggestion +import kotlinx.coroutines.launch + +@Composable +fun SuggestRoute( + onBackPressed: () -> Unit, + onLoginPressed: () -> Unit, + onShowSnackbar: suspend (String, String?, SnackbarDuration?) -> Boolean, + viewModel: SuggestViewModel = hiltViewModel(), +) { + val uiState by viewModel.sendSuggestState.collectAsStateWithLifecycle() + val userStatus by viewModel.userData.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + val context = LocalContext.current + + LaunchedEffect(uiState, userStatus) { + if (userStatus is Status.Denied) { + scope.launch { + val result = onShowSnackbar( + context.getString(R.string.suggestion_deny_message), + context.getString(R.string.suggestion_login_snackbar_action), + null, + ) + if (result) { + onLoginPressed() + } else { + onBackPressed.invoke() + } + } + } + + if (uiState is SuggestUiState.Complete) { + scope.launch { + onShowSnackbar(context.getString(R.string.suggestion_success_message), null, null) + onBackPressed.invoke() + } + } + } + + SuggestScreen( + isDialogShow = viewModel.showDialog, + suggestFormState = viewModel.suggestState, + userStatus = userStatus, + onBackPressed = onBackPressed, + onSuggestSend = { + viewModel.sendSuggestion(context) { + scope.launch { + onShowSnackbar(it, null, null) + } + } + }, + onSetDescription = viewModel::onDescriptionChange, + onImageSet = viewModel::onImageSet, + ) +} + +private const val MAX_ITEMS = 3 + +@Composable +fun SuggestScreen( + isDialogShow: Boolean, + userStatus: Status, + suggestFormState: Suggestion, + onBackPressed: () -> Unit, + onSuggestSend: () -> Unit, + onSetDescription: (String) -> Unit, + onImageSet: (List, Int) -> Unit, + modifier: Modifier = Modifier, +) { + val launcher = getMediaLauncher(callback = onImageSet) + val context = LocalContext.current + val keyboardController = LocalSoftwareKeyboardController.current + val enabled = userStatus is Status.Granted + + Box(modifier = modifier) { + LazyColumn { + toolbar(onBackPressed) + descriptionEditText( + enabled, + suggestFormState.description, + onSetDescription, + ) + placeImage( + enabled, + suggestFormState.images.map { + val contentResolver = context.contentResolver + BitmapFactory.decodeStream(contentResolver.openInputStream(Uri.parse(it))) + }, + MAX_ITEMS, + ) { + launcher.launch(PickVisualMediaRequest(ImageOnly)) + } + item { + Spacer(modifier = Modifier.padding(vertical = 4.dp, horizontal = 16.dp)) + Button( + onClick = { + keyboardController?.hide() + onSuggestSend.invoke() + }, + enabled = enabled, + ) { + Text(text = stringResource(id = R.string.suggestion_send_label)) + } + } + } + if (isDialogShow) { + MinimalDialog() + } + } +} + +@Composable +fun getMediaLauncher( + callback: (List, Int) -> Unit, +): ManagedActivityResultLauncher> { + return rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickMultipleVisualMedia(MAX_ITEMS), + ) { uris -> + if (uris.isNotEmpty()) callback(uris, MAX_ITEMS) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +private fun LazyListScope.toolbar(onBackClick: () -> Unit) { + item { + PsTopAppBar( + titleRes = R.string.suggestion_title_screen, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon(imageVector = PsIcons.Back, contentDescription = null) + } + }, + ) + } +} + +fun LazyListScope.descriptionEditText( + enabled: Boolean, + description: String, + onSetDescription: (String) -> Unit, +) { + item { + Text( + text = "Help developer to make better plant detection", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) + + OutlinedTextField( + value = description, + enabled = enabled, + label = { + Text( + text = stringResource(R.string.suggestion_description), + style = MaterialTheme.typography.labelMedium, + ) + }, + onValueChange = onSetDescription, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + minLines = 4, + maxLines = 7, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +fun LazyListScope.placeImage( + enabled: Boolean, + image: List, + maxItems: Int, + onImageSet: () -> Unit, +) { + item { + Spacer(modifier = Modifier.height(16.dp)) + Text( + "Place some image ${image.size}/$maxItems", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(modifier = Modifier.height(4.dp)) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .height(100.dp) + .padding(horizontal = 16.dp), + ) { + image.forEachIndexed { index, data -> + item { + AsyncImage( + model = + ImageRequest.Builder(LocalContext.current).data(data).crossfade(true) + .build(), + modifier = Modifier.size(100.dp), + contentScale = ContentScale.Crop, + contentDescription = "image@${image[index]}", + ) + } + } + + if (image.size != maxItems) { + item { + Card( + onClick = { if (enabled) onImageSet() }, + modifier = Modifier.size(100.dp), + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Default.Add, contentDescription = null) + } + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MinimalDialog() { + AlertDialog(onDismissRequest = {}) { + Surface( + modifier = Modifier + .wrapContentWidth() + .wrapContentHeight(), + shape = MaterialTheme.shapes.large, + tonalElevation = AlertDialogDefaults.TonalElevation, + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CircularProgressIndicator() + Text(stringResource(R.string.suggestion_uploading_message)) + } + } + } +} + +@Preview +@Composable +fun Preview_SuggestScreen() { + PsTheme { + PsBackground { + SuggestScreen( + isDialogShow = false, + userStatus = Status.Loading, + suggestFormState = Suggestion(), + onBackPressed = {}, + onSuggestSend = {}, + onSetDescription = {}, + onImageSet = { _, _ -> }, + ) + } + } +} diff --git a/feature/suggest/src/main/java/com/github/andiim/plantscan/feature/suggest/SuggestViewModel.kt b/feature/suggest/src/main/java/com/github/andiim/plantscan/feature/suggest/SuggestViewModel.kt new file mode 100644 index 00000000..e1afc4a4 --- /dev/null +++ b/feature/suggest/src/main/java/com/github/andiim/plantscan/feature/suggest/SuggestViewModel.kt @@ -0,0 +1,130 @@ +package com.github.andiim.plantscan.feature.suggest + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.util.Log +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.andiim.plantscan.core.domain.GetUserLoginInfoUseCase +import com.github.andiim.plantscan.core.domain.PostSuggestionUseCase +import com.github.andiim.plantscan.core.model.data.Suggestion +import com.github.andiim.plantscan.core.storageUpload.StorageHelper +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.datetime.Clock +import javax.inject.Inject + +private const val TAG = "SuggestViewModel" + +@HiltViewModel +class SuggestViewModel @Inject constructor( + getUserDataUseCase: GetUserLoginInfoUseCase, + private val postSuggestion: PostSuggestionUseCase, + private val storageHelper: StorageHelper, +) : ViewModel() { + var showDialog by mutableStateOf(false) + private set + + var suggestState by mutableStateOf(Suggestion()) + private set + + val userData = getUserDataUseCase().map { + if (it.isAnonymous) { + Status.Denied + } else { + Status.Granted(it.userId) + } + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + initialValue = Status.Loading, + ) + + private val _sendSuggestState = MutableStateFlow(SuggestUiState.Default) + val sendSuggestState = _sendSuggestState.asStateFlow() + + fun onDescriptionChange(newValue: String) { + suggestState = suggestState.copy(description = newValue) + } + + fun onImageSet(uris: List, maxSize: Int) { + if (uris.size <= maxSize) { + val newValue: List = uris.map(Uri::toString) + suggestState = suggestState.copy(images = newValue) + } + } + + @Suppress("detekt:TooGenericExceptionCaught") + fun sendSuggestion(context: Context, onError: (String) -> Unit) { + if (userData.value is Status.Granted) { + if (suggestState.description.isBlank()) { + onError(context.resources.getString(R.string.suggest_description_error_message)) + return + } + + val id = (userData.value as Status.Granted).id + viewModelScope.launch { + try { + showDialog = true + with(suggestState) { + _sendSuggestState.value = SuggestUiState.Loading + val contentResolver = context.contentResolver + val listUrl: List = if (images.isNotEmpty()) { + val images: List = images.map { + BitmapFactory.decodeStream( + contentResolver.openInputStream( + Uri.parse(it), + ), + ) + } + images.mapIndexed { index, bitmap -> + val baseLocation = + "suggestions/$id/${Clock.System.now()}_${id}_$index" + storageHelper.upload(bitmap, baseLocation).first() + } + } else { + listOf() + } + + val result = postSuggestion(this.copy(images = listUrl)).first() + + showDialog = false + _sendSuggestState.value = SuggestUiState.Complete + Log.i(TAG, "sendSuggestion: $result") + suggestState = Suggestion() + } + } catch (throwable: Throwable) { + showDialog = false + _sendSuggestState.value = SuggestUiState.Default + Log.e(TAG, "Error Occurred", throwable) + onError(throwable.message.orEmpty()) + } + } + } + } +} + +sealed interface Status { + data object Loading : Status + data class Granted(val id: String) : Status + data object Denied : Status +} + +sealed interface SuggestUiState { + + data object Default : SuggestUiState + data object Loading : SuggestUiState + data object Complete : SuggestUiState +} diff --git a/feature/suggest/src/main/java/com/github/andiim/plantscan/feature/suggest/navigation/SuggestNavigation.kt b/feature/suggest/src/main/java/com/github/andiim/plantscan/feature/suggest/navigation/SuggestNavigation.kt new file mode 100644 index 00000000..802d7de0 --- /dev/null +++ b/feature/suggest/src/main/java/com/github/andiim/plantscan/feature/suggest/navigation/SuggestNavigation.kt @@ -0,0 +1,32 @@ +package com.github.andiim.plantscan.feature.suggest.navigation + +import androidx.compose.material3.SnackbarDuration +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.github.andiim.plantscan.core.ui.navigation.AppDestination +import com.github.andiim.plantscan.feature.suggest.SuggestRoute + +fun NavController.navigateToSuggest() { + this.navigate(Suggest.route) { launchSingleTop = true } +} + +object Suggest : AppDestination { + override val route: String = "suggest" +} + +fun NavGraphBuilder.suggestScreen( + onBackClick: () -> Unit, + onLoginPressed: () -> Unit, + onShowSnackbar: suspend (String, String?, SnackbarDuration?) -> Boolean, +) { + composable( + route = Suggest.route, + ) { + SuggestRoute( + onBackPressed = onBackClick, + onShowSnackbar = onShowSnackbar, + onLoginPressed = onLoginPressed, + ) + } +} diff --git a/feature/suggest/src/main/res/values/strings.xml b/feature/suggest/src/main/res/values/strings.xml new file mode 100644 index 00000000..7c80bc36 --- /dev/null +++ b/feature/suggest/src/main/res/values/strings.xml @@ -0,0 +1,15 @@ + + + Please give some short description + Uploading… + Description + + Not sure for the result? + give a suggestion + Give a suggestion + Send + Sorry, you must login to access this feature + Thank you for the feedback + Cancel + Login + \ No newline at end of file diff --git a/feature/web/.gitignore b/feature/web/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/web/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/web/README.md b/feature/web/README.md new file mode 100644 index 00000000..e69de29b diff --git a/feature/web/build.gradle.kts b/feature/web/build.gradle.kts new file mode 100644 index 00000000..30cd999c --- /dev/null +++ b/feature/web/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + alias(libs.plugins.android.feature) + alias(libs.plugins.android.library.compose) + alias(libs.plugins.android.library.jacoco) +} + +android { + namespace = "com.github.andiim.plantscan.feature.web" +} + +dependencies { + implementation(libs.accompanist.webview) +} \ No newline at end of file diff --git a/feature/web/src/main/AndroidManifest.xml b/feature/web/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/feature/web/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature/web/src/main/java/com/github/andiim/plantscan/feature/web/WebNavigation.kt b/feature/web/src/main/java/com/github/andiim/plantscan/feature/web/WebNavigation.kt new file mode 100644 index 00000000..a79db855 --- /dev/null +++ b/feature/web/src/main/java/com/github/andiim/plantscan/feature/web/WebNavigation.kt @@ -0,0 +1,49 @@ +package com.github.andiim.plantscan.feature.web + +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.github.andiim.plantscan.core.ui.navigation.AppDestination +import java.net.URLDecoder +import java.net.URLEncoder + +private val urlCharacterEncoding: String = Charsets.UTF_8.name() + +internal class WebArgs(val url: String) { + constructor(savedStateHandle: SavedStateHandle) : this( + URLDecoder.decode( + checkNotNull(savedStateHandle[Web.webArg]), + urlCharacterEncoding, + ), + ) +} + +fun NavController.navigateToWeb(url: String) { + val encodedUrl = URLEncoder.encode(url, urlCharacterEncoding) + this.navigate("${Web.route}/$encodedUrl") { + launchSingleTop = true + } +} + +object Web : AppDestination { + override val route: String = "web" + const val webArg = "url" + val routeWithArgs = "$route/{$webArg}" + val arguments = listOf( + navArgument(webArg) { type = NavType.StringType }, + ) +} + +fun NavGraphBuilder.webViewScreen( + onBackClick: () -> Unit, +) { + composable( + route = Web.routeWithArgs, + arguments = Web.arguments, + ) { + WebRoute(popUpScreen = onBackClick) + } +} diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/web/WebScreen.kt b/feature/web/src/main/java/com/github/andiim/plantscan/feature/web/WebScreen.kt similarity index 60% rename from app/src/main/java/com/github/andiim/plantscan/app/ui/screens/web/WebScreen.kt rename to feature/web/src/main/java/com/github/andiim/plantscan/feature/web/WebScreen.kt index 0c12a52a..bae28d1c 100644 --- a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/web/WebScreen.kt +++ b/feature/web/src/main/java/com/github/andiim/plantscan/feature/web/WebScreen.kt @@ -1,11 +1,11 @@ -package com.github.andiim.plantscan.app.ui.screens.web +package com.github.andiim.plantscan.feature.web import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -15,28 +15,40 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel import com.google.accompanist.web.WebView import com.google.accompanist.web.rememberWebViewState @OptIn(ExperimentalMaterial3Api::class) @Composable -fun WebScreen(url: String, name: String, popUpScreen: () -> Unit) { - val state = rememberWebViewState("https://$url") +fun WebRoute( + popUpScreen: () -> Unit, + modifier: Modifier = Modifier, + viewModel: WebViewModel = hiltViewModel(), +) { + val state = rememberWebViewState(url = "https://${viewModel.webUrl}") Scaffold( + modifier = modifier, topBar = { - Column(modifier = Modifier.fillMaxWidth().wrapContentHeight()) { + Column( + Modifier + .fillMaxWidth() + .wrapContentHeight() + ) { TopAppBar( - title = { Text(name) }, + title = { Text("${state.pageTitle ?: "Loading..."} ") }, navigationIcon = { IconButton(onClick = popUpScreen) { - Icon(Icons.Default.ArrowBack, contentDescription = "Back") + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } - }) + }, + ) if (state.isLoading) { LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) } } - }) { + }, + ) { WebView(state, Modifier.padding(it)) } } diff --git a/feature/web/src/main/java/com/github/andiim/plantscan/feature/web/WebViewModel.kt b/feature/web/src/main/java/com/github/andiim/plantscan/feature/web/WebViewModel.kt new file mode 100644 index 00000000..8951994c --- /dev/null +++ b/feature/web/src/main/java/com/github/andiim/plantscan/feature/web/WebViewModel.kt @@ -0,0 +1,13 @@ +package com.github.andiim.plantscan.feature.web + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +@HiltViewModel +class WebViewModel @Inject constructor( + savedStateHandle: SavedStateHandle +) : ViewModel() { + private val webArgs: WebArgs = WebArgs(savedStateHandle) + val webUrl: String = webArgs.url +} diff --git a/firebase.json b/firebase.json index e46df396..b3b842e3 100644 --- a/firebase.json +++ b/firebase.json @@ -6,12 +6,18 @@ "firestore": { "port": 8080 }, - "database": { - "port": 9000 - }, "ui": { "enabled": true }, - "singleProjectMode": true + "singleProjectMode": true, + "storage": { + "port": 9199 + } + }, + "firestore": { + "rules": "firestore.rules" + }, + "storage": { + "rules": "storage.rules" } } diff --git a/firestore.rules b/firestore.rules new file mode 100644 index 00000000..21fdd940 --- /dev/null +++ b/firestore.rules @@ -0,0 +1,20 @@ +rules_version = '2'; + +service cloud.firestore { + match /databases/{database}/documents { + + // This rule allows anyone with your Firestore database reference to view, edit, + // and delete all data in your Firestore database. It is useful for getting + // started, but it is configured to expire after 30 days because it + // leaves your app open to attackers. At that time, all client + // requests to your Firestore database will be denied. + // + // Make sure to write security rules for your app before that time, or else + // all client requests to your Firestore database will be denied until you Update + // your rules + match /{document=**} { + // allow read, write: if request.time < timestamp.date(2023, 7, 11); + allow read, write: if true; + } + } +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 45cc1ca7..b57dc01e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,19 +4,37 @@ # 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=-Xmx4G +# Ensure important default jvmargs aren't overwritten. See https://github.com/gradle/gradle/issues/19750 +org.gradle.jvmargs=-Xmx6g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g + # 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 +org.gradle.parallel=true + +# Not encouraged by Gradle and can produce weird results. Wait for isolated projects instead. +org.gradle.configureondemand=false + +# Enable caching between builds. +org.gradle.caching=true + +# Enable configuration caching between builds. +org.gradle.configuration-cache=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 +# 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 -# Automatically convert third-party libraries to use AndroidX -android.enableJetifier=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official -org.gradle.configuration-cache=true + +# Non-transitive R classes is recommended and is faster/smaller +android.nonTransitiveRClass=true + +# Disable build features that are enabled by default, +# https://developer.android.com/build/releases/gradle-plugin#default-changes +android.defaults.buildfeatures.resvalues=false +android.defaults.buildfeatures.shaders=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5b6cf9e1..684e3d0d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,119 +1,223 @@ [versions] -agp = "8.1.0" -activity = "1.7.2" +# DEPENDENCIES +activity = "1.8.0" +agp = "8.1.3" +androidDesugarJdkLibs = "2.0.4" androidx_activity_compose = "1.7.2" +androidxBrowser = "1.6.0" +androidxTestCore = "1.5.0" androidx_test = "1.5.2" -androidx_test_rules = "1.5.0" androidx_test_ext = "1.1.5" +androidx_test_rules = "1.5.0" appcompat = "1.6.1" -camerax_version = "1.2.3" +benmanesversion = "0.47.0" +camerax_version = "1.3.0" +coil = "2.5.0" +compose = "2023.10.01" +core_ktx = "1.12.0" coroutines = "1.7.3" -coil = "2.4.0" -compose = "2023.06.01" -compose_compilerextension = "1.4.8" -constraint_layout = "2.1.4" -core_ktx = "1.10.1" +datastore = "1.0.0" detekt = "1.23.0" +detekt_compose = "0.3.2" dokka = "1.8.20" espresso_core = "3.5.1" -firebase = "32.2.2" -hilt = "2.47" +firebase = "32.5.0" +gmsPlugin = "4.3.14" +googleOss = "17.0.1" +googleOssPlugin = "0.10.6" +hilt = "2.48" +hilt-work-common = "1.1.0" +jacoco = "0.8.7" junit = "4.13.2" -kotlin = "1.8.22" +kotlinx-serialization = "1.0.0" +kotlinx-serialization-json = "1.5.1" +ksp = "1.9.10-1.0.13" ktlint = "0.45.2" ktlint_gradle = "10.2.1" -lifecycle = "2.6.1" -material = "1.9.0" -navigation = "2.6.0" -compile_sdk_version = "33" -min_sdk_version = "21" -target_sdk_version = "33" -benmanesversion = "0.47.0" +lifecycle = "2.6.2" +lint = "31.1.3" +material = "1.10.0" +navigation = "2.7.5" +okhttp = "4.10.0" +org-jetbrains-kotlin-android = "1.8.0" +profileInstaller = "1.3.1" +protobuf = "3.23.4" +protobufPlugin = "0.9.3" +retrofit = "2.9.0" +room = "2.6.0" +secrets = "2.0.1" +splash = "1.0.1" timber = "5.0.1" truth = "1.1.5" -org-jetbrains-kotlin-android = "1.8.0" -constraintlayout = "2.1.4" -tensorflow = "2.12.0" -tensorflow_support = "0.4.3" +window = "1.1.0" + +# SDK VERSION +compile_sdk_version = "34" +compose_compilerextension = "1.5.3" +gson = "2.10.1" +integrity = "1.2.0" +kotlin = "1.9.10" +kotlinxDatetime = "0.4.1" +min_sdk_version = "27" +objectDetection = "17.0.0" +target_sdk_version = "34" +uiautomator = "2.2.0" +benchmark-macro-junit4 = "1.2.0" [libraries] -junit = { module = "junit:junit", version.ref = "junit" } +accompanist_permission = { module = "com.google.accompanist:accompanist-permissions", version = "0.32.0" } accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version = "0.30.1" } -accompanist_permission = { module = "com.google.accompanist:accompanist-permissions", version = "0.30.1" } -androidx_appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } -androidx_core_ktx = { module = "androidx.core:core-ktx", version.ref = "core_ktx" } -androidx_test_rules = { module = "androidx.test:rules", version.ref = "androidx_test_rules" } -androidx_test_runner = { module = "androidx.test:runner", version.ref = "androidx_test" } -androidx_test_ext_junit = { module = "androidx.test.ext:junit", version.ref = "androidx_test_ext" } -androidx_test_ext_junit_ktx = { module = "androidx.test.ext:junit-ktx", version.ref = "androidx_test_ext" } -dagger_hilt = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } -dagger_hilt_navigation_compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.0.0" } -dagger_hilt_compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } -dagger_hilt_testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } +accompanist-testharness = { group = "com.google.accompanist", name = "accompanist-testharness", version = "0.30.1" } +agp = { module = "com.android.tools.build:gradle", version.ref = "agp" } +android-desugarJdkLibs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "androidDesugarJdkLibs" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } +androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "androidxBrowser" } +androidx-core_ktx = { module = "androidx.core:core-ktx", version.ref = "core_ktx" } +androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splash" } +androidx-core_testing = { module = "androidx.arch.core:core-testing", version = "2.2.0" } +androidx-dataStore-core = { group = "androidx.datastore", name = "datastore", version.ref = "datastore" } +androidx-mockito = { module = "org.mockito:mockito-core", version = "4.8.1" } +androidx-mockito-android = { module = "org.mockito:mockito-android", version = "4.8.1" } +androidx-mockito-dexmaker = { module = "com.linkedin.dexmaker:dexmaker-mockito-inline", version = "2.28.1" } +androidx-mockito-inline = { module = "org.mockito:mockito-inline", version = "4.8.1" } +androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx_test_ext" } +androidx-test-ext-junit-ktx = { module = "androidx.test.ext:junit-ktx", version.ref = "androidx_test_ext" } +androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx_test_rules" } +androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx_test" } +androidx-hilt-common = { group = "androidx.hilt", name = "hilt-common", version.ref = "hilt-work-common" } +androidx-hilt-work = { group = "androidx.hilt", name = "hilt-work", version = "hilt-work-common" } +androidx-lifecycle-livedata = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycle" } +androidx-lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } +androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileInstaller" } +androidx-window = { module = "androidx.window:window", version.ref = "window" } +androidx-window-extension = { module = "androidx.window.extensions.core:core", version.ref = "window" } +androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version = "2.8.1" } +androidx-work-testing = { group = "androidx.work", name = "work-testing", version = "2.8.1" } +androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" } +androidx-tracing-ktx = { group = "androidx.tracing", name = "tracing-ktx", version = "1.1.0" } +autofactory = { group = "com.google.auto.factory", name = "auto-factory", version = "1.0.1" } camera = { module = "androidx.camera:camera-camera2", version.ref = "camerax_version" } camera-core = { module = "androidx.camera:camera-core", version.ref = "camerax_version" } +camera-extensions = { module = "androidx.camera:camera-extensions", version.ref = "camerax_version" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camerax_version" } camera-video = { module = "androidx.camera:camera-video", version.ref = "camerax_version" } camera-view = { module = "androidx.camera:camera-view", version.ref = "camerax_version" } -camera-extensions = { module = "androidx.camera:camera-extensions", version.ref = "camerax_version" } +coil = { module = "io.coil-kt:coil", version.ref = "coil" } coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } -compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose" } +coil-kt-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coil" } compose-activity = { group = "androidx.activity", name = "activity-compose", version.ref = "activity" } -compose-material = { group = "androidx.compose.material3", name = "material3" } +compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose" } +compose-material = { group = "androidx.compose.material3", name = "material3", version = "1.2.0-alpha10" } +compose-materialWindow = { group = "androidx.compose.material3", name = "material3-window-size-class" } compose-materialIcons = { group = "androidx.compose.material", name = "material-icons-extended" } +compose-runtime = { group = "androidx.compose.runtime", name = "runtime" } compose-runtimeLivedata = { group = "androidx.compose.runtime", name = "runtime-livedata" } +compose-runtimeTracing = { group = "androidx.compose.runtime", name = "runtime-tracing", version = "1.0.0-alpha04" } +compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } +compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout" } compose-ui = { group = "androidx.compose.ui", name = "ui" } compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +compose-ui-test = { group = "androidx.compose.ui", name = "ui-test-junit4", version = "1.5.4" } compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +compose-ui-util = { group = "androidx.compose.ui", name = "ui-util" } +compose-ui-viewBinding = { module = "androidx.compose.ui:ui-viewbinding" } +dagger_hilt_navigation_compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.1.0" } +dagger_hilt_testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } detekt_formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" } -espresso_core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso_core" } +detekt_compose = { module = "io.nlopez.compose.rules:detekt", version.ref = "detekt_compose" } +dhp = { module = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "hilt" } +dokka_core = { module = "org.jetbrains.dokka:dokka-core", version.ref = "dokka" } +dokka_gradle_plugin = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version.ref = "dokka" } +espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso_core" } +espresso-idlingResource = { module = "androidx.test.espresso:espresso-idling-resource", version.ref = "espresso_core" } +espresso-intent = { module = "androidx.test.espresso:espresso-intents", version.ref = "espresso_core" } firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" } -firebase-auth = { group = "com.google.firebase", name = "firebase-auth-ktx" } +firebase-appdistribution-plugin = { group = "com.google.firebase", name = "firebase-appdistribution-gradle", version = "4.0.1" } +firebase-auth = { group = "com.google.firebase", name = "firebase-auth" } firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebase" } -firebase-config = { group = "com.google.firebase", name = "firebase-config-ktx" } -firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics-ktx" } -firebase-crashlytics-plugin = { module = "com.google.firebase:firebase-crashlytics-gradle", version = "2.9.8" } -firebase-firestore = { group = "com.google.firebase", name = "firebase-firestore-ktx" } -firebase-perf = { group = "com.google.firebase", name = "firebase-perf-ktx" } -firebase-messaging = { module = "com.google.firebase:firebase-messaging-ktx" } -firebase-app-check = { module = "com.google.firebase:firebase-appcheck-ktx" } -firebase-app-check-debug = { module = "com.google.firebase:firebase-appcheck-debug" } -firebase-app-check-debug-testing = { module = "com.google.firebase:firebase-appcheck-debug-testing" } +firebase-cloud-messaging = { group = "com.google.firebase", name = "firebase-messaging" } +firebase-config = { group = "com.google.firebase", name = "firebase-config" } +firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" } +firebase-crashlytics-plugin = { module = "com.google.firebase:firebase-crashlytics-gradle", version = "2.9.9" } +firebase-firestore = { group = "com.google.firebase", name = "firebase-firestore" } +firebase-messaging = { module = "com.google.firebase:firebase-messaging" } +firebase-perf = { group = "com.google.firebase", name = "firebase-perf" } +firebase-perf-plugin = { group = "com.google.firebase", name = "perf-plugin", version = "1.4.2" } +firebase-storage = { module = "com.google.firebase:firebase-storage" } +google-gson = { module = "com.google.code.gson:gson", version.ref = "gson" } +google-oss-licenses = { group = "com.google.android.gms", name = "play-services-oss-licenses", version.ref = "googleOss" } +google-oss-licenses-plugin = { group = "com.google.android.gms", name = "oss-licenses-plugin", version.ref = "googleOssPlugin" } +hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } +hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } +junit = { module = "junit:junit", version.ref = "junit" } +kgp = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kotlin-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlin-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } -lifecycle-livedata = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycle" } -lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } -lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } +kotlin-coroutines-test-turbine = { module = "app.cash.turbine:turbine", version = "1.0.0" } +kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib-jdk8", version.ref = "kotlin" } +kotlinx-coroutines-guava = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-guava", version.ref = "coroutines" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } +ksp-plugin = { group = "com.google.devtools.ksp", name = "com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } +lint-api = { group = "com.android.tools.lint", name = "lint-api", version.ref = "lint" } material = { module = "com.google.android.material:material", version.ref = "material" } -tensorflow = { module = "org.tensorflow:tensorflow-lite-gpu", version.ref = "tensorflow" } -tensorflow-support = { module = "org.tensorflow:tensorflow-lite-support", version.ref = "tensorflow_support" } -tensorflow-metadata = { module = "org.tensorflow:tensorflow-lite-metadata", version.ref = "tensorflow_support" } +metrics-performance = { group = "androidx.metrics", name = "metrics-performance", version = "1.0.0-alpha04" } navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" } -agp = { module = "com.android.tools.build:gradle", version.ref = "agp" } -kgp = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } -dhp = { module = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "hilt" } -dokka_gradle_plugin = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version.ref = "dokka" } -dokka_core = { module = "org.jetbrains.dokka:dokka-core", version.ref = "dokka" } -paging-common = { module = "androidx.paging:paging-common-ktx", version = "3.2.0" } -paging-compose = { module = "androidx.paging:paging-compose", version = "3.2.0" } +navigation-testing = { group = "androidx.navigation", name = "navigation-testing", version.ref = "navigation" } +navigation-ui = { module = "androidx.navigation:navigation-ui-ktx", version = "2.7.5" } +okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } +play-services-auth = { group = "com.google.android.gms", name = "play-services-auth", version = "20.7.0" } +protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin-lite", version.ref = "protobuf" } +protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" } +retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } +retrofit-kotlin-serialization = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "kotlinx-serialization" } +room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } truth = { module = "com.google.truth:truth", version.ref = "truth" } -play-services-auth = { group = "com.google.android.gms", name = "play-services-auth", version = "20.6.0" } -constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } +uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" } +benchmark-macro-junit4 = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "benchmark-macro-junit4" } +hilt-common = { group = "androidx.hilt", name = "hilt-common", version = "1.1.0" } + [bundles] camera = ["camera", "camera-core", "camera-lifecycle", "camera-extensions", "camera-video", "camera-view"] -compose = ["coil-compose", "compose-activity", "compose-material", "compose-materialIcons", "compose-runtimeLivedata", "compose-ui", "compose-ui-graphics", "compose-ui-tooling-preview", "paging-compose", "navigation-compose"] +compose = ["coil-compose", "compose-activity", "compose-material", "compose-materialIcons", "compose-runtimeLivedata", "compose-ui", "compose-ui-graphics", "compose-ui-tooling-preview", "navigation-compose", "compose-ui-viewBinding", "androidx-lifecycle-runtime-compose"] compose-debug = ["compose-ui-tooling", "compose-ui-test-manifest"] -tensorflow = ["tensorflow", "tensorflow-metadata", "tensorflow-support"] -firebase = ["firebase-auth", "firebase-config", "firebase-crashlytics", "firebase-firestore", "firebase-analytics", "firebase-perf", "firebase-messaging", "firebase-app-check", "firebase-app-check-debug"] -lifecycle = ["lifecycle-livedata", "lifecycle-runtime", "lifecycle-viewmodel-compose"] -paging = ["paging-common", "paging-compose"] +firebase = ["firebase-auth", "firebase-config", "firebase-crashlytics", "firebase-firestore", "firebase-analytics", "firebase-perf", "firebase-messaging", "firebase-storage"] +lifecycle = ["androidx-lifecycle-livedata", "androidx-lifecycle-runtime", "androidx-lifecycle-viewmodel-compose"] +navigation = ["navigation-ui"] +retrofit = ["retrofit-core", "retrofit-kotlin-serialization", "kotlinx-serialization-json", "okhttp-logging"] [plugins] +android-application-compose = { id = "plantscan.android.application.compose", version = "unspecified" } +android-application = { id = "plantscan.android.application", version = "unspecified" } +android-application-firebase = { id = "plantscan.android.application.firebase", version = "unspecified" } +android-application-flavors = { id = "plantscan.android.application.flavors", version = "unspecified" } +android-application-jacoco = { id = "plantscan.android.application.jacoco", version = "unspecified" } +android-feature = { id = "plantscan.android.feature", version = "unspecified" } +android-hilt = { id = "plantscan.android.hilt", version = "unspecified" } +android-library = { id = "plantscan.android.library", version = "unspecified" } +android-library-compose = { id = "plantscan.android.library.compose", version = "unspecified" } +android-library-jacoco = { id = "plantscan.android.library.jacoco", version = "unspecified" } +android-room = { id = "plantscan.android.room", version = "unspecified" } +android-test = { id = "plantscan.android.test", version = "unspecified" } +com-android-library = { id = "com.android.library", version.ref = "agp" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } +gms = { id = "com.google.gms.google-services", version.ref = "gmsPlugin" } +jvm-library = { id = "plantscan.jvm.library", version = "unspecified" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version = "1.9.0" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint.gradle" } -versions = { id = "com.github.ben-manes.versions", version.ref = "benmanesversion" } +modulegraph = { id = "dev.iurysouza.modulegraph", version = "0.2.2" } org-jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "org-jetbrains-kotlin-android" } +protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" } +secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secrets" } +versions = { id = "com.github.ben-manes.versions", version.ref = "benmanesversion" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +com-android-test = { id = "com.android.test", version.ref = "agp" } +com-android-application = { id = "com.android.application", version.ref = "agp" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 9f4197d5..2a2d1c5c 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ +#Thu Sep 07 20:34:03 WIB 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/library-android/build.gradle.kts b/library-android/build.gradle.kts deleted file mode 100644 index af07ca65..00000000 --- a/library-android/build.gradle.kts +++ /dev/null @@ -1,79 +0,0 @@ -version = LibraryAndroidCoordinates.LIBRARY_VERSION - -plugins { - id("com.android.library") - kotlin("android") - kotlin("kapt") - id("com.google.dagger.hilt.android") -} - -android { - compileSdk = libs.versions.compile.sdk.version.get().toInt() - - defaultConfig { - minSdk = libs.versions.min.sdk.version.get().toInt() - namespace = "com.github.andiim.plantscan.library.android" - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - consumerProguardFiles("consumer-rules.pro") - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - composeOptions { kotlinCompilerExtensionVersion = libs.versions.compose.compilerextension.get() } - buildFeatures { - mlModelBinding = true - buildConfig = true - viewBinding = true - compose = true - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() - } - - buildTypes { - getByName("release") { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - } - - lint { - warningsAsErrors = true - abortOnError = true - } -} - -dependencies { - implementation(libs.androidx.appcompat) - implementation(libs.androidx.core.ktx) - implementation(libs.material) - implementation(libs.constraintlayout) - - // Tensorflow - implementation(libs.bundles.tensorflow) - - // Hilt - implementation(libs.dagger.hilt) - implementation(libs.dagger.hilt.navigation.compose) - kapt(libs.dagger.hilt.compiler) - - // Compose - implementation(platform(libs.compose.bom)) - implementation(libs.bundles.compose) - debugImplementation(libs.bundles.compose.debug) - implementation(libs.bundles.lifecycle) - - testImplementation(libs.junit) - implementation(libs.bundles.camera) - - androidTestImplementation(libs.androidx.test.runner) - androidTestImplementation(libs.androidx.test.ext.junit) -} diff --git a/library-android/src/androidTest/java/com/github/andiim/plantscan/library/android/ToastUtilTest.kt b/library-android/src/androidTest/java/com/github/andiim/plantscan/library/android/ToastUtilTest.kt deleted file mode 100644 index a614e3a7..00000000 --- a/library-android/src/androidTest/java/com/github/andiim/plantscan/library/android/ToastUtilTest.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.github.andiim.plantscan.library.android - -import android.widget.Toast -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ToastUtilTest { - - @Test - fun showCorrectToast() { - val context = InstrumentationRegistry.getInstrumentation().targetContext - - val toast = ToastUtil.showToast(context, "test message") - - assertEquals(Toast.LENGTH_SHORT, toast.duration) - } -} diff --git a/library-android/src/main/AndroidManifest.xml b/library-android/src/main/AndroidManifest.xml deleted file mode 100644 index 1e84c001..00000000 --- a/library-android/src/main/AndroidManifest.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/library-android/src/main/java/com/github/andiim/plantscan/library/android/ToastUtil.kt b/library-android/src/main/java/com/github/andiim/plantscan/library/android/ToastUtil.kt deleted file mode 100644 index d23334da..00000000 --- a/library-android/src/main/java/com/github/andiim/plantscan/library/android/ToastUtil.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.github.andiim.plantscan.library.android - -import android.content.Context -import android.widget.Toast - -object ToastUtil { - - fun showToast(context: Context, message: String): Toast = - Toast.makeText(context, message, Toast.LENGTH_SHORT).also { - it.show() - } -} diff --git a/library-android/src/main/java/com/github/andiim/plantscan/library/android/detect/DetectActivity.kt b/library-android/src/main/java/com/github/andiim/plantscan/library/android/detect/DetectActivity.kt deleted file mode 100644 index 28a423dd..00000000 --- a/library-android/src/main/java/com/github/andiim/plantscan/library/android/detect/DetectActivity.kt +++ /dev/null @@ -1,217 +0,0 @@ -package com.github.andiim.plantscan.library.android.detect - -import android.Manifest -import android.content.ContentValues -import android.content.pm.PackageManager -import android.os.Build -import android.os.Bundle -import android.provider.MediaStore -import android.util.Log -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import androidx.camera.core.CameraSelector -import androidx.camera.core.ImageAnalysis -import androidx.camera.core.ImageCapture -import androidx.camera.core.ImageCaptureException -import androidx.camera.core.ImageProxy -import androidx.camera.core.Preview -import androidx.camera.core.VideoCapture -import androidx.camera.lifecycle.ProcessCameraProvider -import androidx.core.content.ContextCompat -import com.github.andiim.plantscan.library.android.ToastUtil -import com.github.andiim.plantscan.library.android.databinding.ActivityDetectBinding -import java.nio.ByteBuffer -import java.text.SimpleDateFormat -import java.util.Locale -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors - -class DetectActivity : AppCompatActivity() { - private lateinit var viewBinding: ActivityDetectBinding - private var imageCapture: ImageCapture? = null - private var videoCapture: VideoCapture? = null - - private lateinit var cameraExecutor: ExecutorService - - private val activityResultLauncher = - registerForActivityResult( - ActivityResultContracts.RequestMultiplePermissions() - ) - { permissions -> - // Handle Permission granted/rejected - var permissionGranted = true - permissions.entries.forEach { - if (it.key in REQUIRED_PERMISSIONS && !it.value) - permissionGranted = false - } - if (!permissionGranted) { - ToastUtil.showToast(baseContext, "Permission request denied") - } else { - startCamera() - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - viewBinding = ActivityDetectBinding.inflate(layoutInflater) - setContentView(viewBinding.root) - - // Request camera permissions - if (allPermissionsGranted()) { - startCamera() - } else { - requestPermissions() - } - - // Set up the listeners for take photo and video capture buttons - viewBinding.imageCaptureButton.setOnClickListener { takePhoto() } - viewBinding.videoCaptureButton.setOnClickListener { captureVideo() } - - cameraExecutor = Executors.newSingleThreadExecutor() - - - } - - private fun takePhoto() { - // Get a stable reference of the modifiable image capture use case - val imageCapture = imageCapture ?: return - - // Create time stamped name and MediaStore entry. - val name = SimpleDateFormat(FILENAME_FORMAT, Locale.US) - .format(System.currentTimeMillis()) - val contentValues = ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, name) - put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg") - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { - put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/CameraX-Image") - } - } - - // Create output options object which contains file + metadata - val outputOptions = ImageCapture.OutputFileOptions - .Builder( - contentResolver, - MediaStore.Images.Media.EXTERNAL_CONTENT_URI, - contentValues - ) - .build() - - // Set up image capture listener, which is triggered after photo has - // been taken - imageCapture.takePicture( - outputOptions, - ContextCompat.getMainExecutor(this), - object : ImageCapture.OnImageSavedCallback { - override fun onError(exc: ImageCaptureException) { - Log.e(TAG, "Photo capture failed: ${exc.message}", exc) - } - - override fun - onImageSaved(output: ImageCapture.OutputFileResults) { - val msg = "Photo capture succeeded: ${output.savedUri}" - ToastUtil.showToast(baseContext, msg) - Log.d(TAG, msg) - } - } - ) - } - - private fun captureVideo() {} - - private fun startCamera() { - val cameraProviderFuture = ProcessCameraProvider.getInstance(this) - - cameraProviderFuture.addListener({ - // Used to bind the lifecycle of cameras to the lifecycle owner - val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get() - - // Preview - val preview = Preview.Builder() - .build() - .also { - it.setSurfaceProvider(viewBinding.viewFinder.surfaceProvider) - } - - imageCapture = ImageCapture.Builder() - .build() - - val imageAnalyzer = ImageAnalysis.Builder() - .build() - .also { - it.setAnalyzer(cameraExecutor, LuminosityAnalyzer { luma -> - Log.d(TAG, "Average luminosity: $luma") - }) - } - - // Select back camera as a default - val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA - - try { - // Unbind use cases before rebinding - cameraProvider.unbindAll() - - // Bind use cases to camera - cameraProvider.bindToLifecycle( - this, cameraSelector, preview, imageCapture, imageAnalyzer) - - } catch(exc: Exception) { - Log.e(TAG, "Use case binding failed", exc) - } - - }, ContextCompat.getMainExecutor(this)) - } - - private fun requestPermissions() { - activityResultLauncher.launch(REQUIRED_PERMISSIONS) - } - - private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all { - ContextCompat.checkSelfPermission( - baseContext, it - ) == PackageManager.PERMISSION_GRANTED - } - - override fun onDestroy() { - super.onDestroy() - cameraExecutor.shutdown() - } - - companion object { - private const val TAG = "CameraXApp" - private const val FILENAME_FORMAT = "yyyy-MM-dd-HH-mm-ss-SSS" - private val REQUIRED_PERMISSIONS = - mutableListOf( - Manifest.permission.CAMERA, - Manifest.permission.RECORD_AUDIO - ).apply { - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { - add(Manifest.permission.WRITE_EXTERNAL_STORAGE) - } - }.toTypedArray() - } -} - -/** Helper type alias used for analysis use case callbacks */ -typealias LumaListener = (luma: Double) -> Unit - -private class LuminosityAnalyzer(private val listener: LumaListener) : ImageAnalysis.Analyzer { - - private fun ByteBuffer.toByteArray(): ByteArray { - rewind() // Rewind the buffer to zero - val data = ByteArray(remaining()) - get(data) // Copy the buffer into a byte array - return data // Return the byte array - } - - override fun analyze(image: ImageProxy) { - - val buffer = image.planes[0].buffer - val data = buffer.toByteArray() - val pixels = data.map { it.toInt() and 0xFF } - val luma = pixels.average() - - listener(luma) - - image.close() - } -} \ No newline at end of file diff --git a/library-android/src/main/java/com/github/andiim/plantscan/library/android/detect/DetectTensorActivity.kt b/library-android/src/main/java/com/github/andiim/plantscan/library/android/detect/DetectTensorActivity.kt deleted file mode 100644 index 98f75542..00000000 --- a/library-android/src/main/java/com/github/andiim/plantscan/library/android/detect/DetectTensorActivity.kt +++ /dev/null @@ -1,83 +0,0 @@ -package com.github.andiim.plantscan.library.android.detect - -import android.content.Intent -import android.graphics.Bitmap -import android.net.Uri -import android.os.Bundle -import android.os.PersistableBundle -import android.provider.MediaStore -import android.util.Log -import androidx.appcompat.app.AppCompatActivity -import com.github.andiim.plantscan.library.android.databinding.ActivityTensorDetectBinding -import com.github.andiim.plantscan.library.android.ml.BestInt8 -import org.tensorflow.lite.DataType -import org.tensorflow.lite.support.image.TensorImage -import org.tensorflow.lite.support.tensorbuffer.TensorBuffer - -class DetectTensorActivity : AppCompatActivity() { - private lateinit var viewBinding: ActivityTensorDetectBinding - lateinit var bitmap: Bitmap - - override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) { - super.onCreate(savedInstanceState, persistentState) - viewBinding = ActivityTensorDetectBinding.inflate(layoutInflater) - setContentView(viewBinding.root) - - val fileName = "labels.txt" - val labels = - application.assets.open(fileName).bufferedReader().use { it.readText() }.split("\n") - - viewBinding.select.setOnClickListener { view -> - var intent: Intent = Intent(Intent.ACTION_GET_CONTENT) - intent.type = "image/*" - - startActivityForResult(intent, 200); - } - - viewBinding.predict.setOnClickListener { - var resized: Bitmap = Bitmap.createScaledBitmap(bitmap, 224, 224, true) - val model = BestInt8.newInstance(this) - - val tbuffer = TensorImage(DataType.FLOAT32) - tbuffer.load(resized) - val byteBuffer = tbuffer.buffer - - val inputFeature0 = - TensorBuffer.createFixedSize(intArrayOf(1, 224, 224, 3), DataType.FLOAT32) - inputFeature0.loadBuffer(byteBuffer) - - val outputs = model.process(inputFeature0) - val outputFeature0 = outputs.outputAsTensorBuffer - var max = getMax(outputFeature0.floatArray) - - viewBinding.textView.text = labels[max] - // textView.text = outputFeature0.floatArray[max].toString() - model.close() - } - } - - fun getMax(arr: FloatArray): Int { - var ind = 0; - var min = 0.0f; - - for (i in 0..51) { - if (arr[i] > min) { - Log.d("MainActivity", "Position =$i") - min = arr[i] - ind = i; - } - } - return ind - } - - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - - if (requestCode == 250) { - viewBinding.img.setImageURI(data?.data) - - var uri: Uri? = data?.data - bitmap = MediaStore.Images.Media.getBitmap(this.contentResolver, uri) - } - } -} diff --git a/library-android/src/main/res/layout/activity_detect.xml b/library-android/src/main/res/layout/activity_detect.xml deleted file mode 100644 index 77c8e199..00000000 --- a/library-android/src/main/res/layout/activity_detect.xml +++ /dev/null @@ -1,43 +0,0 @@ - - - - - -