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 🤖
-[](https://github.com/cortinico/kotlin-android-template/generate)   
+[](https://github.com/cortinico/kotlin-android-template/generate)   
-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/benchmark-rules.pro b/app/benchmark-rules.pro
new file mode 100644
index 00000000..96b67f2d
--- /dev/null
+++ b/app/benchmark-rules.pro
@@ -0,0 +1,18 @@
+# Proguard rules for the `benchmark` build type.
+#
+# Obsfuscation must be disabled for the build variant that generates Baseline Profile, otherwise
+# wrong symbols would be generated. The generated Baseline Profile will be properly applied when generated
+# without obfuscation and your app is being obfuscated.
+-dontobfuscate
+
+# Please add these rules to your existing keep rules in order to suppress warnings.
+# This is generated automatically by the Android Gradle plugin.
+-dontwarn org.bouncycastle.jsse.BCSSLParameters
+-dontwarn org.bouncycastle.jsse.BCSSLSocket
+-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
+-dontwarn org.conscrypt.Conscrypt$Version
+-dontwarn org.conscrypt.Conscrypt
+-dontwarn org.conscrypt.ConscryptHostnameVerifier
+-dontwarn org.openjsse.javax.net.ssl.SSLParameters
+-dontwarn org.openjsse.javax.net.ssl.SSLSocket
+-dontwarn org.openjsse.net.ssl.OpenJSSE
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 56ad9c9c..f7dd65c2 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,116 +1,131 @@
+import com.github.andiim.plantscan.app.PsBuildType
+import com.google.firebase.appdistribution.gradle.firebaseAppDistribution
+
plugins {
- id("com.android.application")
- kotlin("android")
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.android.application.compose)
+ alias(libs.plugins.android.application.flavors)
+ alias(libs.plugins.android.application.jacoco)
+ alias(libs.plugins.android.hilt)
+ alias(libs.plugins.android.application.firebase)
+ alias(libs.plugins.protobuf)
kotlin("kapt")
- id("com.google.dagger.hilt.android")
- id("com.google.gms.google-services")
- id("com.google.firebase.crashlytics")
- id("com.google.firebase.firebase-perf")
+ id("jacoco")
+ id("com.google.android.gms.oss-licenses-plugin")
}
android {
- compileSdk = libs.versions.compile.sdk.version.get().toInt()
-
defaultConfig {
- minSdk = libs.versions.min.sdk.version.get().toInt()
- targetSdk = libs.versions.target.sdk.version.get().toInt()
- namespace = "com.github.andiim.plantscan.app"
-
applicationId = AppCoordinates.APP_ID
versionCode = AppCoordinates.APP_VERSION_CODE
versionName = AppCoordinates.APP_VERSION_NAME
- testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
- }
- buildFeatures {
- compose = true
- buildConfig = true
- }
- composeOptions { kotlinCompilerExtensionVersion = libs.versions.compose.compilerextension.get() }
- compileOptions {
- sourceCompatibility = JavaVersion.VERSION_17
- targetCompatibility = JavaVersion.VERSION_17
+
+ testInstrumentationRunner = "com.github.andiim.plantscan.core.testing.PsAppTestRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
}
- kotlinOptions { jvmTarget = JavaVersion.VERSION_17.toString() }
+
buildTypes {
- getByName("release") {
- isMinifyEnabled = false
+ debug {
+ applicationIdSuffix = PsBuildType.DEBUG.applicationIdSuffix
+ }
+ val release by getting {
+ isMinifyEnabled = true
+ applicationIdSuffix = PsBuildType.RELEASE.applicationIdSuffix
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
- "proguard-rules.pro"
+ "proguard-rules.pro",
)
+ signingConfig = signingConfigs.getByName("debug")
+
+ firebaseAppDistribution {
+ artifactType = "APK"
+ }
+ }
+ create("benchmark") {
+ initWith(release)
+ matchingFallbacks.add("release")
+ signingConfig = signingConfigs.getByName("debug")
+ proguardFiles("benchmark-rules.pro")
+ isMinifyEnabled = true
+ applicationIdSuffix = PsBuildType.BENCHMARK.applicationIdSuffix
+ }
+ }
+
+ packaging {
+ resources {
+ excludes.add("/META-INF/{AL2.0,LGPL2.1}")
+ }
+ }
+
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
}
+ animationsDisabled = true
}
lint {
- warningsAsErrors = true
- abortOnError = true
- baseline = File("lint-baseline.xml")
+ baseline = file("lint-baseline.xml")
}
- // Use this block to configure different flavors
-// flavorDimensions("version")
-// productFlavors {
-// create("full") {
-// dimension = "version"
-// applicationIdSuffix = ".full"
-// }
-// create("demo") {
-// dimension = "version"
-// applicationIdSuffix = ".demo"
-// }
-// }
+ namespace = "com.github.andiim.plantscan.app"
}
dependencies {
- // Logging
- implementation(libs.timber)
-
- // UI
- implementation(libs.material)
-
- // Hilt
- implementation(libs.dagger.hilt)
- implementation(libs.dagger.hilt.navigation.compose)
- kapt(libs.dagger.hilt.compiler)
-
- // Ext. Module
- implementation(projects.libraryAndroid)
- implementation(projects.libraryKotlin)
-
- // Compose
- implementation(platform(libs.compose.bom))
- implementation(libs.bundles.compose)
- debugImplementation(libs.bundles.compose.debug)
- implementation(libs.bundles.lifecycle)
-
- // Accompanist
- implementation(libs.accompanist.permission)
- implementation(libs.accompanist.webview)
-
- // Firebase
- implementation(platform(libs.firebase.bom))
- implementation(libs.bundles.firebase)
- implementation(libs.play.services.auth)
-
- // compat
+ implementation(project(":feature:findplant"))
+ implementation(project(":feature:plant"))
+ implementation(project(":feature:plantDetail"))
+ implementation(project(":feature:history"))
+ implementation(project(":feature:settings"))
+
+ implementation(project(":feature:camera"))
+ implementation(project(":feature:detect"))
+ implementation(project(":feature:detect-detail"))
+ implementation(project(":feature:suggest"))
+ implementation(project(":feature:account"))
+
+ implementation(project(":core:common"))
+ implementation(project(":core:ui"))
+ implementation(project(":core:designsystem"))
+ implementation(project(":core:data"))
+ implementation(project(":core:domain"))
+ implementation(project(":core:model"))
+
+ implementation(project(":core:auth"))
+ implementation(project(":core:analytics"))
+ implementation(project(":core:storage-upload"))
+ implementation(project(":core:firestore"))
+
+ androidTestImplementation(project(":core:testing"))
+ androidTestImplementation(project(":core:datastore-test"))
+ androidTestImplementation(project(":core:data-test"))
+ androidTestImplementation(project(":core:network"))
+ androidTestImplementation(libs.navigation.testing)
+ androidTestImplementation(libs.accompanist.testharness)
+ androidTestImplementation(kotlin("test"))
+
+ debugImplementation(libs.compose.ui.test.manifest)
+ debugImplementation(project(":ui-test-hilt-manifest"))
+
+ implementation(libs.camera)
+ implementation(libs.camera.core)
+ implementation(libs.camera.view)
+
+ implementation(libs.compose.activity)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.core.ktx)
-
- // Unit tests
- testImplementation(libs.junit)
- testImplementation(libs.dagger.hilt.testing)
- kaptTest(libs.dagger.hilt.compiler)
-
- // Instrument test
- androidTestImplementation(libs.androidx.test.ext.junit)
- androidTestImplementation(libs.androidx.test.ext.junit.ktx)
- androidTestImplementation(libs.androidx.test.rules)
- androidTestImplementation(libs.espresso.core)
- androidTestImplementation(libs.dagger.hilt.testing)
- androidTestImplementation(libs.kotlin.coroutines.test)
- androidTestImplementation(libs.truth)
- kaptAndroidTest(libs.dagger.hilt.compiler)
+ implementation(libs.androidx.core.splashscreen)
+ implementation(libs.compose.runtime)
+ implementation(libs.androidx.lifecycle.runtime.compose)
+ implementation(libs.compose.runtimeTracing)
+ implementation(libs.compose.materialWindow)
+ implementation(libs.dagger.hilt.navigation.compose)
+ implementation(libs.navigation.compose)
+ implementation(libs.androidx.window)
+ implementation(libs.androidx.profileinstaller)
+ implementation(libs.kotlinx.coroutines.guava)
+ implementation(libs.coil)
+ implementation(libs.timber)
}
-
-kapt { correctErrorTypes = true }
-hilt { enableAggregatingTask = true }
\ No newline at end of file
diff --git a/app/consumer-rules.pro b/app/consumer-rules.pro
new file mode 100644
index 00000000..17327391
--- /dev/null
+++ b/app/consumer-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/app/proguard-rules.pro b/app/proguard-rules.pro
index 2f9dc5a4..dec28160 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -1,21 +1,24 @@
-# Add project specific ProGuard rules here.
-# You can control the set of applied configuration files using the
-# proguardFiles setting in build.gradle.kts.
-#
-# For more details, see
-# http://developer.android.com/guide/developing/tools/proguard.html
+-dontwarn org.bouncycastle.jsse.BCSSLParameters
+-dontwarn org.bouncycastle.jsse.BCSSLSocket
+-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
+-dontwarn org.conscrypt.Conscrypt$Version
+-dontwarn org.conscrypt.Conscrypt
+-dontwarn org.conscrypt.ConscryptHostnameVerifier
+-dontwarn org.openjsse.javax.net.ssl.SSLParameters
+-dontwarn org.openjsse.javax.net.ssl.SSLSocket
+-dontwarn org.openjsse.net.ssl.OpenJSSE
-# If your project uses WebView with JS, uncomment the following
-# and specify the fully qualified class name to the JavaScript interface
-# class:
-#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
-# public *;
-#}
-
-# Uncomment this to preserve the line number information for
-# debugging stack traces.
-#-keepattributes SourceFile,LineNumberTable
-
-# If you keep the line number information, uncomment this to
-# hide the original source file name.
-#-renamesourcefileattribute SourceFile
+# Fix for Retrofit issue https://github.com/square/retrofit/issues/3751
+# Keep generic signature of Call, Response (R8 full mode strips signatures from non-kept items).
+-keep,allowobfuscation,allowshrinking interface retrofit2.Call
+-keep,allowobfuscation,allowshrinking class retrofit2.Response
+-keepclassmembers class com.github.andiim.plantscan.app.core.firestore.model.** { *; }
+-keepclassmembers class com.github.andiim.plantscan.app.core.domain.model.** { *; }
+# With R8 full mode generic signatures are stripped for classes that are not
+# kept. Suspend functions are wrapped in continuations where the type argument
+# is used.
+-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
+# Keep DataStore fields
+-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite* {
+ ;
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/github/andiim/plantscan/app/ui/NavigationTest.kt b/app/src/androidTest/java/com/github/andiim/plantscan/app/ui/NavigationTest.kt
new file mode 100644
index 00000000..79a9b7fe
--- /dev/null
+++ b/app/src/androidTest/java/com/github/andiim/plantscan/app/ui/NavigationTest.kt
@@ -0,0 +1,67 @@
+package com.github.andiim.plantscan.app.ui
+
+import androidx.annotation.StringRes
+import androidx.compose.ui.test.assertIsSelected
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import com.github.andiim.plantscan.core.rules.GrantPostNotificationsPermissionRule
+import dagger.hilt.android.testing.BindValue
+import dagger.hilt.android.testing.HiltAndroidRule
+import dagger.hilt.android.testing.HiltAndroidTest
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import kotlin.properties.ReadOnlyProperty
+import com.github.andiim.plantscan.feature.findplant.R as findPlantR
+
+/**
+ * Tests all the navigation that are handled by the navigation library.
+ */
+@HiltAndroidTest
+class NavigationTest {
+ /**
+ * Manages the components' state and is used to perform injection on your test.
+ */
+ @get:Rule(order = 0)
+ val hiltRule = HiltAndroidRule(this)
+
+ /**
+ * Create a temporary folder used to create a Data Store file. This guarantees that
+ * the file is removed in between test, preventing a crash.
+ */
+ @BindValue
+ @get:Rule(order = 1)
+ val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
+
+ /**
+ * Grant [android.Manifest.permission.POST_NOTIFICATIONS] permission.
+ */
+ @get:Rule(order = 2)
+ val postNotificationsPermission = GrantPostNotificationsPermissionRule()
+
+ /**
+ * Use the primary activity to initialize the app normally.
+ */
+ @get:Rule(order = 3)
+ val composeTestRule = createAndroidComposeRule()
+
+ // TODO INSERT NEEDS REPO HERE /
+
+ private fun AndroidComposeTestRule<*, *>.stringResource(@StringRes resId: Int) =
+ ReadOnlyProperty { _, _ -> activity.getString(resId) }
+
+ // The strings used for matching in these tests
+ private val findPlant by composeTestRule.stringResource(findPlantR.string.find_plant)
+
+ @Before
+ fun setup() = hiltRule.inject()
+
+ @Test
+ fun firstScreen_isFindPlant() {
+ composeTestRule.apply {
+ onNodeWithText(findPlant).assertIsSelected()
+ }
+ }
+}
diff --git a/app/src/androidTest/java/com/github/andiim/plantscan/app/ui/NavigationUiTest.kt b/app/src/androidTest/java/com/github/andiim/plantscan/app/ui/NavigationUiTest.kt
new file mode 100644
index 00000000..4977d32a
--- /dev/null
+++ b/app/src/androidTest/java/com/github/andiim/plantscan/app/ui/NavigationUiTest.kt
@@ -0,0 +1,213 @@
+package com.github.andiim.plantscan.app.ui
+
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
+import androidx.compose.material3.windowsizeclass.WindowSizeClass
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import com.github.andiim.plantscan.app.utils.BOTTOM_BAR_TAG
+import com.github.andiim.plantscan.app.utils.NAV_RAIL_TAG
+import com.github.andiim.plantscan.core.data.util.NetworkMonitor
+import com.github.andiim.plantscan.core.rules.GrantPostNotificationsPermissionRule
+import com.github.andiim.plantscan.uitesthiltmanifest.HiltComponentActivity
+import com.google.accompanist.testharness.TestHarness
+import dagger.hilt.android.testing.BindValue
+import dagger.hilt.android.testing.HiltAndroidRule
+import dagger.hilt.android.testing.HiltAndroidTest
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import javax.inject.Inject
+
+/**
+ * Tests that the navigation UI is rendered correctly on different screen sizes.
+ */
+@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
+@HiltAndroidTest
+class NavigationUiTest {
+ /**
+ * Manages the components' state and is used to perform injection on your test.
+ */
+ @get:Rule(order = 0)
+ val hiltRule = HiltAndroidRule(this)
+
+ /**
+ * Create a temporary folder used to create a Data Store file. This guarantees that
+ * the file is removed in between each test, preventing a crash.
+ */
+ @BindValue
+ @get:Rule(order = 1)
+ val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
+
+ /**
+ * Grant [android.Manifest.permission.POST_NOTIFICATIONS] permission.
+ */
+ @get:Rule(order = 2)
+ val postNotificationsPermission = GrantPostNotificationsPermissionRule()
+
+ /**
+ * Use a test activity to set the content on.
+ */
+ @get:Rule(order = 3)
+ val composeTestRule = createAndroidComposeRule()
+
+ @Inject
+ lateinit var networkMonitor: NetworkMonitor
+
+ @Before
+ fun setup() {
+ hiltRule.inject()
+ }
+
+ @Test
+ fun compactWidth_compactHeight_showsNavigationBar() {
+ composeTestRule.setContent {
+ TestHarness(size = DpSize(400.dp, 400.dp)) {
+ BoxWithConstraints {
+ MainApp(
+ windowSizeClass = WindowSizeClass.calculateFromSize(
+ DpSize(
+ maxWidth,
+ maxHeight,
+ ),
+ ),
+ networkMonitor = networkMonitor,
+ )
+ }
+ }
+ }
+
+ composeTestRule.onNodeWithTag(BOTTOM_BAR_TAG).assertIsDisplayed()
+ composeTestRule.onNodeWithTag(NAV_RAIL_TAG).assertDoesNotExist()
+ }
+
+ @Test
+ fun expandedWidth_compactHeight_showsNavigationRail() {
+ composeTestRule.setContent {
+ TestHarness(size = DpSize(900.dp, 400.dp)) {
+ BoxWithConstraints {
+ MainApp(
+ windowSizeClass = WindowSizeClass.calculateFromSize(
+ DpSize(
+ maxWidth,
+ maxHeight,
+ ),
+ ),
+ networkMonitor = networkMonitor,
+ )
+ }
+ }
+ }
+ composeTestRule.onNodeWithTag(NAV_RAIL_TAG).assertIsDisplayed()
+ composeTestRule.onNodeWithTag(BOTTOM_BAR_TAG).assertDoesNotExist()
+ }
+
+ @Test
+ fun compactWidth_mediumHeight_showsNavigationBar() {
+ composeTestRule.setContent {
+ TestHarness(size = DpSize(400.dp, 500.dp)) {
+ BoxWithConstraints {
+ MainApp(
+ windowSizeClass = WindowSizeClass.calculateFromSize(
+ DpSize(
+ maxWidth,
+ maxHeight,
+ ),
+ ),
+ networkMonitor = networkMonitor,
+ )
+ }
+ }
+ }
+ composeTestRule.onNodeWithTag(BOTTOM_BAR_TAG).assertIsDisplayed()
+ composeTestRule.onNodeWithTag(NAV_RAIL_TAG).assertDoesNotExist()
+ }
+
+ @Test
+ fun mediumWidth_mediumHeight_showsNavigationRail() {
+ composeTestRule.setContent {
+ TestHarness(size = DpSize(610.dp, 500.dp)) {
+ BoxWithConstraints {
+ MainApp(
+ windowSizeClass = WindowSizeClass.calculateFromSize(
+ DpSize(
+ maxWidth,
+ maxHeight,
+ ),
+ ),
+ networkMonitor = networkMonitor,
+ )
+ }
+ }
+ }
+ composeTestRule.onNodeWithTag(NAV_RAIL_TAG).assertIsDisplayed()
+ composeTestRule.onNodeWithTag(BOTTOM_BAR_TAG).assertDoesNotExist()
+ }
+
+ @Test
+ fun expandedWidth_mediumHeight_showsNavigationRail() {
+ composeTestRule.setContent {
+ TestHarness(size = DpSize(900.dp, 500.dp)) {
+ BoxWithConstraints {
+ MainApp(
+ windowSizeClass = WindowSizeClass.calculateFromSize(
+ DpSize(
+ maxWidth,
+ maxHeight,
+ ),
+ ),
+ networkMonitor = networkMonitor,
+ )
+ }
+ }
+ }
+ composeTestRule.onNodeWithTag(NAV_RAIL_TAG).assertIsDisplayed()
+ composeTestRule.onNodeWithTag(BOTTOM_BAR_TAG).assertDoesNotExist()
+ }
+
+ @Test
+ fun compactWidth_expandedHeight_showsNavigationBar() {
+ composeTestRule.setContent {
+ TestHarness(size = DpSize(400.dp, 1000.dp)) {
+ BoxWithConstraints {
+ MainApp(
+ windowSizeClass = WindowSizeClass.calculateFromSize(
+ DpSize(
+ maxWidth,
+ maxHeight,
+ ),
+ ),
+ networkMonitor = networkMonitor,
+ )
+ }
+ }
+ }
+ composeTestRule.onNodeWithTag(BOTTOM_BAR_TAG).assertIsDisplayed()
+ composeTestRule.onNodeWithTag(NAV_RAIL_TAG).assertDoesNotExist()
+ }
+
+ @Test
+ fun expandedWidth_expandedHeight_showsNavigationRail() {
+ composeTestRule.setContent {
+ TestHarness(size = DpSize(900.dp, 1000.dp)) {
+ BoxWithConstraints {
+ MainApp(
+ windowSizeClass = WindowSizeClass.calculateFromSize(
+ DpSize(
+ maxWidth,
+ maxHeight,
+ ),
+ ),
+ networkMonitor = networkMonitor,
+ )
+ }
+ }
+ }
+ composeTestRule.onNodeWithTag(NAV_RAIL_TAG).assertIsDisplayed()
+ composeTestRule.onNodeWithTag(BOTTOM_BAR_TAG).assertDoesNotExist()
+ }
+}
diff --git a/app/src/androidTest/java/com/github/andiim/plantscan/app/ui/PsAppStateTest.kt b/app/src/androidTest/java/com/github/andiim/plantscan/app/ui/PsAppStateTest.kt
new file mode 100644
index 00000000..1f140dfe
--- /dev/null
+++ b/app/src/androidTest/java/com/github/andiim/plantscan/app/ui/PsAppStateTest.kt
@@ -0,0 +1,154 @@
+package com.github.andiim.plantscan.app.ui
+
+import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
+import androidx.compose.material3.windowsizeclass.WindowSizeClass
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.ComposeNavigator
+import androidx.navigation.compose.composable
+import androidx.navigation.createGraph
+import androidx.navigation.testing.TestNavHostController
+import com.github.andiim.plantscan.core.testing.util.TestNetworkMonitor
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+/**
+ * Test [PsAppState].
+ */
+@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
+class PsAppStateTest {
+ @get:Rule
+ val composeTestRule = createComposeRule()
+ private val networkMonitor = TestNetworkMonitor()
+ private lateinit var state: PsAppState
+
+ @Test
+ fun psAppState_currentDestination() = runTest {
+ var currentDestination: String? = null
+ composeTestRule.setContent {
+ val navController = rememberTestNavController()
+ state = remember(navController) {
+ PsAppState(
+ navController = navController,
+ networkMonitor = networkMonitor,
+ windowSizeClass = getCompactWindowClass(),
+ coroutineScope = backgroundScope,
+ )
+ }
+ currentDestination = state.currentDestination?.route
+ LaunchedEffect(Unit) {
+ navController.setCurrentDestination("b")
+ }
+ }
+
+ assertEquals("b", currentDestination)
+ }
+
+ @Test
+ fun psAppSate_destinations() = runTest {
+ composeTestRule.setContent {
+ state = rememberPsAppState(
+ windowSizeClass = getCompactWindowClass(),
+ networkMonitor = networkMonitor,
+ )
+ }
+ assertEquals(3, state.topLevelDestinations.size)
+ assertTrue(state.topLevelDestinations[0].name.contains("find_plant", true))
+ assertTrue(state.topLevelDestinations[1].name.contains("history", true))
+ assertTrue(state.topLevelDestinations[2].name.contains("settings", true))
+ }
+
+ @Test
+ fun psAppState_showBottomBar_compact() = runTest {
+ composeTestRule.setContent {
+ state = PsAppState(
+ navController = NavHostController(LocalContext.current),
+ networkMonitor = networkMonitor,
+ windowSizeClass = getCompactWindowClass(),
+ coroutineScope = backgroundScope,
+ )
+ }
+
+ assertTrue(state.shouldShowBottomBar)
+ assertFalse(state.shouldShowNavRail)
+ }
+
+ @Test
+ fun psAppState_showNavRail_medium() = runTest {
+ composeTestRule.setContent {
+ state = PsAppState(
+ navController = NavHostController(LocalContext.current),
+ networkMonitor = networkMonitor,
+ windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(800.dp, 800.dp)),
+ coroutineScope = backgroundScope,
+ )
+ }
+
+ assertTrue(state.shouldShowNavRail)
+ assertFalse(state.shouldShowBottomBar)
+ }
+
+ @Test
+ fun psAppState_showNavRail_large() = runTest {
+ composeTestRule.setContent {
+ state = PsAppState(
+ navController = NavHostController(LocalContext.current),
+ networkMonitor = networkMonitor,
+ windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)),
+ coroutineScope = backgroundScope,
+ )
+ }
+
+ assertTrue(state.shouldShowNavRail)
+ assertFalse(state.shouldShowBottomBar)
+ }
+
+ @Test
+ fun stateIsOfflineWhenNetworkMonitorIsOffline() = runTest(UnconfinedTestDispatcher()) {
+ composeTestRule.setContent {
+ state = PsAppState(
+ navController = NavHostController(LocalContext.current),
+ networkMonitor = networkMonitor,
+ windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)),
+ coroutineScope = backgroundScope,
+ )
+ }
+
+ backgroundScope.launch { state.isOffline.collect() }
+ networkMonitor.setConnected(false)
+ assertEquals(
+ true,
+ state.isOffline.value,
+ )
+ }
+
+ private fun getCompactWindowClass() = WindowSizeClass.calculateFromSize(DpSize(500.dp, 300.dp))
+}
+
+@Composable
+private fun rememberTestNavController(): TestNavHostController {
+ val context = LocalContext.current
+ return remember {
+ TestNavHostController(context).apply {
+ navigatorProvider.addNavigator(ComposeNavigator())
+ graph = createGraph(startDestination = "a") {
+ composable("a") { }
+ composable("b") { }
+ composable("c") { }
+ }
+ }
+ }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ef26f813..a0fca144 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,45 +3,66 @@
xmlns:tools="http://schemas.android.com/tools">
+
+
+ android:theme="@style/Theme.PlantScan.Splash"
+ tools:targetApi="tiramisu">
+
-
-
-
+
-
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png
new file mode 100644
index 00000000..22e34285
Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/PlantScanActivity.kt b/app/src/main/java/com/github/andiim/plantscan/app/PlantScanActivity.kt
deleted file mode 100644
index c3206bf0..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/PlantScanActivity.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package com.github.andiim.plantscan.app
-
-import android.os.Bundle
-import androidx.activity.ComponentActivity
-import androidx.activity.compose.setContent
-import dagger.hilt.android.AndroidEntryPoint
-
-@AndroidEntryPoint
-class PlantScanActivity : ComponentActivity() {
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContent { PlantScanApp() }
- }
-}
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/PlantScanApp.kt b/app/src/main/java/com/github/andiim/plantscan/app/PlantScanApp.kt
deleted file mode 100644
index bc8d967a..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/PlantScanApp.kt
+++ /dev/null
@@ -1,112 +0,0 @@
-package com.github.andiim.plantscan.app
-
-import android.Manifest
-import android.content.Context
-import android.os.Build
-import androidx.annotation.RequiresApi
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Snackbar
-import androidx.compose.material3.SnackbarHost
-import androidx.compose.material3.SnackbarHostState
-import androidx.compose.material3.Surface
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.ReadOnlyComposable
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalConfiguration
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.unit.dp
-import androidx.navigation.NavHostController
-import androidx.navigation.compose.rememberNavController
-import com.github.andiim.plantscan.app.ui.common.composables.BottomBar
-import com.github.andiim.plantscan.app.ui.common.composables.PermissionDialog
-import com.github.andiim.plantscan.app.ui.common.composables.RationaleDialog
-import com.github.andiim.plantscan.app.ui.common.snackbar.SnackbarManager
-import com.github.andiim.plantscan.app.ui.navigation.SetupRootNavGraph
-import com.github.andiim.plantscan.app.ui.theme.PlantScanTheme
-import com.google.accompanist.permissions.ExperimentalPermissionsApi
-import com.google.accompanist.permissions.isGranted
-import com.google.accompanist.permissions.rememberPermissionState
-import com.google.accompanist.permissions.shouldShowRationale
-import kotlinx.coroutines.CoroutineScope
-
-@Composable
-fun PlantScanApp() {
- PlantScanTheme {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- RequestNotificationPermissionDialog()
- }
-
- Surface(
- modifier = Modifier.fillMaxSize(),
- color = MaterialTheme.colorScheme.background
- ) {
-
- val appState = rememberAppState()
- Scaffold(
- snackbarHost = {
- SnackbarHost(
- hostState = appState.snackbarHostState,
- modifier = Modifier.padding(8.dp),
- snackbar = { snackbarData ->
- Snackbar(
- snackbarData,
- contentColor = MaterialTheme.colorScheme.onPrimary
- )
- })
- },
- bottomBar = { BottomBar(state = appState) }
- ) { innerPadding ->
- SetupRootNavGraph(appState, modifier = Modifier.padding(innerPadding))
- }
- }
- }
-}
-
-@Composable
-@ReadOnlyComposable
-fun getContext(): Context {
- LocalConfiguration.current
- return LocalContext.current
-}
-
-@Composable
-fun rememberAppState(
- snackbarHostState: SnackbarHostState = SnackbarHostState(),
- navController: NavHostController = rememberNavController(),
- snackbarManager: SnackbarManager = SnackbarManager,
- getContext: Context = getContext(),
- coroutineScope: CoroutineScope = rememberCoroutineScope()
-) =
- remember(
- snackbarHostState,
- navController,
- snackbarManager,
- getContext,
- coroutineScope
- ) {
- PlantScanAppState(
- snackbarHostState,
- navController,
- snackbarManager,
- getContext,
- coroutineScope
- )
- }
-
-@RequiresApi(Build.VERSION_CODES.TIRAMISU)
-@OptIn(ExperimentalPermissionsApi::class)
-@Composable
-fun RequestNotificationPermissionDialog() {
- val permissionState =
- rememberPermissionState(permission = Manifest.permission.POST_NOTIFICATIONS)
-
- if (!permissionState.status.isGranted) {
- if (permissionState.status.shouldShowRationale) RationaleDialog()
- else PermissionDialog { permissionState.launchPermissionRequest() }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/PlantScanAppState.kt b/app/src/main/java/com/github/andiim/plantscan/app/PlantScanAppState.kt
deleted file mode 100644
index 3935b601..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/PlantScanAppState.kt
+++ /dev/null
@@ -1,69 +0,0 @@
-package com.github.andiim.plantscan.app
-
-import android.content.Context
-import android.content.Intent
-import android.net.Uri
-import androidx.compose.material3.SnackbarHostState
-import androidx.navigation.NavGraph.Companion.findStartDestination
-import androidx.navigation.NavHostController
-import com.github.andiim.plantscan.app.ui.common.snackbar.SnackbarManager
-import com.github.andiim.plantscan.app.ui.common.snackbar.SnackbarMessage.Companion.toMessage
-import com.github.andiim.plantscan.app.ui.navigation.Direction
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.flow.filterNotNull
-import kotlinx.coroutines.launch
-
-class PlantScanAppState(
- val snackbarHostState: SnackbarHostState,
- val navController: NavHostController,
- private val snackbarManager: SnackbarManager,
- private val context: Context,
- coroutineScope: CoroutineScope
-) {
- init {
- coroutineScope.launch {
- snackbarManager.snackbarMessages.filterNotNull().collect { snackbarMessage ->
- val text = snackbarMessage.toMessage(context.resources)
- snackbarHostState.showSnackbar(text)
- }
- }
- }
-
- fun popUp() {
- navController.popBackStack()
- }
-
- fun navigate(route: String, singleTopLaunch: Boolean = true) {
- if (route == Direction.Detect.route) {
- toDetectActivity()
- } else {
- navController.navigate(route) { launchSingleTop = singleTopLaunch }
- }
- }
-
- private fun toDetectActivity() {
- val intent = Intent(Intent.ACTION_VIEW).apply {
- data = Uri.parse("plantscan://detection")
- `package` = context.packageName
- }
- context.startActivity(intent)
- }
-
- fun navigateAndPopUp(route: String, popUp: String) {
- navController.navigate(route) {
- launchSingleTop = true
- popUpTo(popUp) { inclusive = true }
- }
- }
-
- fun clearAndNavigate(route: String) {
- navController.navigate(route) {
- popUpTo(navController.graph.findStartDestination().id) {
- // saveState = true
- inclusive = true
- }
- launchSingleTop = true
- // restoreState = true
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/PlantScanHiltApplication.kt b/app/src/main/java/com/github/andiim/plantscan/app/PlantScanHiltApplication.kt
deleted file mode 100644
index 42868a7a..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/PlantScanHiltApplication.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.github.andiim.plantscan.app
-
-import android.app.Application
-import com.github.andiim.plantscan.app.di.DebugModule.provideTimberTree
-import dagger.hilt.android.HiltAndroidApp
-import timber.log.Timber
-
-@HiltAndroidApp
-class PlantScanHiltApplication : Application() {
- override fun onCreate() {
- super.onCreate()
-
- if (BuildConfig.DEBUG) {
- Timber.plant(provideTimberTree())
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/PlantScanMessagingService.kt b/app/src/main/java/com/github/andiim/plantscan/app/PlantScanMessagingService.kt
deleted file mode 100644
index b77b40ab..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/PlantScanMessagingService.kt
+++ /dev/null
@@ -1,57 +0,0 @@
-package com.github.andiim.plantscan.app
-
-import android.app.NotificationChannel
-import android.app.NotificationManager
-import android.app.PendingIntent
-import android.content.Intent
-import android.os.Build
-import androidx.core.app.NotificationCompat
-import com.google.firebase.messaging.FirebaseMessagingService
-import com.google.firebase.messaging.RemoteMessage
-import timber.log.Timber
-import kotlin.random.Random
-
-class PlantScanMessagingService : FirebaseMessagingService() {
- private val random = Random
- override fun onMessageReceived(remoteMessage: RemoteMessage) {
- remoteMessage.notification?.let { message -> sendNotification(message) }
- }
-
- private fun sendNotification(message: RemoteMessage.Notification) {
- // If you want the notifications to appear when your app is in foreground
- val intent =
- Intent(this, PlantScanActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) }
-
- val pendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE)
-
- val channelId = this.getString(R.string.default_notification_channel_id)
-
- val notificationBuilder =
- NotificationCompat.Builder(this, channelId)
- .setContentTitle(message.title)
- .setContentText(message.body)
- .setSmallIcon(R.mipmap.ic_launcher)
- .setAutoCancel(true)
- .setContentIntent(pendingIntent)
-
- val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- val channel = NotificationChannel(channelId, CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT)
- manager.createNotificationChannel(channel)
- }
-
- manager.notify(random.nextInt(), notificationBuilder.build())
- }
-
- override fun onNewToken(token: String) {
- // If you want to send messages to this application instance or
- // manage this apps subscriptions on the server side, send the
- // FCM registration token to your app server.
- Timber.d("FCM", "onNewToken: $token")
- }
-
- companion object {
- const val CHANNEL_NAME = "FCM notification channel"
- }
-}
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/PlantscanApplication.kt b/app/src/main/java/com/github/andiim/plantscan/app/PlantscanApplication.kt
new file mode 100644
index 00000000..9de6e170
--- /dev/null
+++ b/app/src/main/java/com/github/andiim/plantscan/app/PlantscanApplication.kt
@@ -0,0 +1,24 @@
+package com.github.andiim.plantscan.app
+
+import android.app.Application
+import androidx.camera.core.CameraXConfig
+import com.github.andiim.plantscan.app.di.DebugModule
+import dagger.hilt.android.HiltAndroidApp
+import timber.log.Timber
+import javax.inject.Inject
+
+@HiltAndroidApp
+class PlantscanApplication : Application(), CameraXConfig.Provider {
+ @Inject
+ lateinit var cameraConfig: CameraXConfig
+
+ override fun getCameraXConfig(): CameraXConfig = cameraConfig
+
+ override fun onCreate() {
+ super.onCreate()
+
+ if (BuildConfig.DEBUG) {
+ Timber.plant(DebugModule.provideTimberTree())
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/data/firebase/AccountService.kt b/app/src/main/java/com/github/andiim/plantscan/app/data/firebase/AccountService.kt
deleted file mode 100644
index 923edebe..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/data/firebase/AccountService.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.github.andiim.plantscan.app.data.firebase
-
-import com.github.andiim.plantscan.app.data.model.User
-import kotlinx.coroutines.flow.Flow
-
-interface AccountService {
- val currentUserId: String
- val hasUser: Boolean
- val currentUser: Flow
-
- suspend fun authenticate(email: String, password: String)
- suspend fun sendRecoveryEmail(email: String)
- suspend fun createAnonymousAccount()
- suspend fun linkAccount(email: String, password: String)
- suspend fun deleteAccount()
- suspend fun signOut()
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/data/firebase/ConfigurationService.kt b/app/src/main/java/com/github/andiim/plantscan/app/data/firebase/ConfigurationService.kt
deleted file mode 100644
index bd8186dc..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/data/firebase/ConfigurationService.kt
+++ /dev/null
@@ -1,6 +0,0 @@
-package com.github.andiim.plantscan.app.data.firebase
-
-interface ConfigurationService {
- suspend fun fetchConfiguration(): Boolean
- val isShowTaskEditButtonConfig: Boolean
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/data/firebase/LogService.kt b/app/src/main/java/com/github/andiim/plantscan/app/data/firebase/LogService.kt
deleted file mode 100644
index 133a8031..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/data/firebase/LogService.kt
+++ /dev/null
@@ -1,5 +0,0 @@
-package com.github.andiim.plantscan.app.data.firebase
-
-interface LogService {
- fun logNonFatalCrash(throwable: Throwable)
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/data/firebase/PlantDatabase.kt b/app/src/main/java/com/github/andiim/plantscan/app/data/firebase/PlantDatabase.kt
deleted file mode 100644
index afcf0753..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/data/firebase/PlantDatabase.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package com.github.andiim.plantscan.app.data.firebase
-
-import androidx.paging.PagingData
-import com.github.andiim.plantscan.app.data.model.Plant
-import kotlinx.coroutines.flow.Flow
-
-interface PlantDatabase {
- fun getAllPlant(): Flow>
- fun getMyPlant(): Flow>
- fun searchPlant(query: String = ""): Flow>
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/data/firebase/implement/AccountServiceImpl.kt b/app/src/main/java/com/github/andiim/plantscan/app/data/firebase/implement/AccountServiceImpl.kt
deleted file mode 100644
index 38cc2fa1..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/data/firebase/implement/AccountServiceImpl.kt
+++ /dev/null
@@ -1,40 +0,0 @@
-package com.github.andiim.plantscan.app.data.firebase.implement
-
-import com.github.andiim.plantscan.app.data.firebase.AccountService
-import com.github.andiim.plantscan.app.data.model.User
-import javax.inject.Inject
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.flowOf
-
-class AccountServiceImpl @Inject constructor() : AccountService {
- override val currentUserId: String
- get() = "1"
- override val hasUser: Boolean
- get() = true
- override val currentUser: Flow
- get() = flowOf(User(id = "1", isAnonymous = true))
-
- override suspend fun authenticate(email: String, password: String) {
- TODO("Not yet implemented")
- }
-
- override suspend fun sendRecoveryEmail(email: String) {
- TODO("Not yet implemented")
- }
-
- override suspend fun createAnonymousAccount() {
- TODO("Not yet implemented")
- }
-
- override suspend fun linkAccount(email: String, password: String) {
- TODO("Not yet implemented")
- }
-
- override suspend fun deleteAccount() {
- TODO("Not yet implemented")
- }
-
- override suspend fun signOut() {
- TODO("Not yet implemented")
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/data/firebase/implement/ConfigurationServiceImpl.kt b/app/src/main/java/com/github/andiim/plantscan/app/data/firebase/implement/ConfigurationServiceImpl.kt
deleted file mode 100644
index 4e41ac54..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/data/firebase/implement/ConfigurationServiceImpl.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package com.github.andiim.plantscan.app.data.firebase.implement
-
-import com.github.andiim.plantscan.app.data.firebase.ConfigurationService
-import javax.inject.Inject
-
-class ConfigurationServiceImpl @Inject constructor(): ConfigurationService {
- override suspend fun fetchConfiguration(): Boolean = true
-
- override val isShowTaskEditButtonConfig: Boolean
- get() = true
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/data/firebase/implement/LogServiceImpl.kt b/app/src/main/java/com/github/andiim/plantscan/app/data/firebase/implement/LogServiceImpl.kt
deleted file mode 100644
index 940ef396..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/data/firebase/implement/LogServiceImpl.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-package com.github.andiim.plantscan.app.data.firebase.implement
-
-import com.github.andiim.plantscan.app.data.firebase.LogService
-import javax.inject.Inject
-
-class LogServiceImpl @Inject constructor(): LogService {
- override fun logNonFatalCrash(throwable: Throwable) {
- TODO("Not yet implemented")
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/data/firebase/implement/PlantDatabaseImpl.kt b/app/src/main/java/com/github/andiim/plantscan/app/data/firebase/implement/PlantDatabaseImpl.kt
deleted file mode 100644
index 6a702b1d..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/data/firebase/implement/PlantDatabaseImpl.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-package com.github.andiim.plantscan.app.data.firebase.implement
-
-import androidx.paging.PagingData
-import com.github.andiim.plantscan.app.data.firebase.PlantDatabase
-import com.github.andiim.plantscan.app.data.model.Plant
-import javax.inject.Inject
-import kotlinx.coroutines.flow.Flow
-
-class PlantDatabaseImpl @Inject constructor() : PlantDatabase {
- override fun getAllPlant(): Flow> {
- TODO("Not yet implemented")
- }
-
- override fun getMyPlant(): Flow> {
- TODO("Not yet implemented")
- }
-
- override fun searchPlant(query: String): Flow> {
- TODO("Not yet implemented")
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/data/model/Image.kt b/app/src/main/java/com/github/andiim/plantscan/app/data/model/Image.kt
deleted file mode 100644
index f1977672..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/data/model/Image.kt
+++ /dev/null
@@ -1,3 +0,0 @@
-package com.github.andiim.plantscan.app.data.model
-
-data class Image(val attribution: String = "", val file: String = "", val name: String = "")
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/data/model/Plant.kt b/app/src/main/java/com/github/andiim/plantscan/app/data/model/Plant.kt
deleted file mode 100644
index 9adf9ae0..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/data/model/Plant.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package com.github.andiim.plantscan.app.data.model
-
-data class Plant(
- val id: String = "",
- val name: String = "",
- val species: String = "",
- val type: String = "",
- var images : List? = null,
- val commonName: List = listOf(),
- var detail: PlantDetail? = null
-)
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/data/model/PlantDetail.kt b/app/src/main/java/com/github/andiim/plantscan/app/data/model/PlantDetail.kt
deleted file mode 100644
index a5ef6a43..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/data/model/PlantDetail.kt
+++ /dev/null
@@ -1,6 +0,0 @@
-package com.github.andiim.plantscan.app.data.model
-
-data class PlantDetail(
- val classification: Taxonomy? = null,
- val description: String? = null
-)
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/data/model/Taxonomy.kt b/app/src/main/java/com/github/andiim/plantscan/app/data/model/Taxonomy.kt
deleted file mode 100644
index 5a5df321..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/data/model/Taxonomy.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-package com.github.andiim.plantscan.app.data.model
-
-data class Taxonomy(
- val id: String = "",
- val phylum: String = "",
- val className: String = "",
- val order: String = "",
- val family: String = "",
- val genus: String = ""
-)
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/di/DebugModule.kt b/app/src/main/java/com/github/andiim/plantscan/app/di/DebugModule.kt
index ad9be34c..9debfdcc 100644
--- a/app/src/main/java/com/github/andiim/plantscan/app/di/DebugModule.kt
+++ b/app/src/main/java/com/github/andiim/plantscan/app/di/DebugModule.kt
@@ -1,5 +1,6 @@
package com.github.andiim.plantscan.app.di
+import com.github.andiim.plantscan.app.utils.logger.TimberDebugTree
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -10,4 +11,4 @@ import dagger.hilt.components.SingletonComponent
object DebugModule {
@Provides
fun provideTimberTree(): TimberDebugTree = TimberDebugTree()
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/di/JankStatsModule.kt b/app/src/main/java/com/github/andiim/plantscan/app/di/JankStatsModule.kt
new file mode 100644
index 00000000..901c5aa1
--- /dev/null
+++ b/app/src/main/java/com/github/andiim/plantscan/app/di/JankStatsModule.kt
@@ -0,0 +1,38 @@
+package com.github.andiim.plantscan.app.di
+
+import android.app.Activity
+import android.view.Window
+import androidx.metrics.performance.JankStats
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.components.ActivityComponent
+import timber.log.Timber
+
+@Module
+@InstallIn(ActivityComponent::class)
+object JankStatsModule {
+ @Provides
+ fun providesOnFrameListener(): JankStats.OnFrameListener {
+ return JankStats.OnFrameListener { frameData ->
+ if (frameData.isJank) {
+ Timber.tag("Ps Jank").v(frameData.toString())
+ }
+ }
+ }
+
+ @Provides
+ fun providesWindow(
+ activity: Activity
+ ): Window {
+ return activity.window
+ }
+
+ @Provides
+ fun providesJankStats(
+ window: Window,
+ frameListener: JankStats.OnFrameListener,
+ ): JankStats {
+ return JankStats.createAndTrack(window, frameListener)
+ }
+}
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/di/ServiceModule.kt b/app/src/main/java/com/github/andiim/plantscan/app/di/ServiceModule.kt
deleted file mode 100644
index 98d03b40..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/di/ServiceModule.kt
+++ /dev/null
@@ -1,30 +0,0 @@
-package com.github.andiim.plantscan.app.di
-
-import com.github.andiim.plantscan.app.data.firebase.AccountService
-import com.github.andiim.plantscan.app.data.firebase.ConfigurationService
-import com.github.andiim.plantscan.app.data.firebase.LogService
-import com.github.andiim.plantscan.app.data.firebase.PlantDatabase
-import com.github.andiim.plantscan.app.data.firebase.implement.AccountServiceImpl
-import com.github.andiim.plantscan.app.data.firebase.implement.ConfigurationServiceImpl
-import com.github.andiim.plantscan.app.data.firebase.implement.LogServiceImpl
-import com.github.andiim.plantscan.app.data.firebase.implement.PlantDatabaseImpl
-import dagger.Binds
-import dagger.Module
-import dagger.hilt.InstallIn
-import dagger.hilt.components.SingletonComponent
-
-@Module
-@InstallIn(SingletonComponent::class)
-abstract class ServiceModule {
- @Binds
- abstract fun provideAccountService(service: AccountServiceImpl): AccountService
-
- @Binds
- abstract fun provideLogService(service: LogServiceImpl): LogService
-
- @Binds
- abstract fun providePlantDatabase(service: PlantDatabaseImpl): PlantDatabase
-
- @Binds
- abstract fun provideConfigurationService(service: ConfigurationServiceImpl): ConfigurationService
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/navigation/PsHost.kt b/app/src/main/java/com/github/andiim/plantscan/app/navigation/PsHost.kt
new file mode 100644
index 00000000..96f451f6
--- /dev/null
+++ b/app/src/main/java/com/github/andiim/plantscan/app/navigation/PsHost.kt
@@ -0,0 +1,102 @@
+package com.github.andiim.plantscan.app.navigation
+
+import androidx.compose.material3.SnackbarDuration
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.navigation.compose.NavHost
+import com.github.andiim.plantscan.app.ui.PsAppState
+import com.github.andiim.plantscan.core.ui.navigation.AppDestination
+import com.github.andiim.plantscan.feature.account.navigation.authScreen
+import com.github.andiim.plantscan.feature.account.navigation.navigateToAuth
+import com.github.andiim.plantscan.feature.camera.navigation.cameraScreen
+import com.github.andiim.plantscan.feature.camera.navigation.navigateToCamera
+import com.github.andiim.plantscan.feature.detect.detail.navigation.detectDetailScreen
+import com.github.andiim.plantscan.feature.detect.detail.navigation.navigateToDetectionDetail
+import com.github.andiim.plantscan.feature.detect.navigation.detectScreen
+import com.github.andiim.plantscan.feature.detect.navigation.navigateToDetection
+import com.github.andiim.plantscan.feature.findplant.navigation.FindPlantGraph
+import com.github.andiim.plantscan.feature.findplant.navigation.findPlantGraph
+import com.github.andiim.plantscan.feature.history.navigation.historyScreen
+import com.github.andiim.plantscan.feature.plant.navigation.navigateToPlants
+import com.github.andiim.plantscan.feature.plant.navigation.plantScreen
+import com.github.andiim.plantscan.feature.plantdetail.navigation.navigateToPlantDetail
+import com.github.andiim.plantscan.feature.plantdetail.navigation.plantDetailScreen
+import com.github.andiim.plantscan.feature.settings.navigation.clearAndNavigateSettings
+import com.github.andiim.plantscan.feature.settings.navigation.settingsGraph
+import com.github.andiim.plantscan.feature.suggest.navigation.navigateToSuggest
+import com.github.andiim.plantscan.feature.suggest.navigation.suggestScreen
+
+/**
+ * Top-level navigation graph. Navigation is organized as explained at
+ * https://d.android.com/jetpack/compose/nav-adaptive/
+ *
+ * The navigation graph defined in this file defines the different top level routes. Navigation
+ * within each route is handled using state and Back Handlers.
+ */
+@Composable
+fun PsHost(
+ appState: PsAppState,
+ onShowSnackbar: suspend (String, String?, SnackbarDuration?) -> Boolean,
+ modifier: Modifier = Modifier,
+ startDestination: AppDestination = FindPlantGraph,
+) {
+ val navController = appState.navController
+ NavHost(
+ navController = navController,
+ startDestination = startDestination.route,
+ modifier = modifier,
+ ) {
+ findPlantGraph(
+ onItemClick = navController::navigateToPlantDetail,
+ onCameraClick = navController::navigateToCamera,
+ onPlantsClick = navController::navigateToPlants,
+ nestedGraphs = {
+ cameraScreen(
+ onBackClick = navController::popBackStack,
+ onShowSnackbar = onShowSnackbar,
+ onImageCaptured = navController::navigateToDetection,
+ )
+ plantScreen(
+ onBackClick = navController::popBackStack,
+ onPlantClick = navController::navigateToPlantDetail,
+ )
+ },
+ )
+
+ plantDetailScreen(
+ onBackClick = navController::popBackStack,
+ )
+
+ detectScreen(
+ onBackClick = navController::popBackStack,
+ onShowSnackbar = onShowSnackbar,
+ onSuggestClick = navController::navigateToSuggest,
+ )
+
+ detectDetailScreen(
+ onBackClick = navController::popBackStack,
+ onSuggestClick = navController::navigateToSuggest
+ )
+
+ suggestScreen(
+ onBackClick = navController::popBackStack,
+ onShowSnackbar = onShowSnackbar,
+ onLoginPressed = navController::navigateToAuth,
+ )
+
+ historyScreen(
+ onDetailClick = navController::navigateToDetectionDetail,
+ )
+
+ settingsGraph(
+ onLoginClick = navController::navigateToAuth,
+ nestedGraphs = {
+ authScreen(
+ onBackPressed = navController::popBackStack,
+ authCallback = navController::clearAndNavigateSettings,
+ onShowSnackbar = onShowSnackbar,
+ )
+ },
+ )
+ }
+}
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/navigation/TopLevelDestination.kt b/app/src/main/java/com/github/andiim/plantscan/app/navigation/TopLevelDestination.kt
new file mode 100644
index 00000000..614a6b7b
--- /dev/null
+++ b/app/src/main/java/com/github/andiim/plantscan/app/navigation/TopLevelDestination.kt
@@ -0,0 +1,38 @@
+package com.github.andiim.plantscan.app.navigation
+
+import androidx.compose.ui.graphics.vector.ImageVector
+import com.github.andiim.plantscan.core.designsystem.icon.PsIcons
+import com.github.andiim.plantscan.feature.findplant.R as findPlantR
+import com.github.andiim.plantscan.feature.history.R as historyR
+import com.github.andiim.plantscan.feature.settings.R as settingsR
+
+/**
+ * Type for the top level destinations in the application. Each of these destinations
+ * can contain one or more screens (based on the window size). Navigation from one screen to the
+ * next within a single destination will be handled directly in composables.
+ */
+enum class TopLevelDestination(
+ val selectedIcon: ImageVector,
+ val unselectedIcon: ImageVector,
+ val iconTextId: Int,
+ val titleTextId: Int,
+) {
+ FIND_PLANT(
+ selectedIcon = PsIcons.Home,
+ unselectedIcon = PsIcons.HomeBorder,
+ iconTextId = findPlantR.string.find_plant,
+ titleTextId = findPlantR.string.find_plant,
+ ),
+ HISTORY(
+ selectedIcon = PsIcons.Garden,
+ unselectedIcon = PsIcons.GardenBorder,
+ iconTextId = historyR.string.detection_history,
+ titleTextId = historyR.string.detection_history,
+ ),
+ SETTINGS(
+ selectedIcon = PsIcons.Settings,
+ unselectedIcon = PsIcons.SettingsBorder,
+ iconTextId = settingsR.string.settings,
+ titleTextId = settingsR.string.settings,
+ ),
+}
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/MainActivity.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/MainActivity.kt
new file mode 100644
index 00000000..c5d1083a
--- /dev/null
+++ b/app/src/main/java/com/github/andiim/plantscan/app/ui/MainActivity.kt
@@ -0,0 +1,213 @@
+package com.github.andiim.plantscan.app.ui
+
+import android.graphics.Color
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.SystemBarStyle
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.viewModels
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
+import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import androidx.core.view.WindowCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.metrics.performance.JankStats
+import androidx.profileinstaller.ProfileVerifier
+import com.github.andiim.plantscan.app.ui.MainActivityUiState.Loading
+import com.github.andiim.plantscan.app.ui.MainActivityUiState.Success
+import com.github.andiim.plantscan.core.analytics.AnalyticsHelper
+import com.github.andiim.plantscan.core.analytics.LocalAnalyticsHelper
+import com.github.andiim.plantscan.core.data.util.NetworkMonitor
+import com.github.andiim.plantscan.core.designsystem.theme.PsTheme
+import com.github.andiim.plantscan.core.model.data.DarkThemeConfig
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.guava.await
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import timber.log.Timber
+import javax.inject.Inject
+
+@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
+@AndroidEntryPoint
+class MainActivity : ComponentActivity() {
+
+ /**
+ * Lazily inject [JankStats], which is used to track jank throughout the app.
+ */
+ @Inject
+ lateinit var lazyStats: dagger.Lazy
+
+ @Inject
+ lateinit var networkMonitor: NetworkMonitor
+
+ @Inject
+ lateinit var analyticsHelper: AnalyticsHelper
+
+ private val viewModel: MainActivityViewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ val splashScreen = installSplashScreen()
+ super.onCreate(savedInstanceState)
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+ var uiState: MainActivityUiState by mutableStateOf(Loading)
+
+ // Update the uiState
+ lifecycleScope.launch {
+ lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.uiState.onEach {
+ uiState = it
+ }.collect()
+ }
+ }
+
+ // Keep the splash screen on-screen until the UI state is loaded. This condition is
+ // evaluated each time the app needs to be redrawn so it should be fast to avoid blocking
+ // the UI.
+ splashScreen.setKeepOnScreenCondition {
+ when (uiState) {
+ Loading -> true
+ is Success -> false
+ }
+ }
+
+ // Turn off the decor fitting system windows, which allows us to handle insets,
+ // including IME animations, and go edge-to-edge
+ // This also sets up the initial system bar style based on the platform theme
+ enableEdgeToEdge()
+
+ setContent {
+ val darkTheme = shouldUseDarkTheme(uiState)
+
+ // Update the edge to edge configuration to match the theme
+ // This is the same parameters as the default enableEdgeToEdge call, but we manually
+ // resolve whether or not to show dark theme using uiState, since it can be different
+ // than the configuration's dark theme value based on the user preference.
+ DisposableEffect(darkTheme) {
+ enableEdgeToEdge(
+ statusBarStyle = SystemBarStyle.auto(
+ Color.TRANSPARENT,
+ Color.TRANSPARENT,
+ ) { darkTheme },
+ navigationBarStyle = SystemBarStyle.auto(
+ lightScrim,
+ darkScrim,
+ ) { darkTheme },
+ )
+ onDispose {}
+ }
+
+ CompositionLocalProvider(LocalAnalyticsHelper provides analyticsHelper) {
+ PsTheme(
+ darkTheme = darkTheme,
+ disableDynamicTheming = shouldDisableDynamicTheming(uiState),
+ ) {
+ MainApp(
+ networkMonitor = networkMonitor,
+ windowSizeClass = calculateWindowSizeClass(this),
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+ }
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ lazyStats.get().isTrackingEnabled = true
+ lifecycleScope.launch {
+ logCompilationStatus()
+ }
+ }
+
+ override fun onPause() {
+ super.onPause()
+ lazyStats.get().isTrackingEnabled = false
+ }
+
+ /**
+ * Logs the app's Baseline Profile Compilation Status using [ProfileVerifier].
+ */
+ private suspend fun logCompilationStatus() {
+ /*
+ When delivering through Google Play, the baseline profile is compiled during installation.
+ In this case you will see the correct state logged without any further action necessary.
+ To verify baseline profile installation locally, you need to manually trigger baseline
+ profile installation.
+ For immediate compilation, call:
+ `adb shell cmd package compile -f -m speed-profile com.example.macrobenchmark.target`
+ You can also trigger background optimizations:
+ `adb shell pm bg-dexopt-job`
+ Both jobs run asynchronously and might take some time complete.
+ To see quick turnaround of the ProfileVerifier, we recommend using `speed-profile`.
+ If you don't do either of these steps, you might only see the profile status reported as
+ "enqueued for compilation" when running the sample locally.
+ */
+ withContext(Dispatchers.IO) {
+ val status = ProfileVerifier.getCompilationStatusAsync().await()
+ Timber.d("ProfileInstaller status code: ${status.profileInstallResultCode}")
+ Timber.d(
+ when {
+ status.isCompiledWithProfile -> "ProfileInstaller: is compiled with profile"
+ status.hasProfileEnqueuedForCompilation() ->
+ "ProfileInstaller: Enqueued for compilation"
+
+ else -> "Profile not compiled or enqueued"
+ },
+ )
+ }
+ }
+}
+
+/**
+ * Returns `true` if the dynamic color is disabled, as a function of the [uiState].
+ */
+@Composable
+private fun shouldDisableDynamicTheming(
+ uiState: MainActivityUiState,
+): Boolean = when (uiState) {
+ Loading -> false
+ is Success -> !uiState.userData.useDynamicColor
+}
+
+/**
+ * Returns `true` if dark theme should be used, as a function of the [uiState] and the
+ * current system context.
+ */
+@Composable
+private fun shouldUseDarkTheme(
+ uiState: MainActivityUiState,
+): Boolean = when (uiState) {
+ Loading -> isSystemInDarkTheme()
+ is Success -> when (uiState.userData.darkThemeConfig) {
+ DarkThemeConfig.FOLLOW_SYSTEM -> isSystemInDarkTheme()
+ DarkThemeConfig.LIGHT -> false
+ DarkThemeConfig.DARK -> true
+ }
+}
+
+/**
+ * The default light scrim, as defined by androidx and the platform:
+ * https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:activity/activity/src/main/java/androidx/activity/EdgeToEdge.kt;l=35-38;drc=27e7d52e8604a080133e8b842db10c89b4482598
+ */
+private val lightScrim = Color.argb(0xe6, 0xFF, 0xFF, 0xFF)
+
+/**
+ * The default dark scrim, as defined by androidx and the platform:
+ * https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:activity/activity/src/main/java/androidx/activity/EdgeToEdge.kt;l=40-44;drc=27e7d52e8604a080133e8b842db10c89b4482598
+ */
+private val darkScrim = Color.argb(0x80, 0x1b, 0x1b, 0x1b)
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/MainActivityViewModel.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/MainActivityViewModel.kt
new file mode 100644
index 00000000..7651d7e2
--- /dev/null
+++ b/app/src/main/java/com/github/andiim/plantscan/app/ui/MainActivityViewModel.kt
@@ -0,0 +1,41 @@
+package com.github.andiim.plantscan.app.ui
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.github.andiim.plantscan.app.ui.MainActivityUiState.Loading
+import com.github.andiim.plantscan.app.ui.MainActivityUiState.Success
+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.UserData
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import javax.inject.Inject
+
+@HiltViewModel
+class MainActivityViewModel @Inject constructor(
+ userDataRepository: UserDataRepository,
+ userLogin: GetUserLoginInfoUseCase,
+) : ViewModel() {
+ val uiState = combine(userLogin(), userDataRepository.userData) { auth, repo ->
+ Success(
+ userData = UserData(
+ isLogin = !auth.isAnonymous,
+ userId = auth.userId,
+ darkThemeConfig = repo.darkThemeConfig,
+ useDynamicColor = repo.useDynamicColor,
+ shouldHideOnboarding = repo.shouldHideOnboarding,
+ ),
+ )
+ }.stateIn(
+ scope = viewModelScope,
+ initialValue = Loading,
+ started = SharingStarted.WhileSubscribed(5_000),
+ )
+}
+
+sealed interface MainActivityUiState {
+ data object Loading : MainActivityUiState
+ data class Success(val userData: UserData) : MainActivityUiState
+}
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/MainApp.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/MainApp.kt
new file mode 100644
index 00000000..11740d1e
--- /dev/null
+++ b/app/src/main/java/com/github/andiim/plantscan/app/ui/MainApp.kt
@@ -0,0 +1,219 @@
+package com.github.andiim.plantscan.app.ui
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.WindowInsetsSides
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.only
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.layout.safeDrawingPadding
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarDuration
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.SnackbarResult
+import androidx.compose.material3.Text
+import androidx.compose.material3.windowsizeclass.WindowSizeClass
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.testTagsAsResourceId
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.navigation.NavDestination
+import androidx.navigation.NavDestination.Companion.hierarchy
+import com.github.andiim.plantscan.app.R
+import com.github.andiim.plantscan.app.navigation.PsHost
+import com.github.andiim.plantscan.app.navigation.TopLevelDestination
+import com.github.andiim.plantscan.app.navigation.TopLevelDestination.FIND_PLANT
+import com.github.andiim.plantscan.app.utils.BOTTOM_BAR_TAG
+import com.github.andiim.plantscan.app.utils.NAV_RAIL_TAG
+import com.github.andiim.plantscan.app.utils.snackbar.showMessage
+import com.github.andiim.plantscan.core.data.util.NetworkMonitor
+import com.github.andiim.plantscan.core.designsystem.component.PsAnimatedVisibility
+import com.github.andiim.plantscan.core.designsystem.component.PsAnimatedVisibilityData
+import com.github.andiim.plantscan.core.designsystem.component.PsBackground
+import com.github.andiim.plantscan.core.designsystem.component.PsNavigationBar
+import com.github.andiim.plantscan.core.designsystem.component.PsNavigationBarItem
+import com.github.andiim.plantscan.core.designsystem.component.PsNavigationRail
+import com.github.andiim.plantscan.core.designsystem.component.PsNavigationRailItem
+import com.github.andiim.plantscan.core.designsystem.component.PsTopAppBar
+import timber.log.Timber
+
+@OptIn(
+ ExperimentalComposeUiApi::class,
+ ExperimentalMaterial3Api::class,
+)
+@Suppress("detekt:LongMethod")
+@Composable
+fun MainApp(
+ windowSizeClass: WindowSizeClass,
+ networkMonitor: NetworkMonitor,
+ modifier: Modifier = Modifier,
+ appState: PsAppState = rememberPsAppState(
+ networkMonitor = networkMonitor,
+ windowSizeClass = windowSizeClass,
+ ),
+) {
+ PsBackground {
+ val snackbarHostState = remember { SnackbarHostState() }
+ val isOffline by appState.isOffline.collectAsStateWithLifecycle()
+ val message = stringResource(R.string.not_connected)
+ val destination = appState.currentTopLevelDestination
+ LaunchedEffect(isOffline) {
+ if (isOffline) {
+ snackbarHostState.showMessage(
+ message = message,
+ duration = SnackbarDuration.Indefinite,
+ )
+ }
+ }
+
+ Scaffold(
+ modifier = modifier
+ .semantics {
+ testTagsAsResourceId = true
+ },
+ topBar = {
+ destination?.let { topDest ->
+ AnimatedVisibility(topDest != FIND_PLANT) {
+ PsTopAppBar(titleRes = topDest.titleTextId)
+ }
+ }
+ },
+ containerColor = Color.Transparent,
+ contentColor = MaterialTheme.colorScheme.onBackground,
+ contentWindowInsets = WindowInsets.safeDrawing,
+ snackbarHost = { SnackbarHost(snackbarHostState) },
+ bottomBar = {
+ PsAnimatedVisibility(
+ destination != null && appState.shouldShowBottomBar,
+ animatedData = PsAnimatedVisibilityData.BottomBar,
+ ) {
+ PsBottomBar(
+ destinations = appState.topLevelDestinations,
+ onNavigateToDestination = appState::navigateToTopLevelDestination,
+ currentDestination = appState.currentDestination,
+ modifier = Modifier.testTag(BOTTOM_BAR_TAG),
+ )
+ }
+ },
+ ) { paddingValues ->
+ Row(
+ Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .consumeWindowInsets(paddingValues)
+ .windowInsetsPadding(
+ WindowInsets.safeDrawing.only(
+ WindowInsetsSides.Horizontal,
+ ),
+ ),
+ ) {
+ PsAnimatedVisibility(destination != null && appState.shouldShowNavRail) {
+ PsNavRail(
+ destinations = appState.topLevelDestinations,
+ onNavigateToDestination = appState::navigateToTopLevelDestination,
+ currentDestination = appState.currentDestination,
+ modifier = Modifier
+ .testTag(NAV_RAIL_TAG)
+ .safeDrawingPadding(),
+ )
+ }
+ PsHost(
+ appState = appState,
+ onShowSnackbar = { message, action, duration ->
+ snackbarHostState.showMessage(
+ message = message,
+ actionLabel = action,
+ duration = duration ?: SnackbarDuration.Short,
+ ) == SnackbarResult.ActionPerformed
+ },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun PsNavRail(
+ destinations: List,
+ onNavigateToDestination: (TopLevelDestination) -> Unit,
+ currentDestination: NavDestination?,
+ modifier: Modifier = Modifier,
+) {
+ PsNavigationRail(modifier = modifier) {
+ destinations.forEach { destination ->
+ val selected = currentDestination.isTopLevelDestinationInHierarchy(destination)
+ Timber.d("${destination.name} SELECTED $selected")
+ PsNavigationRailItem(
+ selected = selected,
+ onClick = { onNavigateToDestination(destination) },
+ icon = {
+ Icon(
+ imageVector = destination.unselectedIcon,
+ contentDescription = null,
+ )
+ },
+ selectedIcon = {
+ Icon(
+ imageVector = destination.selectedIcon,
+ contentDescription = null,
+ )
+ },
+ label = { Text(stringResource(destination.iconTextId)) },
+ modifier = Modifier,
+ )
+ }
+ }
+}
+
+@Composable
+fun PsBottomBar(
+ destinations: List,
+ onNavigateToDestination: (TopLevelDestination) -> Unit,
+ currentDestination: NavDestination?,
+ modifier: Modifier = Modifier,
+) {
+ PsNavigationBar(modifier = modifier) {
+ destinations.forEach { destination ->
+ val selected = currentDestination.isTopLevelDestinationInHierarchy(destination)
+ PsNavigationBarItem(
+ selected = selected,
+ onClick = { onNavigateToDestination(destination) },
+ icon = {
+ Icon(
+ imageVector = destination.unselectedIcon,
+ contentDescription = null,
+ )
+ },
+ selectedIcon = {
+ Icon(
+ imageVector = destination.selectedIcon,
+ contentDescription = null,
+ )
+ },
+ label = { Text(stringResource(destination.iconTextId)) },
+ modifier = Modifier,
+ )
+ }
+ }
+}
+
+private fun NavDestination?.isTopLevelDestinationInHierarchy(destination: TopLevelDestination) =
+ this?.hierarchy?.any {
+ it.route?.contains(destination.name, true) ?: false
+ } ?: false
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/PsAppState.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/PsAppState.kt
new file mode 100644
index 00000000..e63b2ab0
--- /dev/null
+++ b/app/src/main/java/com/github/andiim/plantscan/app/ui/PsAppState.kt
@@ -0,0 +1,141 @@
+package com.github.andiim.plantscan.app.ui
+
+import androidx.compose.material3.windowsizeclass.WindowSizeClass
+import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.navigation.NavController
+import androidx.navigation.NavDestination
+import androidx.navigation.NavGraph.Companion.findStartDestination
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.currentBackStackEntryAsState
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.navOptions
+import androidx.tracing.trace
+import com.github.andiim.plantscan.app.navigation.TopLevelDestination
+import com.github.andiim.plantscan.app.navigation.TopLevelDestination.FIND_PLANT
+import com.github.andiim.plantscan.app.navigation.TopLevelDestination.HISTORY
+import com.github.andiim.plantscan.app.navigation.TopLevelDestination.SETTINGS
+import com.github.andiim.plantscan.core.data.util.NetworkMonitor
+import com.github.andiim.plantscan.core.ui.TrackDisposableJank
+import com.github.andiim.plantscan.feature.findplant.navigation.FindPlant
+import com.github.andiim.plantscan.feature.findplant.navigation.navigateToFindPlant
+import com.github.andiim.plantscan.feature.history.navigation.History
+import com.github.andiim.plantscan.feature.history.navigation.navigateToHistory
+import com.github.andiim.plantscan.feature.settings.navigation.Settings
+import com.github.andiim.plantscan.feature.settings.navigation.navigateToSettings
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+
+@Composable
+fun rememberPsAppState(
+ windowSizeClass: WindowSizeClass,
+ networkMonitor: NetworkMonitor,
+ navController: NavHostController = rememberNavController(),
+ coroutineScope: CoroutineScope = rememberCoroutineScope(),
+): PsAppState {
+ NavigationTrackingSideEffect(navController)
+ return remember(
+ navController,
+ coroutineScope,
+ windowSizeClass,
+ networkMonitor,
+ ) {
+ PsAppState(
+ navController,
+ coroutineScope,
+ windowSizeClass,
+ networkMonitor,
+ )
+ }
+}
+
+@Stable
+class PsAppState(
+ val navController: NavHostController,
+ val coroutineScope: CoroutineScope,
+ val windowSizeClass: WindowSizeClass,
+ networkMonitor: NetworkMonitor,
+) {
+ val currentDestination: NavDestination?
+ @Composable get() = navController
+ .currentBackStackEntryAsState().value?.destination
+
+ val currentTopLevelDestination: TopLevelDestination?
+ @Composable get() = when (currentDestination?.route) {
+ FindPlant.route -> FIND_PLANT
+ History.route -> HISTORY
+ Settings.route -> SETTINGS
+ else -> null
+ }
+
+ val shouldShowBottomBar: Boolean
+ get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact
+
+ val shouldShowNavRail: Boolean
+ get() = !shouldShowBottomBar
+
+ val isOffline = networkMonitor.isOnline
+ .map(Boolean::not)
+ .stateIn(
+ scope = coroutineScope,
+ started = SharingStarted.WhileSubscribed(5_000),
+ initialValue = false,
+ )
+
+ /**
+ * Map of top level destinations to be used in the TopBar, BottomBar and NavRail. The key is the
+ * route.
+ */
+ val topLevelDestinations: List = TopLevelDestination.values().asList()
+
+ /**
+ * UI logic for navigating to a top level destination in the app. Top level destinations have
+ * only one copy of the destination of the back stack, and save and restore state whenever you
+ * navigate to and from it.
+ *
+ * @param topLevelDestination: The destination the app needs to navigate to.
+ */
+ fun navigateToTopLevelDestination(topLevelDestination: TopLevelDestination) {
+ trace("Navigation: ${topLevelDestination.name}") {
+ val topLevelNavOptions = navOptions {
+ // Pop up to the start destination of the graph to
+ // avoid building up a large stack of destinations
+ // on the back stack as users select items
+ popUpTo(navController.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
+ }
+
+ when (topLevelDestination) {
+ FIND_PLANT -> navController.navigateToFindPlant(topLevelNavOptions)
+ HISTORY -> navController.navigateToHistory(topLevelNavOptions)
+ SETTINGS -> navController.navigateToSettings(topLevelNavOptions)
+ }
+ }
+ }
+}
+
+/** Stores information about navigation events to be used with JankStats. */
+@Composable
+fun NavigationTrackingSideEffect(navController: NavHostController) {
+ TrackDisposableJank(navController) { metricsHolder ->
+ val listener =
+ NavController.OnDestinationChangedListener { _, destination, _ ->
+ metricsHolder.state?.putState("Navigation", destination.route.toString())
+ }
+
+ navController.addOnDestinationChangedListener(listener)
+
+ onDispose { navController.removeOnDestinationChangedListener(listener) }
+ }
+}
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/common/composables/BottomBarComposable.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/common/composables/BottomBarComposable.kt
deleted file mode 100644
index 68e9c435..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/common/composables/BottomBarComposable.kt
+++ /dev/null
@@ -1,53 +0,0 @@
-package com.github.andiim.plantscan.app.ui.common.composables
-
-import androidx.annotation.StringRes
-import androidx.compose.foundation.layout.RowScope
-import androidx.compose.material3.Icon
-import androidx.compose.material3.NavigationBar
-import androidx.compose.material3.NavigationBarItem
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.res.stringResource
-import androidx.navigation.NavDestination.Companion.hierarchy
-import androidx.navigation.compose.currentBackStackEntryAsState
-import com.github.andiim.plantscan.app.ui.navigation.Direction
-import com.github.andiim.plantscan.app.ui.navigation.NavigationObject
-import com.github.andiim.plantscan.app.PlantScanAppState
-
-@Composable
-fun BottomBar(state: PlantScanAppState) {
- val navBackStateEntry by state.navController.currentBackStackEntryAsState()
- val currentDestination = navBackStateEntry?.destination
- if (currentDestination?.hierarchy?.any { it.route == Direction.MainNav.route } == true)
- NavigationBar {
- for (navigationItem in NavigationObject.bottomNavItems) {
- Items(
- state = state,
- title = navigationItem.title,
- icon = navigationItem.icon,
- direction = navigationItem.direction
- )
- }
- }
-}
-
-@Composable
-private fun RowScope.Items(
- state: PlantScanAppState,
- @StringRes title: Int,
- icon: ImageVector,
- direction: Direction
-) {
- val navBackStackEntry by state.navController.currentBackStackEntryAsState()
- val currentDestination = navBackStackEntry?.destination
-
- NavigationBarItem(
- icon = { Icon(imageVector = icon, contentDescription = stringResource(title)) },
- selected = currentDestination?.hierarchy?.any { it.route == direction.route } == true,
- label = { Text(stringResource(title)) },
- onClick = { state.clearAndNavigate(direction.route) },
- alwaysShowLabel = false
- )
-}
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/common/composables/ButtonComposable.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/common/composables/ButtonComposable.kt
deleted file mode 100644
index 91f29d12..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/common/composables/ButtonComposable.kt
+++ /dev/null
@@ -1,87 +0,0 @@
-package com.github.andiim.plantscan.app.ui.common.composables
-
-import android.content.res.Configuration
-import androidx.annotation.StringRes
-import androidx.compose.foundation.layout.Column
-import androidx.compose.material3.Button
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.runtime.Composable
-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.sp
-import com.github.andiim.plantscan.app.ui.common.extensions.basicButton
-import com.github.andiim.plantscan.app.ui.theme.PlantScanTheme
-import com.github.andiim.plantscan.app.R.string as AppText
-
-@Composable
-fun BasicTextButton(@StringRes text: Int, modifier: Modifier, action: () -> Unit) {
- TextButton(onClick = action, modifier = modifier) { Text(text = stringResource(text)) }
-}
-
-@Composable
-fun BasicButton(@StringRes text: Int, modifier: Modifier, action: () -> Unit) {
- Button(
- onClick = action,
- modifier = modifier,
- colors =
- ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.onBackground,
- contentColor = MaterialTheme.colorScheme.onPrimary
- )
- ) {
- Text(text = stringResource(text), fontSize = 16.sp)
- }
-}
-
-@Composable
-fun DialogConfirmButton(@StringRes text: Int, action: () -> Unit) {
- Button(
- onClick = action,
- colors =
- ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.primaryContainer,
- contentColor = MaterialTheme.colorScheme.onPrimaryContainer
- )
- ) {
- Text(text = stringResource(text))
- }
-}
-
-@Composable
-fun DialogCancelButton(@StringRes text: Int, action: () -> Unit) {
- Button(
- onClick = action,
- colors =
- ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.primaryContainer,
- contentColor = MaterialTheme.colorScheme.onPrimaryContainer
- )
- ) {
- Text(text = stringResource(text))
- }
-}
-
-@Preview(name = "Night Mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
-@Preview(name = "Day Mode", uiMode = Configuration.UI_MODE_NIGHT_NO)
-@Composable
-private fun Preview() {
- PlantScanTheme {
- Surface {
- Column(horizontalAlignment = Alignment.CenterHorizontally) {
- BasicTextButton(
- text = AppText.app_name,
- modifier = Modifier.basicButton(),
- action = {})
- BasicButton(text = AppText.app_name, modifier = Modifier.basicButton(), action = {})
- DialogConfirmButton(text = AppText.app_name, action = {})
- DialogCancelButton(text = AppText.app_name, action = {})
- }
- }
- }
-}
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/common/composables/CardComposable.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/common/composables/CardComposable.kt
deleted file mode 100644
index c5188316..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/common/composables/CardComposable.kt
+++ /dev/null
@@ -1,139 +0,0 @@
-package com.github.andiim.plantscan.app.ui.common.composables
-
-import android.content.res.Configuration
-import androidx.annotation.DrawableRes
-import androidx.annotation.StringRes
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.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.material3.Text
-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.painterResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import com.github.andiim.plantscan.app.ui.common.extensions.dropdownSelector
-import com.github.andiim.plantscan.app.ui.theme.PlantScanTheme
-import com.github.andiim.plantscan.app.R.drawable as AppDrawable
-import com.github.andiim.plantscan.app.R.string as AppText
-
-@Composable
-fun DangerousCardEditor(
- @StringRes title: Int,
- @DrawableRes icon: Int,
- content: String,
- modifier: Modifier,
- onEditClick: () -> Unit
-) {
- CardEditor(title, icon, content, onEditClick, MaterialTheme.colorScheme.primary, modifier)
-}
-
-@Composable
-fun RegularCardEditor(
- @StringRes title: Int,
- @DrawableRes icon: Int,
- content: String,
- modifier: Modifier,
- onEditClick: () -> Unit
-) {
- CardEditor(title, icon, content, onEditClick, MaterialTheme.colorScheme.onSurface, modifier)
-}
-
-@Composable
-@OptIn(ExperimentalMaterial3Api::class)
-fun CardEditor(
- @StringRes title: Int,
- @DrawableRes icon: Int,
- content: String,
- onEditClick: () -> Unit,
- highlightColor: Color,
- modifier: Modifier
-) {
- Card(
- colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.onPrimary),
- modifier = modifier,
- onClick = onEditClick) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.fillMaxWidth().padding(16.dp)) {
- Column(modifier = Modifier.weight(1f)) {
- Text(stringResource(title), color = highlightColor)
- }
-
- if (content.isNotBlank()) {
- Text(text = content, modifier = Modifier.padding(16.dp, 0.dp))
- }
-
- Icon(
- painter = painterResource(icon),
- contentDescription = "Icon",
- tint = highlightColor)
- }
- }
-}
-
-@Composable
-fun CardSelector(
- @StringRes label: Int,
- options: List,
- selection: String,
- modifier: Modifier,
- onNewValue: (String) -> Unit
-) {
- Card(
- colors =
- CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.onPrimaryContainer,
- ),
- modifier = modifier) {
- DropdownSelector(
- label = label,
- options = options,
- selection = selection,
- modifier = Modifier.dropdownSelector(),
- onNewValue = onNewValue)
- }
-}
-
-@Preview(name = "Night Mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
-@Preview(name = "Day Mode", uiMode = Configuration.UI_MODE_NIGHT_NO)
-@Composable
-private fun Preview() {
- PlantScanTheme {
- Surface {
- Column(modifier = Modifier.padding(16.dp)) {
- DangerousCardEditor(
- title = AppText.app_name,
- icon = AppDrawable.ic_visibility_on,
- content = "something",
- modifier = Modifier,
- onEditClick = {})
- Spacer(Modifier.padding(8.dp))
- RegularCardEditor(
- title = AppText.app_name,
- icon = AppDrawable.ic_visibility_on,
- content = "something",
- modifier = Modifier,
- onEditClick = {})
- Spacer(Modifier.padding(8.dp))
- CardSelector(
- label = AppText.app_name,
- options = listOf("Hello", "World!"),
- selection = "select this?",
- modifier = Modifier,
- onNewValue = {})
- }
- }
- }
-}
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/common/composables/DropdownComposable.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/common/composables/DropdownComposable.kt
deleted file mode 100644
index 5bb42574..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/common/composables/DropdownComposable.kt
+++ /dev/null
@@ -1,135 +0,0 @@
-package com.github.andiim.plantscan.app.ui.common.composables
-
-import android.content.res.Configuration
-import androidx.annotation.StringRes
-import androidx.compose.foundation.layout.Column
-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.material.icons.Icons
-import androidx.compose.material.icons.filled.MoreVert
-import androidx.compose.material3.DropdownMenuItem
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.ExposedDropdownMenuBox
-import androidx.compose.material3.ExposedDropdownMenuDefaults
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextField
-import androidx.compose.material3.TextFieldColors
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.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.app.ui.common.extensions.dropdownSelector
-import com.github.andiim.plantscan.app.ui.theme.PlantScanTheme
-import com.github.andiim.plantscan.app.R.string as AppText
-
-@Composable
-@OptIn(ExperimentalMaterial3Api::class)
-fun DropdownContextMenu(
- options: List,
- modifier: Modifier,
- onActionClick: (String) -> Unit
-) {
- var isExpanded by remember { mutableStateOf(false) }
-
- ExposedDropdownMenuBox(
- expanded = isExpanded, modifier = modifier, onExpandedChange = { isExpanded = !isExpanded }) {
- Icon(
- modifier = Modifier.padding(8.dp, 0.dp),
- imageVector = Icons.Default.MoreVert,
- contentDescription = "More")
-
- ExposedDropdownMenu(
- modifier = Modifier.width(180.dp),
- expanded = isExpanded,
- onDismissRequest = { isExpanded = false }) {
- options.forEach { selectionOption ->
- DropdownMenuItem(
- text = { Text(text = selectionOption) },
- onClick = {
- isExpanded = false
- onActionClick(selectionOption)
- })
- }
- }
- }
-}
-
-@Composable
-@OptIn(ExperimentalMaterial3Api::class)
-fun DropdownSelector(
- @StringRes label: Int,
- options: List,
- selection: String,
- modifier: Modifier,
- onNewValue: (String) -> Unit
-) {
- var isExpanded by remember { mutableStateOf(false) }
-
- ExposedDropdownMenuBox(
- expanded = isExpanded, modifier = modifier, onExpandedChange = { isExpanded = !isExpanded }) {
- TextField(
- modifier = Modifier.fillMaxWidth(),
- readOnly = true,
- value = selection,
- label = { Text(stringResource(label)) },
- onValueChange = {},
- trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = isExpanded) },
- colors = dropdownColors()
- )
-
- ExposedDropdownMenu(expanded = isExpanded, onDismissRequest = { isExpanded = false }) {
- options.forEach { selectionOption ->
- DropdownMenuItem(
- text = { Text(text = selectionOption) },
- onClick = {
- onNewValue(selectionOption)
- isExpanded = false
- })
- }
- }
- }
-}
-
-@Composable
-@OptIn(ExperimentalMaterial3Api::class)
-private fun dropdownColors(): TextFieldColors {
- return ExposedDropdownMenuDefaults.textFieldColors(
- focusedContainerColor = MaterialTheme.colorScheme.onPrimaryContainer,
- focusedIndicatorColor = Color.Transparent,
- unfocusedIndicatorColor = Color.Transparent,
- focusedTrailingIconColor = MaterialTheme.colorScheme.onSurface,
- focusedLabelColor = MaterialTheme.colorScheme.primary,
- unfocusedLabelColor = MaterialTheme.colorScheme.primary)
-}
-
-@Preview(name = "Night Mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
-@Preview(name = "Day Mode", uiMode = Configuration.UI_MODE_NIGHT_NO)
-@Composable
-private fun Preview() {
- PlantScanTheme {
- Surface {
- Column(Modifier.padding(16.dp)) {
- DropdownContextMenu(
- options = listOf("Hello", "World!"), modifier = Modifier, onActionClick = {})
- Spacer(Modifier.padding(8.dp))
- DropdownSelector(
- label = AppText.app_name,
- options = listOf("Hello", "World!"),
- selection = "select this?",
- modifier = Modifier.dropdownSelector(),
- onNewValue = {})
- }
- }
- }
-}
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/common/composables/PermissionDialogComposable.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/common/composables/PermissionDialogComposable.kt
deleted file mode 100644
index 36e1d0b0..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/common/composables/PermissionDialogComposable.kt
+++ /dev/null
@@ -1,132 +0,0 @@
-package com.github.andiim.plantscan.app.ui.common.composables
-
-import android.content.res.Configuration
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.wrapContentHeight
-import androidx.compose.foundation.layout.wrapContentWidth
-import androidx.compose.material3.AlertDialog
-import androidx.compose.material3.AlertDialogDefaults
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-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.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import com.github.andiim.plantscan.app.ui.common.extensions.alertDialog
-import com.github.andiim.plantscan.app.ui.common.extensions.textButton
-import com.github.andiim.plantscan.app.ui.theme.PlantScanTheme
-import com.github.andiim.plantscan.app.R.string as AppText
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun PermissionDialog(onRequestPermission: () -> Unit) {
- var showWarningDialog by remember { mutableStateOf(true) }
-
- if (showWarningDialog) {
- AlertDialog(
- modifier = Modifier.alertDialog(), onDismissRequest = { showWarningDialog = false }) {
- Surface(
- modifier = Modifier
- .wrapContentWidth()
- .wrapContentHeight(),
- shape = MaterialTheme.shapes.large,
- tonalElevation = AlertDialogDefaults.TonalElevation
- ) {
- Column(modifier = Modifier.padding(24.dp)) {
- Text(
- stringResource(AppText.notification_permission_title),
- color = (MaterialTheme.colorScheme).onSurface,
- style = (MaterialTheme.typography).headlineSmall
- )
- Spacer(modifier = Modifier.height(16.dp))
- Text(
- stringResource(AppText.notification_permission_description),
- style = (MaterialTheme.typography).bodyMedium,
- color = (MaterialTheme.colorScheme).onSurfaceVariant,
- )
- Spacer(modifier = Modifier.height(24.dp))
- TextButton(
- onClick = {
- onRequestPermission()
- showWarningDialog = false
- },
- modifier = Modifier.align(Alignment.End)
- ) {
- Text(
- stringResource(AppText.request_notification_permission),
- style = (MaterialTheme.typography).labelLarge
- )
- }
- }
- }
- }
- }
-}
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun RationaleDialog() {
- var showWarningDialog by remember { mutableStateOf(true) }
-
- if (showWarningDialog) {
- AlertDialog(
- modifier = Modifier.alertDialog(),
- onDismissRequest = { showWarningDialog = false }
- ) {
- Surface(
- modifier = Modifier
- .wrapContentWidth()
- .wrapContentHeight(),
- shape = MaterialTheme.shapes.large,
- tonalElevation = AlertDialogDefaults.TonalElevation
- ) {
- Column(modifier = Modifier.padding(16.dp)) {
- Text(
- stringResource(AppText.notification_permission_title),
- color = (MaterialTheme.colorScheme).onSurface,
- style = (MaterialTheme.typography).headlineSmall
- )
- Spacer(modifier = Modifier.height(16.dp))
- Text(
- stringResource(AppText.notification_permission_description),
- color = (MaterialTheme.colorScheme).onSurfaceVariant,
- style = (MaterialTheme.typography).bodyMedium
- )
- Spacer(modifier = Modifier.height(24.dp))
- TextButton(
- onClick = { showWarningDialog = false },
- modifier = Modifier.textButton()
- ) {
- Text(stringResource(AppText.ok))
- }
- }
- }
- }
- }
-}
-
-@Preview(name = "Night Mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
-@Preview(name = "Day Mode", uiMode = Configuration.UI_MODE_NIGHT_NO)
-@Composable
-private fun RationalDialogPreview() {
- PlantScanTheme { Surface { RationaleDialog() } }
-}
-
-@Preview(name = "Night Mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
-@Preview(name = "Day Mode", uiMode = Configuration.UI_MODE_NIGHT_NO)
-@Composable
-private fun PermissionDialogPreview() {
- PlantScanTheme { Surface { PermissionDialog {} } }
-}
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/common/composables/PlantItem.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/common/composables/PlantItem.kt
deleted file mode 100644
index a3f3def5..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/common/composables/PlantItem.kt
+++ /dev/null
@@ -1,98 +0,0 @@
-package com.github.andiim.plantscan.app.ui.common.composables
-
-import android.content.res.Configuration
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.ArrowForwardIos
-import androidx.compose.material3.Card
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import com.github.andiim.plantscan.app.data.model.Image
-import com.github.andiim.plantscan.app.data.model.Plant
-import com.github.andiim.plantscan.app.ui.theme.PlantScanTheme
-
-@Composable
-fun PlantItem(plant: Plant, onClick: (Plant) -> Unit = {}) {
- Card(modifier = Modifier.fillMaxWidth().height(96.dp).clickable { onClick.invoke(plant) }) {
- Row(
- modifier = Modifier.fillMaxWidth().padding(16.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceAround) {
-// AsyncImage(
-// model =
-// ImageRequest.Builder(LocalContext.current)
-// .data(plant.images[0].file)
-// .crossfade(true)
-// .build(),
-// placeholder = painterResource(ImageDrawable.ic_error),
-// contentDescription = plant.name,
-// contentScale = ContentScale.Crop,
-// modifier = Modifier.clip(CircleShape).size(64.dp).align(Alignment.CenterVertically))
- Spacer(modifier = Modifier.size(8.dp))
- PlantContent(modifier = Modifier.weight(2f), plant = plant)
- Spacer(modifier = Modifier.size(8.dp))
- Icon(Icons.Default.ArrowForwardIos, contentDescription = "click")
- }
- }
-}
-
-@Composable
-private fun PlantContent(modifier: Modifier = Modifier, plant: Plant) {
- Column(modifier = modifier) {
- Text(text = plant.name, style = (MaterialTheme.typography).titleSmall)
- KnownNames(names = plant.commonName)
- }
-}
-
-@Composable
-private fun KnownNames(names: List) {
- val nameString = names.joinToString(", ")
- Text(
- text = nameString,
- maxLines = 2,
- overflow = TextOverflow.Clip,
- style = (MaterialTheme.typography).bodyMedium)
-}
-
-@Preview(
- name = "Night Mode",
- uiMode = Configuration.UI_MODE_NIGHT_YES,
-)
-@Preview(
- name = "Day Mode",
- uiMode = Configuration.UI_MODE_NIGHT_NO,
-)
-@Composable
-private fun Preview() {
- val plant =
- Plant(
- id = "1",
- name = "Bird of Paradise",
- species = "Strelizia reginae",
- type = "bird",
- images =
- listOf(
- Image(
- attribution = "Creative Common",
- name = "Orchid",
- file =
- "https://upload.wikimedia.org/wikipedia/commons/3/30/Orchid_Phalaenopsis_hybrid.jpg")),
- commonName = listOf("Strelizia reginae", "Crane flower", "Bird of Paradise"),
- )
- PlantScanTheme { PlantItem(plant) }
-}
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/common/composables/PlantPagedList.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/common/composables/PlantPagedList.kt
deleted file mode 100644
index ebe248b5..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/common/composables/PlantPagedList.kt
+++ /dev/null
@@ -1,123 +0,0 @@
-package com.github.andiim.plantscan.app.ui.common.composables
-
-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.padding
-import androidx.compose.foundation.layout.wrapContentSize
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.rememberLazyListState
-import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.Divider
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedButton
-import androidx.compose.material3.Text
-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.unit.dp
-import androidx.paging.LoadState
-import androidx.paging.compose.LazyPagingItems
-import androidx.paging.compose.itemContentType
-import androidx.paging.compose.itemKey
-import com.github.andiim.plantscan.app.data.model.Plant
-
-@Composable
-fun PlantPagedList(plants: LazyPagingItems, onItemClick: (Plant) -> Unit) {
- LazyColumn(state = rememberLazyListState()) {
- items(
- count = plants.itemCount,
- key = plants.itemKey { it.id },
- contentType = plants.itemContentType { "Orchids" }) { index ->
- val orchid = plants[index]
- orchid?.let {
- PlantItem(plant = it, onItemClick)
- Divider()
- }
- Divider()
- }
-
- plants.apply {
- when (loadState.prepend) {
- is LoadState.NotLoading -> {
- if (itemCount == 0) {
- item(key = "not_loading_and_empty") {
- EmptyItem(modifier = Modifier.fillParentMaxSize())
- }
- }
- }
-
- is LoadState.Loading -> {
- item { LoadingItem(modifier = Modifier.fillParentMaxSize()) }
- }
-
- is LoadState.Error -> {
- val e = loadState.refresh as LoadState.Error
- item {
- ErrorItem(
- message = e.error.localizedMessage!!,
- modifier = Modifier.fillParentMaxSize(),
- onClickRetry = { retry() })
- }
- }
- }
- }
- }
-}
-
-@Composable
-private fun LoadingItem(modifier: Modifier) {
- Column(
- modifier = modifier,
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.Center
- ) {
- Text(
- modifier = Modifier.padding(8.dp),
- text = "Refresh Loading"
- )
- CircularProgressIndicator(color = Color.Black)
- }
-}
-
-@Composable
-private fun ErrorItem(
- message: String,
- modifier: Modifier = Modifier,
- onClickRetry: () -> Unit
-) {
- Row(
- modifier = modifier.padding(16.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- text = message,
- maxLines = 1,
- modifier = Modifier.weight(1f),
- style = MaterialTheme.typography.headlineSmall,
- color = Color.Red
- )
- OutlinedButton(onClick = onClickRetry) {
- Text(text = "Try again")
- }
- }
-}
-
-@Composable
-private fun EmptyItem(modifier: Modifier) {
- Box(
- modifier = modifier,
- contentAlignment = Alignment.Center
- ) {
- Column(
- modifier = Modifier.wrapContentSize(),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- // TODO : Adding an empty animation!
- Text(text = "Empty List")
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/common/composables/TextFieldComposable.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/common/composables/TextFieldComposable.kt
deleted file mode 100644
index 24fe2d11..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/common/composables/TextFieldComposable.kt
+++ /dev/null
@@ -1,143 +0,0 @@
-/*
-Copyright 2022 Google LLC
-
-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.app.ui.common.composables
-
-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.Surface
-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.painterResource
-import androidx.compose.ui.res.stringResource
-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.app.ui.theme.PlantScanTheme
-import com.github.andiim.plantscan.app.R.drawable as AppIcon
-import com.github.andiim.plantscan.app.R.string as AppText
-
-@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(AppText.email)) },
- leadingIcon = { Icon(imageVector = Icons.Default.Email, contentDescription = "Email") })
-}
-
-@Composable
-fun PasswordField(value: String, onNewValue: (String) -> Unit, modifier: Modifier = Modifier) {
- PasswordField(value, AppText.password, onNewValue, modifier)
-}
-
-@Composable
-fun RepeatPasswordField(
- value: String,
- onNewValue: (String) -> Unit,
- modifier: Modifier = Modifier
-) {
- PasswordField(value, AppText.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 icon =
- if (isVisible) painterResource(AppIcon.ic_visibility_on)
- else painterResource(AppIcon.ic_visibility_off)
-
- val visualTransformation =
- if (isVisible) VisualTransformation.None else PasswordVisualTransformation()
-
- OutlinedTextField(
- 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(painter = icon, contentDescription = "Visibility")
- }
- },
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
- 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() {
- PlantScanTheme {
- Surface {
- Column(
- verticalArrangement = Arrangement.SpaceEvenly,
- horizontalAlignment = Alignment.CenterHorizontally,
- modifier = Modifier.padding(16.dp)) {
- BasicField(text = AppText.app_name, 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/app/src/main/java/com/github/andiim/plantscan/app/ui/common/composables/ToolbarComposable.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/common/composables/ToolbarComposable.kt
deleted file mode 100644
index fb2385b5..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/common/composables/ToolbarComposable.kt
+++ /dev/null
@@ -1,104 +0,0 @@
-/*
-Copyright 2022 Google LLC
-
-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.app.ui.common.composables
-
-import android.content.res.Configuration
-import androidx.annotation.DrawableRes
-import androidx.annotation.StringRes
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.RowScope
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBar
-import androidx.compose.material3.TopAppBarColors
-import androidx.compose.material3.TopAppBarDefaults
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.tooling.preview.Preview
-import com.github.andiim.plantscan.app.ui.theme.PlantScanTheme
-import com.github.andiim.plantscan.app.R.drawable as AppIcon
-import com.github.andiim.plantscan.app.R.string as AppText
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun BasicToolbar(
- @StringRes title: Int,
- leading: @Composable () -> Unit = {},
- trailing: @Composable (RowScope.() -> Unit) = {}
-) {
- TopAppBar(
- navigationIcon = leading,
- title = { Text(stringResource(title)) },
- colors = TopAppBarDefaults.orchidScanTopAppBarColor(),
- actions = trailing
- )
-}
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun ActionToolbar(
- @StringRes title: Int,
- @DrawableRes endActionIcon: Int,
- modifier: Modifier,
- endAction: () -> Unit
-) {
- TopAppBar(
- title = { Text(stringResource(title)) },
- colors = TopAppBarDefaults.orchidScanTopAppBarColor(),
- actions = {
- Box(modifier) {
- IconButton(onClick = endAction) {
- Icon(painter = painterResource(endActionIcon), contentDescription = "Action")
- }
- }
- })
-}
-
-@Composable
-@OptIn(ExperimentalMaterial3Api::class)
-private fun TopAppBarDefaults.orchidScanTopAppBarColor(): TopAppBarColors {
- return topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
-}
-
-@Preview(name = "Night Mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
-@Preview(name = "Day Mode", uiMode = Configuration.UI_MODE_NIGHT_NO)
-@Composable
-private fun BasicToolbarPreview() {
- PlantScanTheme { Surface { BasicToolbar(title = AppText.app_name) } }
-}
-
-@Preview(name = "Night Mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
-@Preview(name = "Day Mode", uiMode = Configuration.UI_MODE_NIGHT_NO)
-@Composable
-private fun ActionToolbarPreview() {
- PlantScanTheme {
- Surface {
- ActionToolbar(
- title = AppText.app_name,
- endActionIcon = AppIcon.ic_visibility_on,
- endAction = {},
- modifier = Modifier
- )
- }
- }
-}
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/common/extensions/ModifierExtension.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/common/extensions/ModifierExtension.kt
deleted file mode 100644
index dab2497f..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/common/extensions/ModifierExtension.kt
+++ /dev/null
@@ -1,52 +0,0 @@
-package com.github.andiim.plantscan.app.ui.common.extensions
-
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.widthIn
-import androidx.compose.foundation.layout.wrapContentHeight
-import androidx.compose.foundation.layout.wrapContentSize
-import androidx.compose.foundation.layout.wrapContentWidth
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.unit.dp
-
-fun Modifier.textButton(): Modifier {
- return this.fillMaxWidth().padding(16.dp, 8.dp, 16.dp, 0.dp)
-}
-
-fun Modifier.basicButton(): Modifier {
- return this.fillMaxWidth().padding(16.dp, 8.dp)
-}
-
-fun Modifier.card(): Modifier {
- return this.padding(16.dp, 0.dp, 16.dp, 8.dp)
-}
-
-fun Modifier.contextMenu(): Modifier {
- return this.wrapContentWidth()
-}
-
-fun Modifier.alertDialog(): Modifier {
- return this.widthIn(280.dp, 560.dp).wrapContentHeight()
-}
-
-fun Modifier.dropdownSelector(): Modifier {
- return this.fillMaxWidth()
-}
-
-fun Modifier.fieldModifier(): Modifier {
- return this.fillMaxWidth().padding(16.dp, 4.dp)
-}
-
-fun Modifier.toolbarActions(): Modifier {
- return this.wrapContentSize(Alignment.TopEnd)
-}
-
-fun Modifier.spacer(): Modifier {
- return this.fillMaxWidth().padding(12.dp)
-}
-
-fun Modifier.smallSpacer(): Modifier {
- return this.fillMaxWidth().height(8.dp)
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/common/snackbar/SnackbarManager.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/common/snackbar/SnackbarManager.kt
deleted file mode 100644
index f85f8a72..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/common/snackbar/SnackbarManager.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-package com.github.andiim.plantscan.app.ui.common.snackbar
-
-import androidx.annotation.StringRes
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
-
-object SnackbarManager {
- private val messages: MutableStateFlow = MutableStateFlow(null)
- val snackbarMessages: StateFlow
- get() = messages.asStateFlow()
-
- fun showMessage(@StringRes message: Int) {
- messages.value = SnackbarMessage.ResourceSnackbar(message)
- }
-
- fun showMessage(message: SnackbarMessage) {
- messages.value = message
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/common/snackbar/SnackbarMessage.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/common/snackbar/SnackbarMessage.kt
deleted file mode 100644
index 5be4c107..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/common/snackbar/SnackbarMessage.kt
+++ /dev/null
@@ -1,25 +0,0 @@
-package com.github.andiim.plantscan.app.ui.common.snackbar
-
-import android.content.res.Resources
-import androidx.annotation.StringRes
-import com.github.andiim.plantscan.app.R
-
-sealed class SnackbarMessage {
- class StringSnackbar(val message: String) : SnackbarMessage()
- class ResourceSnackbar(@StringRes val message: Int) : SnackbarMessage()
-
- companion object {
- fun SnackbarMessage.toMessage(resources: Resources): String {
- return when (this) {
- is StringSnackbar -> this.message
- is ResourceSnackbar -> resources.getString(this.message)
- }
- }
-
- fun Throwable.toSnackbarMessage(): SnackbarMessage {
- val message = this.message.orEmpty()
- return if (message.isNotBlank()) StringSnackbar(message)
- else ResourceSnackbar(R.string.generic_error)
- }
- }
-}
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/navigation/Direction.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/navigation/Direction.kt
deleted file mode 100644
index b0ebd04f..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/navigation/Direction.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-package com.github.andiim.plantscan.app.ui.navigation
-
-import com.github.andiim.plantscan.app.data.model.Plant
-
-sealed class Direction(val route: String) {
- object MainNav : Direction("main_navigation")
- object AccountNav : Direction("account_navigation")
- object Splash : Direction("splash")
- object FindPlant : Direction("find")
- object Detect : Direction("orchid_detect")
- object MyGarden : Direction("my_garden")
- object Settings : Direction("settings")
- object Login : Direction("login")
- object SignUp : Direction("signup")
- object List : Direction("orchid")
- object Web : Direction("web_screen/{url}") {
- fun setUrl(url: String) = "web_screen/$url"
- }
-
- object Detail : Direction("orchid/{orchid_id}") {
- fun createRoute(plant: Plant) = "orchid/${plant.id}"
- }
-}
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/navigation/Navigation.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/navigation/Navigation.kt
deleted file mode 100644
index 55e80d0c..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/navigation/Navigation.kt
+++ /dev/null
@@ -1,166 +0,0 @@
-package com.github.andiim.plantscan.app.ui.navigation
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.remember
-import androidx.compose.ui.Modifier
-import androidx.hilt.navigation.compose.hiltViewModel
-import androidx.navigation.NavGraphBuilder
-import androidx.navigation.NavType
-import androidx.navigation.compose.NavHost
-import androidx.navigation.compose.composable
-import androidx.navigation.navArgument
-import androidx.navigation.navigation
-import com.github.andiim.plantscan.app.PlantScanAppState
-import com.github.andiim.plantscan.app.ui.screens.auth.login.LoginScreen
-import com.github.andiim.plantscan.app.ui.screens.auth.login.LoginViewModel
-import com.github.andiim.plantscan.app.ui.screens.auth.signUp.SignUpScreen
-import com.github.andiim.plantscan.app.ui.screens.auth.signUp.SignUpViewModel
-import com.github.andiim.plantscan.app.ui.screens.detail.DetailScreen
-import com.github.andiim.plantscan.app.ui.screens.detail.DetailViewModel
-import com.github.andiim.plantscan.app.ui.screens.home.findPlant.FindPlantElement
-import com.github.andiim.plantscan.app.ui.screens.home.findPlant.FindPlantViewModel
-import com.github.andiim.plantscan.app.ui.screens.home.myGarden.MyGardenElement
-import com.github.andiim.plantscan.app.ui.screens.home.myGarden.MyGardenViewModel
-import com.github.andiim.plantscan.app.ui.screens.home.settings.SettingsElement
-import com.github.andiim.plantscan.app.ui.screens.home.settings.SettingsViewModel
-import com.github.andiim.plantscan.app.ui.screens.list.PlantListScreen
-import com.github.andiim.plantscan.app.ui.screens.list.PlantListViewModel
-import com.github.andiim.plantscan.app.ui.screens.splash.SplashScreen
-import com.github.andiim.plantscan.app.ui.screens.web.WebScreen
-
-@Composable
-fun SetupRootNavGraph(appState: PlantScanAppState, modifier: Modifier = Modifier) {
- NavHost(
- modifier = modifier,
- navController = appState.navController,
- startDestination = Direction.Splash.route,
- ) {
- navigation(startDestination = Direction.Login.route, route = Direction.AccountNav.route) {
- authLoginScreen(appState)
- authSignUpScreen(appState)
- webViewScreen(appState)
- }
-
- detailScreen(appState)
-
- navigation(
- startDestination = Direction.FindPlant.route,
- route = Direction.MainNav.route
- ) {
- homeFindPlantElement(appState)
- homeMyGardenElement(appState)
- homeSettingsElement(appState)
- }
-
- listScreen(appState)
- splashScreen(appState)
- }
-}
-
-private fun NavGraphBuilder.authLoginScreen(appState: PlantScanAppState) {
- composable(route = Direction.Login.route) { backStackEntry ->
- val parentEntry =
- remember(backStackEntry) {
- appState.navController.getBackStackEntry(Direction.AccountNav.route)
- }
- val viewModel: LoginViewModel = hiltViewModel(parentEntry)
- LoginScreen(
- openAndPopUp = appState::navigateAndPopUp,
- openWeb = { url ->
- appState.navigate(Direction.Web.setUrl(url), singleTopLaunch = false)
- },
- viewModel = viewModel
- )
- }
-}
-
-private fun NavGraphBuilder.authSignUpScreen(appState: PlantScanAppState) {
- composable(route = Direction.SignUp.route) { backStackEntry ->
- val parentEntry =
- remember(backStackEntry) {
- appState.navController.getBackStackEntry(Direction.AccountNav.route)
- }
- val viewModel: SignUpViewModel = hiltViewModel(parentEntry)
- SignUpScreen(openAndPopUp = appState::navigateAndPopUp, viewModel = viewModel)
- }
-}
-
-private fun NavGraphBuilder.detailScreen(appState: PlantScanAppState) {
- composable(
- route = Direction.Detail.route,
- arguments = listOf(navArgument("orchid_id") { type = NavType.StringType })
- ) { backStackEntry ->
- val viewModel: DetailViewModel = hiltViewModel()
- val id = backStackEntry.arguments?.getString("orchid_id")
- DetailScreen(id = id, popUpScreen = appState::popUp, viewModel = viewModel)
- }
-}
-
-private fun NavGraphBuilder.homeMyGardenElement(appState: PlantScanAppState) {
- composable(route = Direction.MyGarden.route) {
- val viewModel: MyGardenViewModel = hiltViewModel()
- MyGardenElement(
- toDetail = { appState.navigate(Direction.Detect.route) },
- viewModel = viewModel
- )
- }
-}
-
-private fun NavGraphBuilder.homeFindPlantElement(appState: PlantScanAppState) {
- composable(route = Direction.FindPlant.route) {
- val viewModel: FindPlantViewModel = hiltViewModel()
- FindPlantElement(
- onDetails = { appState.navigate(Direction.Detail.createRoute(it)) },
- viewModel = viewModel,
- toDetect = { appState.navigate(Direction.Detect.route) },
- toPlantType = {})
- }
-}
-
-private fun NavGraphBuilder.homeSettingsElement(appState: PlantScanAppState) {
- composable(route = Direction.Settings.route) { backStackEntry ->
- val parentEntry =
- remember(backStackEntry) {
- appState.navController.getBackStackEntry(Direction.MainNav.route)
- }
- val viewModel: SettingsViewModel = hiltViewModel(parentEntry)
- SettingsElement(
- restartApp = appState::clearAndNavigate,
- openScreen = appState::navigate,
- viewModel = viewModel
- )
- }
-}
-
-private fun NavGraphBuilder.listScreen(appState: PlantScanAppState) {
- composable(route = Direction.List.route) { backStackEntry ->
- val parentEntry =
- remember(backStackEntry) {
- appState.navController.getBackStackEntry(Direction.MainNav.route)
- }
- val viewModel: PlantListViewModel = hiltViewModel(parentEntry)
- PlantListScreen(
- toDetails = { appState.navigate(Direction.Detail.createRoute(it)) },
- popUpScreen = appState::popUp,
- viewModel = viewModel
- )
- }
-}
-
-private fun NavGraphBuilder.splashScreen(appState: PlantScanAppState) {
- composable(route = Direction.Splash.route) {
- SplashScreen(openAndPopUp = appState::navigateAndPopUp)
- }
-}
-
-private fun NavGraphBuilder.webViewScreen(appState: PlantScanAppState) {
- composable(
- route = Direction.Web.route,
- arguments = listOf(navArgument("url") { type = NavType.StringType })
- ) { backStackEntry ->
- val url = backStackEntry.arguments?.getString("url")
- url?.let { WebScreen(url = it, name = "Testing", popUpScreen = appState::popUp) }
- }
-}
-
-
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/navigation/NavigationItem.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/navigation/NavigationItem.kt
deleted file mode 100644
index 67a4b078..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/navigation/NavigationItem.kt
+++ /dev/null
@@ -1,6 +0,0 @@
-package com.github.andiim.plantscan.app.ui.navigation
-
-import androidx.annotation.StringRes
-import androidx.compose.ui.graphics.vector.ImageVector
-
-data class NavigationItem(@StringRes val title: Int, val icon: ImageVector, val direction: Direction)
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/navigation/NavigationObject.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/navigation/NavigationObject.kt
deleted file mode 100644
index e95e6756..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/navigation/NavigationObject.kt
+++ /dev/null
@@ -1,28 +0,0 @@
-package com.github.andiim.plantscan.app.ui.navigation
-
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.List
-import androidx.compose.material.icons.filled.Search
-import androidx.compose.material.icons.outlined.LocalFlorist
-import com.github.andiim.plantscan.app.R.string as AppText
-
-object NavigationObject {
- val bottomNavItems =
- listOf(
- NavigationItem(
- title = AppText.label_search,
- icon = Icons.Filled.Search,
- direction = Direction.FindPlant
- ),
- NavigationItem(
- title = AppText.label_garden_screen,
- icon = Icons.Outlined.LocalFlorist,
- direction = Direction.MyGarden
- ),
- NavigationItem(
- title = AppText.label_settings,
- icon = Icons.Filled.List,
- direction = Direction.Settings
- ),
- )
-}
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/auth/login/LoginScreen.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/auth/login/LoginScreen.kt
deleted file mode 100644
index 8f4056da..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/auth/login/LoginScreen.kt
+++ /dev/null
@@ -1,153 +0,0 @@
-package com.github.andiim.plantscan.app.ui.screens.auth.login
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxHeight
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.text.ClickableText
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
-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.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.app.R
-import com.github.andiim.plantscan.app.ui.common.composables.BasicButton
-import com.github.andiim.plantscan.app.ui.common.composables.BasicTextButton
-import com.github.andiim.plantscan.app.ui.common.composables.EmailField
-import com.github.andiim.plantscan.app.ui.common.composables.PasswordField
-import com.github.andiim.plantscan.app.ui.common.extensions.basicButton
-import com.github.andiim.plantscan.app.ui.common.extensions.fieldModifier
-import com.github.andiim.plantscan.app.ui.common.extensions.textButton
-import com.github.andiim.plantscan.app.ui.theme.PlantScanTheme
-
-@Composable
-fun LoginScreen(
- openWeb: (String) -> Unit,
- openAndPopUp: (String, String) -> Unit,
- viewModel: LoginViewModel = hiltViewModel()
-) {
- val loginState by viewModel.state.collectAsState()
-
- LoginContent(
- email = loginState.email,
- password = loginState.password,
- openWeb = openWeb,
- openAndPopUp = openAndPopUp,
- onEmailChange = viewModel::onEmailChange,
- onPasswordChange = viewModel::onPasswordChange,
- onSignInClick = viewModel::onSignInClick,
- onForgotPasswordClick = viewModel::onForgotPasswordClick
- )
-}
-
-@Composable
-fun LoginContent(
- email: String = "",
- password: String = "",
- openWeb: (String) -> Unit = {},
- openAndPopUp: (String, String) -> Unit,
- onEmailChange: (String) -> Unit = {},
- onPasswordChange: (String) -> Unit = {},
- onForgotPasswordClick: () -> Unit = {},
- onSignInClick: ((String, String) -> Unit) -> Unit = {}
-) {
- Box(modifier = Modifier.padding(24.dp)) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .fillMaxHeight()
- .verticalScroll(rememberScrollState()),
- verticalArrangement = Arrangement.Center,
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- EmailField(email, onEmailChange, Modifier.fieldModifier())
- PasswordField(password, onPasswordChange, Modifier.fieldModifier())
-
- BasicButton(
- R.string.label_sign_in,
- Modifier.basicButton(),
- action = { onSignInClick(openAndPopUp) })
-
- BasicTextButton(
- R.string.hint_forgot_password,
- Modifier.textButton(),
- action = onForgotPasswordClick
- )
- }
- TermsAndPrivacyStatementText(
- openWeb = openWeb,
- modifier = Modifier.align(Alignment.BottomCenter)
- )
- }
-}
-
-@Composable
-private fun TermsAndPrivacyStatementText(
- modifier: Modifier = Modifier,
- openWeb: (String) -> Unit,
-) {
- val annotatedText = buildAnnotatedString {
- withStyle(style = SpanStyle()) { append("By continuing, you agree to our") }
- pushStringAnnotation(tag = "URL_A", annotation = "android.com")
- withStyle(
- style =
- SpanStyle(color = (MaterialTheme.colorScheme).primary, fontWeight = FontWeight.Bold)
- ) {
- append(" Terms ")
- }
- append(" and ")
- pushStringAnnotation(tag = "URL_B", annotation = "developer.android.com")
- withStyle(
- style =
- SpanStyle(color = (MaterialTheme.colorScheme).primary, fontWeight = FontWeight.Bold)
- ) {
- append(" Privacy Policy ")
- }
- pop()
- }
-
- ClickableText(
- text = annotatedText,
- modifier = modifier,
- style = TextStyle.Default.copy(textAlign = TextAlign.Center, fontSize = 13.sp)
- ) { offset ->
- val termsString =
- annotatedText
- .getStringAnnotations(tag = "URL_A", start = offset, end = offset)
- .firstOrNull()
- val privacyString =
- annotatedText
- .getStringAnnotations(tag = "URL_B", start = offset, end = offset)
- .firstOrNull()
-
- if (termsString != null && privacyString == null) {
- openWeb(termsString.item)
- } else privacyString?.let { openWeb(it.item) }
- }
-}
-
-@Preview
-@Composable
-private fun Preview_LoginContent() {
- PlantScanTheme {
- Surface {
- LoginContent(openAndPopUp = { _, _ -> })
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/auth/login/LoginState.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/auth/login/LoginState.kt
deleted file mode 100644
index 2323f20d..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/auth/login/LoginState.kt
+++ /dev/null
@@ -1,3 +0,0 @@
-package com.github.andiim.plantscan.app.ui.screens.auth.login
-
-data class LoginState(val email: String = "", val password: String = "")
\ No newline at end of file
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/auth/login/LoginViewModel.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/auth/login/LoginViewModel.kt
deleted file mode 100644
index 38e43734..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/auth/login/LoginViewModel.kt
+++ /dev/null
@@ -1,58 +0,0 @@
-package com.github.andiim.plantscan.app.ui.screens.auth.login
-
-import com.github.andiim.plantscan.app.R
-import com.github.andiim.plantscan.app.data.firebase.AccountService
-import com.github.andiim.plantscan.app.data.firebase.LogService
-import com.github.andiim.plantscan.app.ui.common.snackbar.SnackbarManager
-import com.github.andiim.plantscan.app.ui.navigation.Direction
-import com.github.andiim.plantscan.app.ui.screens.viewModels.PlantScanViewModel
-import com.github.andiim.plantscan.library.android.extensions.isValidEmail
-import dagger.hilt.android.lifecycle.HiltViewModel
-import javax.inject.Inject
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asStateFlow
-
-@HiltViewModel
-class LoginViewModel @Inject
-constructor(private val accountService: AccountService, logService: LogService) :
- PlantScanViewModel(logService) {
- private val _state = MutableStateFlow(LoginState())
- val state = _state.asStateFlow()
-
- fun onEmailChange(newValue: String) {
- _state.value = _state.value.copy(email = newValue)
- }
-
- fun onPasswordChange(newValue: String) {
- _state.value = _state.value.copy(password = newValue)
- }
-
- fun onSignInClick(openAndPopUp: (String, String) -> Unit) {
- if (!_state.value.email.isValidEmail()) {
- SnackbarManager.showMessage(R.string.email_error)
- return
- }
-
- if (_state.value.password.isBlank()) {
- SnackbarManager.showMessage(R.string.error_empty_password)
- return
- }
-
- launchCatching {
- accountService.authenticate(_state.value.email, _state.value.password)
- openAndPopUp(Direction.Settings.route, Direction.Login.route)
- }
- }
-
- fun onForgotPasswordClick() {
- if (!_state.value.email.isValidEmail()) {
- SnackbarManager.showMessage(R.string.email_error)
- return
- }
-
- launchCatching {
- accountService.sendRecoveryEmail(_state.value.email)
- SnackbarManager.showMessage(R.string.hint_recovery_email_sent)
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/auth/signUp/SignUpScreen.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/auth/signUp/SignUpScreen.kt
deleted file mode 100644
index 321fb467..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/auth/signUp/SignUpScreen.kt
+++ /dev/null
@@ -1,74 +0,0 @@
-package com.github.andiim.plantscan.app.ui.screens.auth.signUp
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxHeight
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material3.Surface
-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.tooling.preview.Preview
-import androidx.hilt.navigation.compose.hiltViewModel
-import com.github.andiim.plantscan.app.R
-import com.github.andiim.plantscan.app.ui.common.composables.BasicButton
-import com.github.andiim.plantscan.app.ui.common.composables.BasicToolbar
-import com.github.andiim.plantscan.app.ui.common.composables.EmailField
-import com.github.andiim.plantscan.app.ui.common.composables.PasswordField
-import com.github.andiim.plantscan.app.ui.common.composables.RepeatPasswordField
-import com.github.andiim.plantscan.app.ui.common.extensions.basicButton
-import com.github.andiim.plantscan.app.ui.common.extensions.fieldModifier
-import com.github.andiim.plantscan.app.ui.theme.PlantScanTheme
-
-@Composable
-fun SignUpScreen(
- openAndPopUp: (String, String) -> Unit,
- viewModel: SignUpViewModel = hiltViewModel()
-) {
- val state by viewModel.state.collectAsState()
- SignUpContent(
- openAndPopUp = openAndPopUp,
- uiState = state,
- onEmailChange = viewModel::onEmailChange,
- onPasswordChange = viewModel::onPasswordChange,
- onRepeatPasswordChange = viewModel::onRepeatPasswordChange,
- onSignUpClick = viewModel::onSignUpClick,
- )
-}
-
-@Composable
-fun SignUpContent(
- openAndPopUp: (String, String) -> Unit = { _, _ -> },
- uiState: SignUpState = SignUpState(),
- onEmailChange: (String) -> Unit = {},
- onPasswordChange: (String) -> Unit = {},
- onRepeatPasswordChange: (String) -> Unit = {},
- onSignUpClick: ((String, String) -> Unit) -> Unit = {}
-) {
- BasicToolbar(R.string.label_create_account)
-
- Column(
- modifier = Modifier.fillMaxWidth().fillMaxHeight().verticalScroll(rememberScrollState()),
- verticalArrangement = Arrangement.Center,
- horizontalAlignment = Alignment.CenterHorizontally) {
- EmailField(uiState.email, onEmailChange, Modifier.fieldModifier())
- PasswordField(uiState.password, onPasswordChange, Modifier.fieldModifier())
- RepeatPasswordField(uiState.repeatPassword, onRepeatPasswordChange, Modifier.fieldModifier())
-
- BasicButton(R.string.label_create_account, Modifier.basicButton()) { onSignUpClick(openAndPopUp) }
- }
-}
-
-@Preview
-@Composable
-private fun Preview_SignUpScreen() {
- PlantScanTheme {
- Surface {
- SignUpContent()
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/auth/signUp/SignUpState.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/auth/signUp/SignUpState.kt
deleted file mode 100644
index c225ca51..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/auth/signUp/SignUpState.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package com.github.andiim.plantscan.app.ui.screens.auth.signUp
-
-data class SignUpState(
- val email: String = "",
- val password: String = "",
- val repeatPassword: String = ""
-)
\ No newline at end of file
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/auth/signUp/SignUpViewModel.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/auth/signUp/SignUpViewModel.kt
deleted file mode 100644
index 3df5b16d..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/auth/signUp/SignUpViewModel.kt
+++ /dev/null
@@ -1,59 +0,0 @@
-package com.github.andiim.plantscan.app.ui.screens.auth.signUp
-
-import com.github.andiim.plantscan.app.R
-import com.github.andiim.plantscan.app.data.firebase.AccountService
-import com.github.andiim.plantscan.app.data.firebase.LogService
-import com.github.andiim.plantscan.app.ui.common.snackbar.SnackbarManager
-import com.github.andiim.plantscan.app.ui.screens.viewModels.PlantScanViewModel
-import com.github.andiim.plantscan.library.android.extensions.isValidEmail
-import com.github.andiim.plantscan.library.android.extensions.isValidPassword
-import com.github.andiim.plantscan.library.android.extensions.passwordMatches
-import dagger.hilt.android.lifecycle.HiltViewModel
-import javax.inject.Inject
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asStateFlow
-@HiltViewModel
-class SignUpViewModel @Inject
-constructor(private val accountService: AccountService, logService: LogService) :
- PlantScanViewModel(logService) {
-
- private val _state = MutableStateFlow(SignUpState())
- val state = _state.asStateFlow()
-
- private val email = _state.value.email
- private val password = _state.value.password
-
- fun onEmailChange(newValue: String) {
- _state.value = _state.value.copy(email = newValue)
- }
-
- fun onPasswordChange(newValue: String) {
- _state.value = _state.value.copy(password = newValue)
- }
-
- fun onRepeatPasswordChange(newValue: String) {
- _state.value = _state.value.copy(repeatPassword = newValue)
- }
-
- fun onSignUpClick(openAndPopUp: (String, String) -> Unit) {
- if (!email.isValidEmail()) {
- SnackbarManager.showMessage(R.string.email_error)
- return
- }
-
- if (!password.isValidPassword()) {
- SnackbarManager.showMessage(R.string.error_wrong_password)
- return
- }
-
- if (!password.passwordMatches(_state.value.repeatPassword)) {
- SnackbarManager.showMessage(R.string.error_password_match)
- return
- }
-
- launchCatching {
- accountService.linkAccount(email, password)
- openAndPopUp("mandatory", "mandatory")
- }
- }
-}
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/detail/DetailScreen.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/detail/DetailScreen.kt
deleted file mode 100644
index c21ad555..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/detail/DetailScreen.kt
+++ /dev/null
@@ -1,31 +0,0 @@
-package com.github.andiim.plantscan.app.ui.screens.detail
-
-import androidx.compose.material3.Surface
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.hilt.navigation.compose.hiltViewModel
-import com.github.andiim.plantscan.app.ui.theme.PlantScanTheme
-
-@Composable
-fun DetailScreen(
- id: String?,
- popUpScreen: () -> Unit,
- viewModel: DetailViewModel = hiltViewModel()
-) {
- DetailContent()
-}
-
-@Composable
-fun DetailContent() {
-
-}
-
-@Preview
-@Composable
-private fun Preview_DetailContent() {
- PlantScanTheme {
- Surface {
- DetailContent()
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/detail/DetailViewModel.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/detail/DetailViewModel.kt
deleted file mode 100644
index 885d0d8d..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/detail/DetailViewModel.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-package com.github.andiim.plantscan.app.ui.screens.detail
-
-import com.github.andiim.plantscan.app.data.firebase.LogService
-import com.github.andiim.plantscan.app.ui.screens.viewModels.PlantScanViewModel
-import dagger.hilt.android.lifecycle.HiltViewModel
-import javax.inject.Inject
-
-@HiltViewModel
-class DetailViewModel @Inject
-constructor(logService: LogService) : PlantScanViewModel(logService) {
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/detect/DetectScreen.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/detect/DetectScreen.kt
deleted file mode 100644
index 4e15a8d9..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/detect/DetectScreen.kt
+++ /dev/null
@@ -1,30 +0,0 @@
-package com.github.andiim.plantscan.app.ui.screens.detect
-
-import androidx.compose.material3.Surface
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.hilt.navigation.compose.hiltViewModel
-import com.github.andiim.plantscan.app.ui.theme.PlantScanTheme
-
-@Composable
-fun DetectScreen(
- popUpScreen: () -> Unit,
- viewModel: DetectViewModel = hiltViewModel()
-) {
- DetectContent()
-}
-
-@Composable
-fun DetectContent() {
-
-}
-
-@Preview
-@Composable
-private fun Preview_DetectContent() {
- PlantScanTheme {
- Surface {
- DetectContent()
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/detect/DetectViewModel.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/detect/DetectViewModel.kt
deleted file mode 100644
index 348d80f5..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/detect/DetectViewModel.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-package com.github.andiim.plantscan.app.ui.screens.detect
-
-import com.github.andiim.plantscan.app.data.firebase.LogService
-import com.github.andiim.plantscan.app.ui.screens.viewModels.PlantScanViewModel
-import dagger.hilt.android.lifecycle.HiltViewModel
-import javax.inject.Inject
-
-@HiltViewModel
-class DetectViewModel @Inject constructor(logService: LogService) :
- PlantScanViewModel(logService) {
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/home/findPlant/FindPlantElement.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/home/findPlant/FindPlantElement.kt
deleted file mode 100644
index 63face96..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/home/findPlant/FindPlantElement.kt
+++ /dev/null
@@ -1,147 +0,0 @@
-package com.github.andiim.plantscan.app.ui.screens.home.findPlant
-
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
-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.filled.CameraAlt
-import androidx.compose.material.icons.filled.Close
-import androidx.compose.material.icons.filled.Search
-import androidx.compose.material3.Button
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.SearchBar
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
-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.draw.shadow
-import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.graphics.Brush
-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 androidx.hilt.navigation.compose.hiltViewModel
-import androidx.paging.PagingData
-import androidx.paging.compose.collectAsLazyPagingItems
-import com.github.andiim.plantscan.app.data.model.Plant
-import com.github.andiim.plantscan.app.ui.common.composables.PlantPagedList
-import com.github.andiim.plantscan.app.ui.theme.PlantScanTheme
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.flowOf
-import com.github.andiim.plantscan.app.R.string as AppText
-
-@Composable
-fun FindPlantElement(
- modifier: Modifier = Modifier,
- onDetails: (Plant) -> Unit,
- toDetect: () -> Unit,
- toPlantType: () -> Unit,
- viewModel: FindPlantViewModel = hiltViewModel()
-) {
- val query by viewModel.query.collectAsState()
-
- FindPlantContent(
- modifier = modifier,
- query = query,
- data = viewModel.fetchedData,
- onQueryChange = viewModel::onQueryChange,
- toDetail = onDetails,
- toDetect = toDetect,
- toPlantType = toPlantType
- )
-}
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun FindPlantContent(
- modifier: Modifier = Modifier,
- query: String = "",
- data: Flow> = flowOf(),
- onQueryChange: (String) -> Unit = {},
- toDetect: () -> Unit = {},
- toDetail: (Plant) -> Unit = {},
- toPlantType: () -> Unit = {},
-) {
- var active by rememberSaveable { mutableStateOf(false) }
- val plants = data.collectAsLazyPagingItems()
-
- Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxSize()) {
- IconButton(modifier = modifier.size(100.dp), onClick = toDetect) {
- Box(
- contentAlignment = Alignment.Center,
- modifier =
- modifier
- .fillMaxSize()
- .background(
- brush =
- Brush.radialGradient(
- colors = listOf(Color(0xFF789885), Color(0xFF7D8A82)),
- center = Offset(0.5f, 0.5f),
- radius = 0.2f
- )
- )
- ) {
- Icon(
- Icons.Default.CameraAlt,
- tint = Color.White,
- modifier = modifier
- .fillMaxSize()
- .padding(30.dp)
- .shadow(8.dp, shape = CircleShape),
- contentDescription = stringResource(AppText.search_using_camera_icon_description)
- )
- }
- }
- Button(modifier = Modifier.align(Alignment.BottomCenter), onClick = toPlantType) {
- Text(text = "Find by plant type")
- }
-
- SearchBar(
- modifier = Modifier.align(Alignment.TopCenter),
- query = query,
- onQueryChange = onQueryChange,
- onSearch = { active = false },
- active = active,
- onActiveChange = { active = it },
- placeholder = { Text(stringResource(AppText.search_placeholder)) },
- leadingIcon = {
- Icon(
- Icons.Default.Search,
- contentDescription = stringResource(AppText.search_icon_description)
- )
- },
- trailingIcon = {
- if (active)
- IconButton(onClick = { active = false }) {
- Icon(
- Icons.Default.Close,
- contentDescription = stringResource(AppText.search_icon_close_description)
- )
- }
- }) {
- PlantPagedList(plants = plants, onItemClick = toDetail)
- }
- }
-}
-
-@Preview
-@Composable
-private fun Preview_FindPlantContent() {
- PlantScanTheme {
- Surface {
- FindPlantContent()
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/home/findPlant/FindPlantViewModel.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/home/findPlant/FindPlantViewModel.kt
deleted file mode 100644
index 12c6b74a..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/home/findPlant/FindPlantViewModel.kt
+++ /dev/null
@@ -1,56 +0,0 @@
-package com.github.andiim.plantscan.app.ui.screens.home.findPlant
-
-import androidx.lifecycle.viewModelScope
-import androidx.paging.PagingData
-import androidx.paging.cachedIn
-import com.github.andiim.plantscan.app.data.firebase.LogService
-import com.github.andiim.plantscan.app.data.firebase.PlantDatabase
-import com.github.andiim.plantscan.app.data.model.Plant
-import com.github.andiim.plantscan.app.ui.screens.viewModels.PlantScanViewModel
-import dagger.hilt.android.lifecycle.HiltViewModel
-import javax.inject.Inject
-import kotlinx.coroutines.FlowPreview
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.debounce
-import kotlinx.coroutines.launch
-import kotlin.time.Duration.Companion.seconds
-
-@OptIn(FlowPreview::class)
-@HiltViewModel
-class FindPlantViewModel @Inject constructor(
- private val plantDatabase: PlantDatabase,
- logService: LogService
-) :
- PlantScanViewModel(logService) {
-
- private val _query = MutableStateFlow("")
- val query: StateFlow = _query.asStateFlow()
-
- private val _fetchedData = MutableStateFlow>(PagingData.empty())
- val fetchedData: StateFlow> = _fetchedData.asStateFlow()
-
- fun onQueryChange(query: String) {
- _query.value = query
- }
-
- init {
- viewModelScope.launch {
- _query
- .debounce(1.seconds)
- .collect { query -> searchPlant(query) }
- }
- }
-
- private fun searchPlant(query: String) {
- viewModelScope.launch {
- if (query.isNotEmpty())
- plantDatabase
- .searchPlant(query = query)
- .cachedIn(viewModelScope)
- .collect { _fetchedData.value = it }
- else _fetchedData.value = PagingData.empty()
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/home/myGarden/MyGardenElement.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/home/myGarden/MyGardenElement.kt
deleted file mode 100644
index 81f568ff..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/home/myGarden/MyGardenElement.kt
+++ /dev/null
@@ -1,39 +0,0 @@
-package com.github.andiim.plantscan.app.ui.screens.home.myGarden
-
-import androidx.compose.material3.Surface
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.hilt.navigation.compose.hiltViewModel
-import androidx.paging.PagingData
-import androidx.paging.compose.collectAsLazyPagingItems
-import com.github.andiim.plantscan.app.data.model.Plant
-import com.github.andiim.plantscan.app.ui.common.composables.PlantPagedList
-import com.github.andiim.plantscan.app.ui.theme.PlantScanTheme
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.flowOf
-
-@Composable
-fun MyGardenElement(
- toDetail: (Plant) -> Unit,
- viewModel: MyGardenViewModel = hiltViewModel()
-) {
- MyGardenContent(plant = viewModel.myPlant, onItemClick = toDetail)
-}
-
-@Composable
-fun MyGardenContent(
- plant: Flow> = flowOf(),
- onItemClick: (Plant) -> Unit = {},
-) {
- PlantPagedList(plants = plant.collectAsLazyPagingItems(), onItemClick = onItemClick)
-}
-
-@Preview
-@Composable
-private fun Preview_MyGardenContent() {
- PlantScanTheme {
- Surface {
- MyGardenContent()
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/home/myGarden/MyGardenViewModel.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/home/myGarden/MyGardenViewModel.kt
deleted file mode 100644
index 9817bb50..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/home/myGarden/MyGardenViewModel.kt
+++ /dev/null
@@ -1,15 +0,0 @@
-package com.github.andiim.plantscan.app.ui.screens.home.myGarden
-
-import androidx.lifecycle.viewModelScope
-import androidx.paging.cachedIn
-import com.github.andiim.plantscan.app.data.firebase.LogService
-import com.github.andiim.plantscan.app.data.firebase.PlantDatabase
-import com.github.andiim.plantscan.app.ui.screens.viewModels.PlantScanViewModel
-import dagger.hilt.android.lifecycle.HiltViewModel
-import javax.inject.Inject
-
-@HiltViewModel
-class MyGardenViewModel @Inject
-constructor(plantDatabase: PlantDatabase, logService: LogService) : PlantScanViewModel(logService) {
- val myPlant = plantDatabase.getMyPlant().cachedIn(viewModelScope)
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/home/settings/SettingsElement.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/home/settings/SettingsElement.kt
deleted file mode 100644
index 7b21a7b0..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/home/settings/SettingsElement.kt
+++ /dev/null
@@ -1,146 +0,0 @@
-package com.github.andiim.plantscan.app.ui.screens.home.settings
-
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxHeight
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material3.AlertDialog
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
-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.tooling.preview.Preview
-import androidx.hilt.navigation.compose.hiltViewModel
-import com.github.andiim.plantscan.app.R
-import com.github.andiim.plantscan.app.ui.common.composables.BasicToolbar
-import com.github.andiim.plantscan.app.ui.common.composables.DangerousCardEditor
-import com.github.andiim.plantscan.app.ui.common.composables.DialogCancelButton
-import com.github.andiim.plantscan.app.ui.common.composables.DialogConfirmButton
-import com.github.andiim.plantscan.app.ui.common.composables.RegularCardEditor
-import com.github.andiim.plantscan.app.ui.common.extensions.card
-import com.github.andiim.plantscan.app.ui.common.extensions.spacer
-import com.github.andiim.plantscan.app.ui.theme.PlantScanTheme
-
-@Composable
-fun SettingsElement(
- restartApp: (String) -> Unit,
- openScreen: (String) -> Unit,
- modifier: Modifier = Modifier,
- viewModel: SettingsViewModel = hiltViewModel()
-) {
- val isAnonymity = viewModel.uiState.collectAsState(SettingsUiState(false))
- SettingsContent(
- modifier = modifier,
- openScreen = openScreen,
- restartApp = restartApp,
- isAnonymity = isAnonymity.value.isAnonymousAccount,
- onLoginClick = viewModel::onLoginClick,
- onSignUpClick = viewModel::onSignUpClick,
- onSignOutClick = viewModel::onSignOutClick,
- onDeleteMyAccountClick = viewModel::onDeleteMyAccountClick
- )
-}
-
-@Composable
-fun SettingsContent(
- modifier: Modifier = Modifier,
- isAnonymity: Boolean = false,
- openScreen: (String) -> Unit = {},
- restartApp: (String) -> Unit = {},
- onLoginClick: ((String) -> Unit) -> Unit = {},
- onSignUpClick: ((String) -> Unit) -> Unit = {},
- onSignOutClick: ((String) -> Unit) -> Unit = {},
- onDeleteMyAccountClick: ((String) -> Unit) -> Unit = {}
-) {
- Column(
- modifier = modifier
- .fillMaxWidth()
- .fillMaxHeight()
- .verticalScroll(rememberScrollState()),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- BasicToolbar(R.string.label_settings)
- Spacer(modifier = Modifier.spacer())
- if (isAnonymity) {
- RegularCardEditor(
- R.string.label_sign_in_sign_up,
- R.drawable.ic_sign_in, "",
- Modifier.card(),
- onEditClick = { onLoginClick(openScreen) })
-
- } else {
- SignOutCard { onSignOutClick(restartApp) }
- DeleteMyAccountCard { onDeleteMyAccountClick(restartApp) }
- }
- }
-}
-
-@Composable
-private fun SignOutCard(signOut: () -> Unit) {
- var showWarningDialog by remember { mutableStateOf(false) }
-
- RegularCardEditor(
- title = R.string.label_sign_out, icon = R.drawable.ic_exit, content = "", Modifier.card()
- ) {
- showWarningDialog = true
- }
-
- if (showWarningDialog) {
- AlertDialog(
- onDismissRequest = { showWarningDialog = false },
- title = { Text(stringResource(R.string.title_sign_out)) },
- text = { Text(stringResource(R.string.description_sign_out)) },
- dismissButton = { DialogCancelButton(R.string.cancel) { showWarningDialog = false } },
- confirmButton = {
- DialogConfirmButton(R.string.label_sign_out) {
- signOut()
- showWarningDialog = false
- }
- })
- }
-}
-
-@Composable
-private fun DeleteMyAccountCard(deleteMyAccount: () -> Unit) {
- var showWarningDialog by remember { mutableStateOf(false) }
-
- DangerousCardEditor(
- title = R.string.label_delete_account,
- icon = R.drawable.ic_delete_my_account,
- content = "",
- modifier = Modifier.card()
- ) { showWarningDialog = true }
-
- if (showWarningDialog) {
- AlertDialog(
- onDismissRequest = { showWarningDialog = false },
- title = { Text(stringResource(R.string.title_delete_account)) },
- text = { Text(stringResource(R.string.description_delete_account)) },
- dismissButton = { DialogCancelButton(R.string.cancel) { showWarningDialog = false } },
- confirmButton = {
- DialogConfirmButton(R.string.label_delete_account) {
- deleteMyAccount()
- showWarningDialog = false
- }
- })
- }
-}
-
-@Preview
-@Composable
-private fun Preview_SettingsContent() {
- PlantScanTheme {
- Surface {
- SettingsContent()
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/home/settings/SettingsUiState.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/home/settings/SettingsUiState.kt
deleted file mode 100644
index 0218783e..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/home/settings/SettingsUiState.kt
+++ /dev/null
@@ -1,3 +0,0 @@
-package com.github.andiim.plantscan.app.ui.screens.home.settings
-
-class SettingsUiState(val isAnonymousAccount: Boolean = true)
\ No newline at end of file
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/home/settings/SettingsViewModel.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/home/settings/SettingsViewModel.kt
deleted file mode 100644
index 5ea7b491..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/home/settings/SettingsViewModel.kt
+++ /dev/null
@@ -1,38 +0,0 @@
-package com.github.andiim.plantscan.app.ui.screens.home.settings
-
-import com.github.andiim.plantscan.app.data.firebase.AccountService
-import com.github.andiim.plantscan.app.data.firebase.LogService
-import com.github.andiim.plantscan.app.ui.navigation.Direction
-import com.github.andiim.plantscan.app.ui.screens.viewModels.PlantScanViewModel
-import dagger.hilt.android.lifecycle.HiltViewModel
-import javax.inject.Inject
-import kotlinx.coroutines.flow.map
-
-@HiltViewModel
-class SettingsViewModel @Inject constructor(
- private val accountService: AccountService,
- logService: LogService
-) :
- PlantScanViewModel(logService) {
- val uiState = accountService.currentUser.map { SettingsUiState(it.isAnonymous) }
-
- fun onLoginClick(openScreen: (String) -> Unit) {
- openScreen(Direction.Login.route)
- }
-
- fun onSignUpClick(openScreen: (String) -> Unit) {
- openScreen(Direction.SignUp.route)
- }
-
- fun onSignOutClick(restartApp: (String) -> Unit) {
- launchCatching { accountService.signOut() }
- restartApp(Direction.Splash.route)
- }
-
- fun onDeleteMyAccountClick(restartApp: (String) -> Unit) {
- launchCatching {
- accountService.deleteAccount()
- restartApp(Direction.Splash.route)
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/list/PlantListScreen.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/list/PlantListScreen.kt
deleted file mode 100644
index b505146a..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/list/PlantListScreen.kt
+++ /dev/null
@@ -1,65 +0,0 @@
-package com.github.andiim.plantscan.app.ui.screens.list
-
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.ArrowBack
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.Surface
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.hilt.navigation.compose.hiltViewModel
-import androidx.paging.PagingData
-import androidx.paging.compose.collectAsLazyPagingItems
-import com.github.andiim.plantscan.app.data.model.Plant
-import com.github.andiim.plantscan.app.ui.common.composables.BasicToolbar
-import com.github.andiim.plantscan.app.ui.common.composables.PlantPagedList
-import com.github.andiim.plantscan.app.ui.theme.PlantScanTheme
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.flowOf
-import com.github.andiim.plantscan.app.R.string as AppText
-
-@Composable
-fun PlantListScreen(
- toDetails: (Plant) -> Unit,
- popUpScreen: () -> Unit,
- viewModel: PlantListViewModel = hiltViewModel()
-) {
- PlantListContent(
- data = viewModel.fetchedData,
- onItemClick = toDetails,
- popUpScreen = popUpScreen
- )
-}
-
-@Composable
-fun PlantListContent(
- popUpScreen: () -> Unit = {},
- onItemClick: (Plant) -> Unit = {},
- data: Flow> = flowOf()
-) {
- val plants = data.collectAsLazyPagingItems()
-
- BasicToolbar(
- leading = {
- IconButton(onClick = popUpScreen) {
- Icon(
- Icons.Default.ArrowBack,
- contentDescription = stringResource(AppText.plants_exit)
- )
- }
- },
- title = AppText.plants_label
- )
- PlantPagedList(plants = plants, onItemClick = onItemClick)
-}
-
-@Preview
-@Composable
-private fun Preview_PlantListContent() {
- PlantScanTheme {
- Surface {
- PlantListContent()
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/list/PlantListViewModel.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/list/PlantListViewModel.kt
deleted file mode 100644
index 7e4f8b23..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/list/PlantListViewModel.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.github.andiim.plantscan.app.ui.screens.list
-
-import androidx.lifecycle.viewModelScope
-import androidx.paging.cachedIn
-import com.github.andiim.plantscan.app.data.firebase.LogService
-import com.github.andiim.plantscan.app.data.firebase.PlantDatabase
-import com.github.andiim.plantscan.app.ui.screens.viewModels.PlantScanViewModel
-import dagger.hilt.android.lifecycle.HiltViewModel
-import javax.inject.Inject
-
-@HiltViewModel
-class PlantListViewModel @Inject constructor(
- plantDatabase: PlantDatabase,
- logService: LogService
-) : PlantScanViewModel(logService) {
- val fetchedData = plantDatabase.getAllPlant().cachedIn(viewModelScope)
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/splash/SplashScreen.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/splash/SplashScreen.kt
deleted file mode 100644
index 5acfcfba..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/splash/SplashScreen.kt
+++ /dev/null
@@ -1,86 +0,0 @@
-package com.github.andiim.plantscan.app.ui.screens.splash
-
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxHeight
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-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.hilt.navigation.compose.hiltViewModel
-import com.github.andiim.plantscan.app.R
-import com.github.andiim.plantscan.app.ui.common.composables.BasicButton
-import com.github.andiim.plantscan.app.ui.common.extensions.basicButton
-import com.github.andiim.plantscan.app.ui.theme.PlantScanTheme
-import kotlinx.coroutines.delay
-
-private const val SPLASH_TIMEOUT = 1000L
-
-@Composable
-fun SplashScreen(
- modifier: Modifier = Modifier,
- openAndPopUp: (String, String) -> Unit,
- viewModel: SplashViewModel = hiltViewModel()
-) {
- SplashContent(
- modifier = modifier,
- openAndPopUp = openAndPopUp,
- onAppStart = viewModel::onAppStart,
- isError = viewModel.showError.value
- )
-}
-
-@Composable
-fun SplashContent(
- modifier: Modifier = Modifier,
- openAndPopUp: (String, String) -> Unit = { _, _ -> },
- onAppStart: ((String, String) -> Unit) -> Unit = {},
- isError: Boolean = false
-) {
- Column(
- modifier =
- modifier
- .fillMaxWidth()
- .fillMaxHeight()
- .background(color = MaterialTheme.colorScheme.background)
- .verticalScroll(rememberScrollState()),
- verticalArrangement = Arrangement.Center,
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- if (isError) {
- Text(text = stringResource(R.string.generic_error))
-
- BasicButton(
- text = R.string.try_again,
- Modifier.basicButton(),
- action = { onAppStart(openAndPopUp) })
- } else {
- CircularProgressIndicator(color = MaterialTheme.colorScheme.onBackground)
- }
- }
-
- LaunchedEffect(true) {
- delay(SPLASH_TIMEOUT)
- onAppStart(openAndPopUp)
- }
-}
-
-@Preview
-@Composable
-private fun Preview_PlantListContent() {
- PlantScanTheme {
- Surface {
- SplashContent()
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/splash/SplashViewModel.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/splash/SplashViewModel.kt
deleted file mode 100644
index 494679d3..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/splash/SplashViewModel.kt
+++ /dev/null
@@ -1,42 +0,0 @@
-package com.github.andiim.plantscan.app.ui.screens.splash
-
-import androidx.compose.runtime.mutableStateOf
-import com.github.andiim.plantscan.app.data.firebase.AccountService
-import com.github.andiim.plantscan.app.data.firebase.ConfigurationService
-import com.github.andiim.plantscan.app.data.firebase.LogService
-import com.github.andiim.plantscan.app.ui.navigation.Direction
-import com.github.andiim.plantscan.app.ui.screens.viewModels.PlantScanViewModel
-import dagger.hilt.android.lifecycle.HiltViewModel
-import javax.inject.Inject
-
-@HiltViewModel
-class SplashViewModel @Inject
-constructor(
- configurationService: ConfigurationService,
- private val accountService: AccountService,
- logService: LogService
-) : PlantScanViewModel(logService) {
- val showError = mutableStateOf(false)
-
- init {
- launchCatching { configurationService.fetchConfiguration() }
- }
-
- fun onAppStart(openAndPopup: (String, String) -> Unit) {
- showError.value = false
- if (accountService.hasUser) openAndPopup(Direction.MainNav.route, Direction.Splash.route)
- else createAnonymousAccount(openAndPopup)
- }
-
- private fun createAnonymousAccount(openAndPopup: (String, String) -> Unit) {
- launchCatching(snackbar = false) {
- try {
- accountService.createAnonymousAccount()
- } catch (ex: Exception) {
- showError.value = true
- throw ex
- }
- openAndPopup(Direction.MainNav.route, Direction.Splash.route)
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/viewModels/PlantScanViewModel.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/viewModels/PlantScanViewModel.kt
deleted file mode 100644
index caec403f..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/screens/viewModels/PlantScanViewModel.kt
+++ /dev/null
@@ -1,22 +0,0 @@
-package com.github.andiim.plantscan.app.ui.screens.viewModels
-
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import com.github.andiim.plantscan.app.data.firebase.LogService
-import com.github.andiim.plantscan.app.ui.common.snackbar.SnackbarManager
-import com.github.andiim.plantscan.app.ui.common.snackbar.SnackbarMessage.Companion.toSnackbarMessage
-import kotlinx.coroutines.CoroutineExceptionHandler
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.launch
-
-open class PlantScanViewModel(private val logService: LogService) : ViewModel() {
- fun launchCatching(snackbar: Boolean = true, block: suspend CoroutineScope.() -> Unit) =
- viewModelScope.launch(
- CoroutineExceptionHandler { _, throwable ->
- if (snackbar) {
- SnackbarManager.showMessage(throwable.toSnackbarMessage())
- }
- logService.logNonFatalCrash(throwable)
- },
- block = block)
-}
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/theme/Shapes.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/theme/Shapes.kt
deleted file mode 100644
index 9e583f58..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/theme/Shapes.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-package com.github.andiim.plantscan.app.ui.theme
-
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.ReadOnlyComposable
-import androidx.compose.runtime.compositionLocalOf
-import androidx.compose.ui.unit.dp
-
-data class Shape(
- val default: RoundedCornerShape = RoundedCornerShape(0.dp),
- val small: RoundedCornerShape = RoundedCornerShape(4.dp),
- val medium: RoundedCornerShape = RoundedCornerShape(8.dp),
- val large: RoundedCornerShape = RoundedCornerShape(16.dp)
-)
-
-val LocalShape = compositionLocalOf { Shape() }
-
-val MaterialTheme.shapeScheme: Shape
- @Composable @ReadOnlyComposable get() = LocalShape.current
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/ui/theme/Type.kt b/app/src/main/java/com/github/andiim/plantscan/app/ui/theme/Type.kt
deleted file mode 100644
index 0928501f..00000000
--- a/app/src/main/java/com/github/andiim/plantscan/app/ui/theme/Type.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.github.andiim.plantscan.app.ui.theme
-
-import androidx.compose.material3.Typography
-import androidx.compose.ui.text.TextStyle
-import androidx.compose.ui.text.font.FontFamily
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.unit.sp
-
-val Typography =
- Typography(
- bodyLarge =
- TextStyle(
- fontFamily = FontFamily.Default,
- fontWeight = FontWeight.Normal,
- fontSize = 16.sp,
- lineHeight = 24.sp,
- letterSpacing = 0.5.sp))
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/utils/Constants.kt b/app/src/main/java/com/github/andiim/plantscan/app/utils/Constants.kt
new file mode 100644
index 00000000..d6c81569
--- /dev/null
+++ b/app/src/main/java/com/github/andiim/plantscan/app/utils/Constants.kt
@@ -0,0 +1,4 @@
+package com.github.andiim.plantscan.app.utils
+
+const val BOTTOM_BAR_TAG = "PsBottomBar"
+const val NAV_RAIL_TAG = "PsNavRail"
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/di/TimberDebugTree.kt b/app/src/main/java/com/github/andiim/plantscan/app/utils/logger/TimberDebugTree.kt
similarity index 92%
rename from app/src/main/java/com/github/andiim/plantscan/app/di/TimberDebugTree.kt
rename to app/src/main/java/com/github/andiim/plantscan/app/utils/logger/TimberDebugTree.kt
index 5def13a5..38f40174 100644
--- a/app/src/main/java/com/github/andiim/plantscan/app/di/TimberDebugTree.kt
+++ b/app/src/main/java/com/github/andiim/plantscan/app/utils/logger/TimberDebugTree.kt
@@ -1,4 +1,4 @@
-package com.github.andiim.plantscan.app.di
+package com.github.andiim.plantscan.app.utils.logger
import android.annotation.SuppressLint
import android.util.Log
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/utils/snackbar/SnackbarHostExt.kt b/app/src/main/java/com/github/andiim/plantscan/app/utils/snackbar/SnackbarHostExt.kt
new file mode 100644
index 00000000..e937dd48
--- /dev/null
+++ b/app/src/main/java/com/github/andiim/plantscan/app/utils/snackbar/SnackbarHostExt.kt
@@ -0,0 +1,23 @@
+package com.github.andiim.plantscan.app.utils.snackbar
+
+import androidx.compose.material3.SnackbarDuration
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.SnackbarResult
+
+/**
+ * Custom Snackbar Message with [SnackbarMessage] visual.
+ */
+suspend fun SnackbarHostState.showMessage(
+ message: String,
+ actionLabel: String? = null,
+ action: (() -> Unit)? = null,
+ duration: SnackbarDuration = SnackbarDuration.Short,
+): SnackbarResult {
+ val snackbarVisual = SnackbarMessage(
+ message = message,
+ setDuration = duration,
+ action = action,
+ actionTitle = actionLabel,
+ )
+ return this.showSnackbar(snackbarVisual)
+}
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/utils/snackbar/SnackbarManager.kt b/app/src/main/java/com/github/andiim/plantscan/app/utils/snackbar/SnackbarManager.kt
new file mode 100644
index 00000000..22c1c5fc
--- /dev/null
+++ b/app/src/main/java/com/github/andiim/plantscan/app/utils/snackbar/SnackbarManager.kt
@@ -0,0 +1,27 @@
+package com.github.andiim.plantscan.app.utils.snackbar
+
+import androidx.compose.material3.SnackbarDuration
+import androidx.compose.material3.SnackbarDuration.Short
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+object SnackbarManager {
+ private val messages: MutableStateFlow = MutableStateFlow(null)
+ val snackbarMessages: StateFlow
+ get() = messages.asStateFlow()
+
+ fun showMessage(
+ message: String,
+ action: (() -> Unit)? = null,
+ duration: SnackbarDuration = Short,
+ label: String? = null,
+ ) {
+ messages.value = SnackbarMessage(
+ message = message,
+ setDuration = duration,
+ actionTitle = label,
+ action = action
+ )
+ }
+}
diff --git a/app/src/main/java/com/github/andiim/plantscan/app/utils/snackbar/SnackbarMessage.kt b/app/src/main/java/com/github/andiim/plantscan/app/utils/snackbar/SnackbarMessage.kt
new file mode 100644
index 00000000..f417f342
--- /dev/null
+++ b/app/src/main/java/com/github/andiim/plantscan/app/utils/snackbar/SnackbarMessage.kt
@@ -0,0 +1,15 @@
+package com.github.andiim.plantscan.app.utils.snackbar
+
+import androidx.compose.material3.SnackbarDuration
+import androidx.compose.material3.SnackbarVisuals
+
+class SnackbarMessage(
+ override val message: String,
+ val setDuration: SnackbarDuration,
+ val actionTitle: String? = null,
+ val action: (() -> Unit)? = null,
+) : SnackbarVisuals {
+ override val actionLabel: String? = actionTitle
+ override val duration: SnackbarDuration = setDuration
+ override val withDismissAction: Boolean = false
+}
diff --git a/app/src/main/res/drawable/ic_calendar.xml b/app/src/main/res/drawable/ic_calendar.xml
deleted file mode 100644
index 964ec03c..00000000
--- a/app/src/main/res/drawable/ic_calendar.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
diff --git a/app/src/main/res/drawable/ic_check.xml b/app/src/main/res/drawable/ic_check.xml
deleted file mode 100644
index 0432fa69..00000000
--- a/app/src/main/res/drawable/ic_check.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
diff --git a/app/src/main/res/drawable/ic_clock.xml b/app/src/main/res/drawable/ic_clock.xml
deleted file mode 100644
index 86533bf0..00000000
--- a/app/src/main/res/drawable/ic_clock.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
diff --git a/app/src/main/res/drawable/ic_create_account.xml b/app/src/main/res/drawable/ic_create_account.xml
deleted file mode 100644
index 491bf766..00000000
--- a/app/src/main/res/drawable/ic_create_account.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
diff --git a/app/src/main/res/drawable/ic_delete_my_account.xml b/app/src/main/res/drawable/ic_delete_my_account.xml
deleted file mode 100644
index 3c4030b0..00000000
--- a/app/src/main/res/drawable/ic_delete_my_account.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
diff --git a/app/src/main/res/drawable/ic_error.xml b/app/src/main/res/drawable/ic_error.xml
deleted file mode 100644
index 7816afd1..00000000
--- a/app/src/main/res/drawable/ic_error.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
diff --git a/app/src/main/res/drawable/ic_exit.xml b/app/src/main/res/drawable/ic_exit.xml
deleted file mode 100644
index 83cdf05a..00000000
--- a/app/src/main/res/drawable/ic_exit.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
diff --git a/app/src/main/res/drawable/ic_flag.xml b/app/src/main/res/drawable/ic_flag.xml
deleted file mode 100644
index 026653ce..00000000
--- a/app/src/main/res/drawable/ic_flag.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 00000000..370b9f5a
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,1199 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml
deleted file mode 100644
index f3164bc8..00000000
--- a/app/src/main/res/drawable/ic_settings.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
diff --git a/app/src/main/res/drawable/ic_sign_in.xml b/app/src/main/res/drawable/ic_sign_in.xml
deleted file mode 100644
index 6bdced2d..00000000
--- a/app/src/main/res/drawable/ic_sign_in.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
diff --git a/app/src/main/res/drawable/ic_visibility_off.xml b/app/src/main/res/drawable/ic_visibility_off.xml
deleted file mode 100644
index 92c48569..00000000
--- a/app/src/main/res/drawable/ic_visibility_off.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
diff --git a/app/src/main/res/drawable/ic_visibility_on.xml b/app/src/main/res/drawable/ic_visibility_on.xml
deleted file mode 100644
index a3e222a2..00000000
--- a/app/src/main/res/drawable/ic_visibility_on.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
diff --git a/app/src/main/res/drawable/icon_base.xml b/app/src/main/res/drawable/icon_base.xml
new file mode 100644
index 00000000..4091d165
--- /dev/null
+++ b/app/src/main/res/drawable/icon_base.xml
@@ -0,0 +1,1193 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/orchid.webp b/app/src/main/res/drawable/orchid.webp
new file mode 100644
index 00000000..6a9825c2
Binary files /dev/null and b/app/src/main/res/drawable/orchid.webp differ
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
index 6f3b755b..e84739f5 100644
--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -1,6 +1,5 @@
-
-
-
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
index 6f3b755b..e84739f5 100644
--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -1,6 +1,5 @@
-
-
-
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png
deleted file mode 100644
index a571e600..00000000
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 00000000..9250e957
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
deleted file mode 100644
index 61da551c..00000000
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..387b37b6
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png
deleted file mode 100644
index c41dd285..00000000
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 00000000..042c5c02
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
deleted file mode 100644
index db5080a7..00000000
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..c1baddca
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
deleted file mode 100644
index 6dba46da..00000000
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 00000000..c5ac8ee2
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
deleted file mode 100644
index da31a871..00000000
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..4dff6ef4
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
deleted file mode 100644
index 15ac6817..00000000
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 00000000..48e14703
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
deleted file mode 100644
index b216f2d3..00000000
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..f58891d9
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
deleted file mode 100644
index f25a4197..00000000
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 00000000..927b2db8
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
deleted file mode 100644
index e96783cc..00000000
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..bd3122c6
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml
new file mode 100644
index 00000000..a6b3daec
--- /dev/null
+++ b/app/src/main/res/values-night/colors.xml
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
index 3c9fb6b7..39212635 100644
--- a/app/src/main/res/values-night/themes.xml
+++ b/app/src/main/res/values-night/themes.xml
@@ -1,34 +1,14 @@
+
-
-
-
+
+
+
\ 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