diff --git a/.github/workflows/androidbuild.yml b/.github/workflows/androidbuild.yml
index dc93f25..0b29f3d 100644
--- a/.github/workflows/androidbuild.yml
+++ b/.github/workflows/androidbuild.yml
@@ -35,14 +35,16 @@ jobs:
- name: Run ktlintCheck on the codebase
run: ./gradlew ktlintCheck
+ - name: Build With Gradle
+ run: ./gradlew build
+
- name: Run Unit Tests
run: ./gradlew clean test
- name: Run debug Build
run: ./gradlew assembleDebug
- - name : Build With Gradle
- run: ./gradlew build
+
- name: Upload a Build Artifact
uses: actions/upload-artifact@v4.3.3
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ba03df1
--- /dev/null
+++ b/README.md
@@ -0,0 +1,117 @@
+## CalorieBytez App
+
+This is a simple Android app that uses the [Calorie Ninja](https://calorieninjas.com/api) to display
+nutritional data for various food items.
+
+
+
+*Pre-requisites*
+- Built on A.S JellyFish
+- JDK 17
+- Access token from CalorieNinja.
+- Once received place it in the local.properties file as follows:
+``` properties
+API_KEY = YourKey
+```
+To Inject the Key when using CI/CD with github actions , add the key to your projects secrets and extract in to your build workflow:
+
+``` yaml
+ - name: Get local.properties from secrets
+ run: echo "${{secrets.LOCAL_PROPERTIES }}" > $GITHUB_WORKSPACE/local.properties
+```
+
+
+## Architecture
+
+This project uses a modularized approach using MVVM with Clean architecture which has the following advantages
+
+- Loose coupling between the code - The code can easily be modified without affecting any or a large part of the app's codebase thus easier to scale the application later on.
+- Easier to test code.
+- Separation of Concern - Different modules have specific responsibilities making it easier for modification and maintenance.
+
+### Modularization Structure
+
+- `core`
+ - `data`
+ - aggregates the data from the network and local database
+ - `network`
+ - handles getting data from any server/remote source
+ - `database`
+ - handles getting cached device data
+- `domain`
+ - defines the core business logic for reuse
+- `app`
+ - handles Ui logic of the app i.e navigation and use of the bottom bar.
+- `feature`
+ - `Search`
+ - handles displaying data of queried item combinations
+ - `Food Details`
+ - handles displaying all food nutritional details
+ - `Saved Food`
+ - handles displaying food saved locally
+- `testing`
+ - Encompasses the core testing functionality of the project
+
+
+
+| Modularization Graph |
+|--------------------------------------------|
+| |
+
+### Testing
+
+The app includes unit tests for all modules, Instrumented tests are ran as unit tests with the use of Roboelectric
+#### tests screenshots
+
+| Image | desc |
+|---|-----------------------------------|
+| | Unit tests for the ViewModels |
+| | Unit tests for the data layer |
+| | Unit tests for the network layer |
+| | Unit tests for the database layer |
+
+
+
+## TechStack
+### Libraries
+* Tech-stack
+ * [Kotlin](https://kotlinlang.org/) - a modern, cross-platform, statically typed, general-purpose programming language with type inference.
+ * [Coroutines](https://kotlinlang.org/docs/reference/coroutines-overview.html) - lightweight threads to perform asynchronous tasks.
+ * [Flow](https://kotlinlang.org/docs/reference/coroutines/flow.html) - a stream of data that emits multiple values sequentially.
+ * [StateFlow](https://developer.android.com/kotlin/flow/stateflow-and-sharedflow#:~:text=StateFlow%20is%20a%20state%2Dholder,property%20of%20the%20MutableStateFlow%20class.) - Flow APIs that enable flows to emit updated state and emit values to multiple consumers optimally.
+ * [Dagger Hilt](https://dagger.dev/hilt/) - a dependency injection library for Android built on top of [Dagger](https://dagger.dev/) that reduces the boilerplate of doing manual injection.
+ * [Jetpack](https://developer.android.com/jetpack)
+ * [Jetpack Compose](https://developer.android.com/jetpack/compose) - A modern toolkit for building native Android UI
+ * [Lifecycle](https://developer.android.com/topic/libraries/architecture/lifecycle) - perform actions in response to a change in the lifecycle state.
+ * [ViewModel](https://developer.android.com/topic/libraries/architecture/viewmodel) - store and manage UI-related data lifecycle in a conscious manner and survive configuration change.
+ * [Room](https://developer.android.com/training/data-storage/room) - An ORM that provides an abstraction layer over SQLite to allow fluent database access.
+ * [Timber](https://github.com/JakeWharton/timber) - a highly extensible Android logger.
+ * [Ktor](https://ktor.io/) - A pure Create asynchronous http client
+
+
+* Tests
+ * [JUnit](https://junit.org/junit4/) - a simple framework for writing repeatable tests.
+ * [MockK](https://github.com/mockk) - mocking library for Kotlin
+ * [Truth](https://github.com/agoda-com/Kakao) - A fluent assertions library for Android and Java.
+* Gradle
+ * [Gradle Kotlin DSL](https://docs.gradle.org/current/userguide/kotlin_dsl.html) - An alternative syntax for writing Gradle build scripts using Koltin.
+ * [Version Catalogs](https://developer.android.com/build/migrate-to-catalogs) - A scalable way of maintaining dependencies and plugins in a multi-module project.
+ * [Convention Plugins](https://docs.gradle.org/current/samples/sample_convention_plugins.html) - A way to encapsulate and reuse common build configuration in Gradle, see [here](https://github.com/daniel-waiguru/WeatherApp/tree/main/build-logic%2Fconvention%2Fsrc%2Fmain%2Fjava)
+ * Plugins
+ * [Ktlint](https://github.com/JLLeitschuh/ktlint-gradle) - creates convenient tasks in your Gradle project that run ktlint checks or do code auto format.
+ * [Spotless](https://github.com/diffplug/spotless) - format Java, groovy, markdown, and license headers using gradle.
+* CI/CD
+ * [GitHub Actions](https://github.com/features/actions)
+
+## ScreenShots
+## Screenshots with Descriptions in Columns
+
+| Loading | Recent Searches | Food List |
+|---|---|---|
+| | | |
+
+| Idle | Details | Error Screen |
+|---|---|--------------|
+| | |
+
+
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 3be1475..edf2ce9 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -29,6 +29,8 @@ android {
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ merges += "META-INF/LICENSE.md"
+ merges += "META-INF/LICENSE-notice.md"
}
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 382cdc6..924cec2 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,6 +2,7 @@
+
{
- Text("Saved")
+ SavedItemsScreen { foodName ->
+ navController.navigate(AppDestinations.FoodDetails(name = foodName))
+ }
}
}
}
diff --git a/app/src/main/java/com/devmike/caloriebytez/ui/navigation/CalorieBottomBar.kt b/app/src/main/java/com/devmike/caloriebytez/navigation/CalorieBottomBar.kt
similarity index 98%
rename from app/src/main/java/com/devmike/caloriebytez/ui/navigation/CalorieBottomBar.kt
rename to app/src/main/java/com/devmike/caloriebytez/navigation/CalorieBottomBar.kt
index 1765d15..504ed80 100644
--- a/app/src/main/java/com/devmike/caloriebytez/ui/navigation/CalorieBottomBar.kt
+++ b/app/src/main/java/com/devmike/caloriebytez/navigation/CalorieBottomBar.kt
@@ -1,4 +1,4 @@
-package com.devmike.caloriebytez.ui.navigation
+package com.devmike.caloriebytez.navigation
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.material3.BottomAppBar
diff --git a/app/src/main/java/com/devmike/caloriebytez/ui/navigation/TopLevelDestinations.kt b/app/src/main/java/com/devmike/caloriebytez/navigation/TopLevelDestinations.kt
similarity index 95%
rename from app/src/main/java/com/devmike/caloriebytez/ui/navigation/TopLevelDestinations.kt
rename to app/src/main/java/com/devmike/caloriebytez/navigation/TopLevelDestinations.kt
index febdff9..6e19a3c 100644
--- a/app/src/main/java/com/devmike/caloriebytez/ui/navigation/TopLevelDestinations.kt
+++ b/app/src/main/java/com/devmike/caloriebytez/navigation/TopLevelDestinations.kt
@@ -1,4 +1,4 @@
-package com.devmike.caloriebytez.ui.navigation
+package com.devmike.caloriebytez.navigation
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bookmarks
diff --git a/build.gradle.kts b/build.gradle.kts
index 311fec0..680a27f 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -11,6 +11,7 @@ plugins {
alias(libs.plugins.detekt)
alias(libs.plugins.spotless)
alias(libs.plugins.android.library) apply false
+ alias(libs.plugins.module.graph) apply true
}
subprojects {
diff --git a/core/common-ui/src/androidTest/java/com/devmike/commonui/ExampleInstrumentedTest.kt b/core/common-ui/src/androidTest/java/com/devmike/commonui/ExampleInstrumentedTest.kt
deleted file mode 100644
index 00add85..0000000
--- a/core/common-ui/src/androidTest/java/com/devmike/commonui/ExampleInstrumentedTest.kt
+++ /dev/null
@@ -1,22 +0,0 @@
-package com.devmike.commonui
-
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.platform.app.InstrumentationRegistry
-import org.junit.Assert.*
-import org.junit.Test
-import org.junit.runner.RunWith
-
-/**
- * Instrumented test, which will execute on an Android device.
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-@RunWith(AndroidJUnit4::class)
-class ExampleInstrumentedTest {
- @Test
- fun useAppContext() {
- // Context of the app under test.
- val appContext = InstrumentationRegistry.getInstrumentation().targetContext
- assertEquals("com.devmike.common_ui.test", appContext.packageName)
- }
-}
diff --git a/core/common-ui/src/main/java/com/devmike/commonui/sharedui/ErrorScreen.kt b/core/common-ui/src/main/java/com/devmike/commonui/sharedui/ErrorScreen.kt
new file mode 100644
index 0000000..58d7254
--- /dev/null
+++ b/core/common-ui/src/main/java/com/devmike/commonui/sharedui/ErrorScreen.kt
@@ -0,0 +1,69 @@
+package com.devmike.commonui.sharedui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+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.Error
+import androidx.compose.material.icons.filled.Lock
+import androidx.compose.material.icons.filled.SearchOff
+import androidx.compose.material.icons.filled.TimerOff
+import androidx.compose.material.icons.filled.Warning
+import androidx.compose.material.icons.filled.WifiOff
+import androidx.compose.material3.Button
+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.graphics.Color
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.devmike.domain.models.AppErrors
+
+@Composable
+fun ErrorScreen(
+ error: AppErrors,
+ onRetry: () -> Unit,
+) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Icon(
+ imageVector =
+ when (error) {
+ is AppErrors.NoInternet -> Icons.Default.WifiOff
+ is AppErrors.Timeout -> Icons.Default.TimerOff
+ is AppErrors.Unknown -> Icons.Default.Error
+ is AppErrors.NotFound -> Icons.Default.SearchOff
+ is AppErrors.Empty -> Icons.Default.Warning
+ is AppErrors.Unauthorized -> Icons.Default.Lock
+ },
+ contentDescription = null,
+ modifier = Modifier.size(64.dp),
+ tint = Color.Red,
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = error.message,
+ fontSize = 18.sp,
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Button(onClick = onRetry) {
+ Text(text = "Retry")
+ }
+ }
+}
diff --git a/core/common-ui/src/main/java/com/devmike/commonui/sharedui/FoodListScreen.kt b/core/common-ui/src/main/java/com/devmike/commonui/sharedui/FoodListScreen.kt
index e112407..c4f0e1d 100644
--- a/core/common-ui/src/main/java/com/devmike/commonui/sharedui/FoodListScreen.kt
+++ b/core/common-ui/src/main/java/com/devmike/commonui/sharedui/FoodListScreen.kt
@@ -29,12 +29,13 @@ import com.devmike.domain.models.CalorieModel
@Composable
fun FoodList(
+ modifier: Modifier = Modifier,
ingredients: List,
onFoodItemClicked: (name: String) -> Unit,
) {
LazyColumn(
modifier =
- Modifier
+ modifier
.fillMaxWidth()
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
diff --git a/core/common-ui/src/test/java/com/devmike/commonui/ExampleUnitTest.kt b/core/common-ui/src/test/java/com/devmike/commonui/ExampleUnitTest.kt
deleted file mode 100644
index 8be7c2d..0000000
--- a/core/common-ui/src/test/java/com/devmike/commonui/ExampleUnitTest.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-package com.devmike.commonui
-
-import org.junit.Assert.*
-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/data/build.gradle.kts b/core/data/build.gradle.kts
index 566c536..1acdd6d 100644
--- a/core/data/build.gradle.kts
+++ b/core/data/build.gradle.kts
@@ -27,4 +27,6 @@ dependencies {
api(project(":domain"))
implementation(project(":core:network"))
implementation(project(":core:database"))
+
+ testImplementation(project(":core:testing"))
}
diff --git a/core/data/src/androidTest/java/com/devmike/data/ExampleInstrumentedTest.kt b/core/data/src/androidTest/java/com/devmike/data/ExampleInstrumentedTest.kt
deleted file mode 100644
index 01fc003..0000000
--- a/core/data/src/androidTest/java/com/devmike/data/ExampleInstrumentedTest.kt
+++ /dev/null
@@ -1,22 +0,0 @@
-package com.devmike.data
-
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.platform.app.InstrumentationRegistry
-import org.junit.Assert.*
-import org.junit.Test
-import org.junit.runner.RunWith
-
-/**
- * Instrumented test, which will execute on an Android device.
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-@RunWith(AndroidJUnit4::class)
-class ExampleInstrumentedTest {
- @Test
- fun useAppContext() {
- // Context of the app under test.
- val appContext = InstrumentationRegistry.getInstrumentation().targetContext
- assertEquals("com.devmike.core.test", appContext.packageName)
- }
-}
diff --git a/core/data/src/main/java/com/devmike/data/di/DataModule.kt b/core/data/src/main/java/com/devmike/data/di/DataModule.kt
index 51148a8..a77e7b7 100644
--- a/core/data/src/main/java/com/devmike/data/di/DataModule.kt
+++ b/core/data/src/main/java/com/devmike/data/di/DataModule.kt
@@ -1,9 +1,9 @@
package com.devmike.data.di
-import com.devmike.data.repository.CaloriesRepository
import com.devmike.data.repository.CaloriesRepositoryImpl
-import com.devmike.data.repository.RecentSearchesRepository
import com.devmike.data.repository.RecentSearchesRepositoryImpl
+import com.devmike.domain.repositories.CaloriesRepository
+import com.devmike.domain.repositories.RecentSearchesRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
diff --git a/core/data/src/main/java/com/devmike/data/repository/CaloriesRepositoryImpl.kt b/core/data/src/main/java/com/devmike/data/repository/CaloriesRepositoryImpl.kt
index b209157..0c151a8 100644
--- a/core/data/src/main/java/com/devmike/data/repository/CaloriesRepositoryImpl.kt
+++ b/core/data/src/main/java/com/devmike/data/repository/CaloriesRepositoryImpl.kt
@@ -9,7 +9,10 @@ import com.devmike.database.entities.RecentSearchEntity
import com.devmike.database.entities.SearchQueryCalorieCrossRef
import com.devmike.domain.models.AppErrors
import com.devmike.domain.models.CalorieModel
+import com.devmike.domain.repositories.CaloriesRepository
import com.devmike.network.datasource.CalorieNetworkSource
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
import java.time.Instant
import javax.inject.Inject
@@ -55,7 +58,11 @@ class CaloriesRepositoryImpl
return calorieDao.getCalorieById(name)?.toCalorieModel()
}
- private suspend fun saveNetworkCalorieDb(
+ override fun getAllCalories(): Flow> = calorieDao.getAllCalories().map { calorieEntities ->
+ calorieEntities.map { it.toCalorieModel() }
+ }
+
+ private suspend fun saveNetworkCalorieDb(
query: String,
calories: List,
) {
diff --git a/core/data/src/main/java/com/devmike/data/repository/RecentSearchesRepositoryImpl.kt b/core/data/src/main/java/com/devmike/data/repository/RecentSearchesRepositoryImpl.kt
index 14de4a2..48f35d4 100644
--- a/core/data/src/main/java/com/devmike/data/repository/RecentSearchesRepositoryImpl.kt
+++ b/core/data/src/main/java/com/devmike/data/repository/RecentSearchesRepositoryImpl.kt
@@ -1,18 +1,21 @@
package com.devmike.data.repository
import com.devmike.database.dao.SearchQueryDao
+import com.devmike.domain.repositories.RecentSearchesRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
-class RecentSearchesRepositoryImpl @Inject constructor(
- private val searchQueryDao: SearchQueryDao
-) : RecentSearchesRepository {
- override fun getRecentSearches(limit: Int): Flow> {
- return searchQueryDao.getRecentQueries(limit = limit).map {
- it.map { recentSearchEntity ->
- recentSearchEntity.queryString
+class RecentSearchesRepositoryImpl
+ @Inject
+ constructor(
+ private val searchQueryDao: SearchQueryDao,
+ ) : RecentSearchesRepository {
+ override fun getRecentSearches(limit: Int): Flow> {
+ return searchQueryDao.getRecentQueries(limit = limit).map {
+ it.map { recentSearchEntity ->
+ recentSearchEntity.queryString
+ }
}
}
}
-}
diff --git a/core/data/src/test/java/com/devmike/data/CaloriesRepositoryImplTest.kt b/core/data/src/test/java/com/devmike/data/CaloriesRepositoryImplTest.kt
new file mode 100644
index 0000000..a89942c
--- /dev/null
+++ b/core/data/src/test/java/com/devmike/data/CaloriesRepositoryImplTest.kt
@@ -0,0 +1,131 @@
+package com.devmike.data
+
+import com.devmike.data.mappers.toCalorieModel
+import com.devmike.data.repository.CaloriesRepositoryImpl
+import com.devmike.database.dao.CalorieDao
+import com.devmike.database.dao.CrossRefDao
+import com.devmike.database.dao.SearchQueryDao
+import com.devmike.database.entities.SearchQueryWithCalories
+import com.devmike.domain.models.AppErrors
+import com.devmike.network.datasource.CalorieNetworkSource
+import com.devmike.network.model.CalorieResponse
+import com.devmike.sampleData.SampleModels
+import com.devmike.testing.sampleData.SampleDto
+import com.devmike.testing.sampleData.SampleEntities
+import com.devmike.testing.sampleData.SampleSearchEntity
+import com.devmike.testing.sampleData.SampleSearchWithCalorie
+import com.google.common.truth.Truth
+import io.mockk.called
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+
+class CaloriesRepositoryImplTest {
+ private lateinit var repository: CaloriesRepositoryImpl
+ private val mockNetworkSource: CalorieNetworkSource = mockk()
+ private val mockCalorieDao: CalorieDao = mockk()
+ private val mockSearchQueryDao: SearchQueryDao = mockk()
+ private val mockCrossRefDao: CrossRefDao = mockk()
+
+ @Before
+ fun setup() {
+ repository =
+ CaloriesRepositoryImpl(
+ mockNetworkSource,
+ mockCalorieDao,
+ mockSearchQueryDao,
+ mockCrossRefDao,
+ )
+ }
+
+ @Test
+ fun` getCalories cachedQueryExists returnsSuccessWithCachedData`() =
+ runTest {
+ val query = SampleSearchEntity.tomatowithpizza.queryString
+ val cachedCalories =
+ listOf(
+ SampleEntities.tomato,
+ SampleEntities.onion,
+ )
+ val cachedSearchQuery =
+ SearchQueryWithCalories(
+ searchQuery = SampleSearchEntity.tomatowithpizza,
+ calories = cachedCalories,
+ )
+
+ coEvery { mockSearchQueryDao.getSearchQueryWithCalories(query) } returns listOf(cachedSearchQuery)
+
+ coEvery {
+ mockSearchQueryDao.getSearchQueryWithCalories("mikke")
+ } returns emptyList()
+ val flowrst = mockSearchQueryDao.getSearchQueryWithCalories("mikke")
+
+ print("the flow result is $flowrst")
+ coVerify {
+ mockNetworkSource wasNot called
+ }
+ coVerify {
+ mockSearchQueryDao.getSearchQueryWithCalories("mikke")
+ }
+
+ val result = repository.getCalories(query)
+
+ Truth.assertThat(result.isSuccess).isTrue()
+
+ Truth.assertThat(result.getOrNull()).containsAnyIn(listOf(SampleModels.tomatomodel))
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @Test
+ fun `getCalories networkRequestSuccessful returns with data`() =
+ runTest {
+ val query = SampleSearchEntity.pizzaonion.queryString
+ val networkCalories =
+ listOf(
+ SampleDto.onionDTO,
+ SampleDto.pizzaDTO,
+ )
+
+ coEvery {
+ mockNetworkSource.getCalorieInformation(query)
+ } returns Result.success(CalorieResponse(networkCalories))
+
+ coEvery {
+ mockCalorieDao.insertAll(any())
+ }
+
+ coEvery {
+ mockSearchQueryDao.getSearchQueryWithCalories(query)
+ } returns listOf(SampleSearchWithCalorie.pizzaonion)
+ coEvery {
+ mockSearchQueryDao.insert(SampleSearchEntity.pizzaonion)
+ } returns 9999L
+
+ coEvery {
+ mockCrossRefDao.insert(any())
+ }
+
+ val result = repository.getCalories(query)
+
+ Truth.assertThat(result.getOrNull()).containsAnyIn(networkCalories.map { it.toCalorieModel() })
+ }
+
+ @Test
+ fun ` getCalories networkRequestFailed returnsFailure`() =
+ runTest {
+ val query = "Invalid query"
+ val error = AppErrors.Unauthorized()
+
+ coEvery { mockNetworkSource.getCalorieInformation(query) } returns Result.failure(error)
+ coEvery { mockSearchQueryDao.getSearchQueryWithCalories(query) } returns emptyList()
+
+ val result = repository.getCalories(query)
+
+ Truth.assertThat(result.isFailure).isTrue()
+ Truth.assertThat(result.exceptionOrNull()).isInstanceOf(AppErrors.Unauthorized::class.java)
+ }
+}
diff --git a/core/data/src/test/java/com/devmike/data/ExampleUnitTest.kt b/core/data/src/test/java/com/devmike/data/ExampleUnitTest.kt
deleted file mode 100644
index 14a4e7e..0000000
--- a/core/data/src/test/java/com/devmike/data/ExampleUnitTest.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-package com.devmike.data
-
-import org.junit.Assert.*
-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/data/src/test/java/com/devmike/data/RecentSearchesRepositoryImplTest.kt b/core/data/src/test/java/com/devmike/data/RecentSearchesRepositoryImplTest.kt
new file mode 100644
index 0000000..1f4b4f9
--- /dev/null
+++ b/core/data/src/test/java/com/devmike/data/RecentSearchesRepositoryImplTest.kt
@@ -0,0 +1,55 @@
+package com.devmike.data
+
+import com.devmike.data.repository.RecentSearchesRepositoryImpl
+import com.devmike.database.dao.SearchQueryDao
+import com.devmike.database.entities.RecentSearchEntity
+import com.google.common.truth.Truth
+import io.mockk.coEvery
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import java.time.Instant
+
+@ExperimentalCoroutinesApi
+class RecentSearchesRepositoryImplTest {
+ private lateinit var repository: RecentSearchesRepositoryImpl
+ private val mockSearchQueryDao: SearchQueryDao = mockk()
+
+ @Before
+ fun setUp() {
+ repository = RecentSearchesRepositoryImpl(mockSearchQueryDao)
+ }
+
+ @Test
+ fun `getRecentSearches should return recent search queries`() =
+ runTest {
+ val limit = 5
+ val recentSearchEntities =
+ listOf(
+ RecentSearchEntity(queryString = "tomato and cabbage", queriedDate = Instant.now()),
+ RecentSearchEntity(queryString = "pineapple on pizza", queriedDate = Instant.now()),
+ )
+ coEvery { mockSearchQueryDao.getRecentQueries(limit) } returns flowOf(recentSearchEntities)
+
+ val result = repository.getRecentSearches(limit).toList()
+
+ Truth.assertThat(result).hasSize(1)
+ Truth.assertThat(result.first()).containsExactly("tomato and cabbage", "pineapple on pizza")
+ }
+
+ @Test
+ fun `getRecentSearches should return empty list when no recent searches`() =
+ runTest {
+ val limit = 5
+ coEvery { mockSearchQueryDao.getRecentQueries(limit) } returns flowOf(emptyList())
+
+ val result = repository.getRecentSearches(limit).toList()
+
+ Truth.assertThat(result).hasSize(1)
+ Truth.assertThat(result.first()).isEmpty()
+ }
+}
diff --git a/core/data/src/test/java/com/devmike/sampleData/SampleDto.kt b/core/data/src/test/java/com/devmike/sampleData/SampleDto.kt
new file mode 100644
index 0000000..c662bde
--- /dev/null
+++ b/core/data/src/test/java/com/devmike/sampleData/SampleDto.kt
@@ -0,0 +1,53 @@
+package com.devmike.testing.sampleData
+
+import com.devmike.network.model.CalorieDTO
+
+object SampleDto {
+ val onionDTO =
+ CalorieDTO(
+ calories = 126.7,
+ carbohydratesTotalG = 28.6,
+ cholesterolMg = 0.0,
+ fatSaturatedG = 0.1,
+ fatTotalG = 0.5,
+ fiberG = 4.0,
+ name = "onion",
+ potassiumMg = 99.0,
+ proteinG = 3.9,
+ servingSizeG = 283.0,
+ sodiumMg = 8.0,
+ sugarG = 13.3,
+ )
+
+ val tomatoDTO =
+ CalorieDTO(
+ calories = 18.2,
+ carbohydratesTotalG = 3.9,
+ cholesterolMg = 0.0,
+ fatSaturatedG = 0.0,
+ fatTotalG = 0.2,
+ fiberG = 1.2,
+ name = "tomato",
+ potassiumMg = 23.0,
+ proteinG = 0.9,
+ servingSizeG = 100.0,
+ sodiumMg = 4.0,
+ sugarG = 2.6,
+ )
+
+ val pizzaDTO =
+ CalorieDTO(
+ calories = 262.9,
+ carbohydratesTotalG = 32.9,
+ cholesterolMg = 16.0,
+ fatSaturatedG = 4.5,
+ fatTotalG = 9.8,
+ fiberG = 2.3,
+ name = "pizza",
+ potassiumMg = 217.0,
+ proteinG = 11.4,
+ servingSizeG = 100.0,
+ sodiumMg = 587.0,
+ sugarG = 3.6,
+ )
+}
diff --git a/core/data/src/test/java/com/devmike/sampleData/SampleEntities.kt b/core/data/src/test/java/com/devmike/sampleData/SampleEntities.kt
new file mode 100644
index 0000000..ee7a6bf
--- /dev/null
+++ b/core/data/src/test/java/com/devmike/sampleData/SampleEntities.kt
@@ -0,0 +1,86 @@
+package com.devmike.testing.sampleData
+
+import com.devmike.database.entities.CalorieEntity
+import com.devmike.database.entities.RecentSearchEntity
+import com.devmike.database.entities.SearchQueryWithCalories
+import java.time.Instant
+
+object SampleEntities {
+ val onion =
+ CalorieEntity(
+ calories = 126.7,
+ carbohydratesTotalG = 28.6,
+ cholesterolMg = 0.0,
+ fatSaturatedG = 0.1,
+ fatTotalG = 0.5,
+ fiberG = 4.0,
+ name = "onion",
+ potassiumMg = 99.0,
+ proteinG = 3.9,
+ servingSizeG = 283.0,
+ sodiumMg = 8.0,
+ sugarG = 13.3,
+ )
+
+ val tomato =
+ CalorieEntity(
+ calories = 18.2,
+ carbohydratesTotalG = 3.9,
+ cholesterolMg = 0.0,
+ fatSaturatedG = 0.0,
+ fatTotalG = 0.2,
+ fiberG = 1.2,
+ name = "tomato",
+ potassiumMg = 23.0,
+ proteinG = 0.9,
+ servingSizeG = 100.0,
+ sodiumMg = 4.0,
+ sugarG = 2.6,
+ )
+
+ val pizza =
+ CalorieEntity(
+ calories = 262.9,
+ carbohydratesTotalG = 32.9,
+ cholesterolMg = 16.0,
+ fatSaturatedG = 4.5,
+ fatTotalG = 9.8,
+ fiberG = 2.3,
+ name = "pizza",
+ potassiumMg = 217.0,
+ proteinG = 11.4,
+ servingSizeG = 100.0,
+ sodiumMg = 587.0,
+ sugarG = 3.6,
+ )
+}
+
+object SampleSearchEntity {
+ val fruits = RecentSearchEntity("Fruits", Instant.now())
+ val vegetablesonion = RecentSearchEntity("Vegetables with onion", Instant.now())
+ val tomatowithpizza = RecentSearchEntity("Tomato with pizza", Instant.now())
+ val pizzaonion = RecentSearchEntity("Pizza on onion", Instant.now())
+ val onion = RecentSearchEntity("Onion", Instant.now())
+}
+
+object SampleSearchWithCalorie {
+ val tomatopizza =
+ SearchQueryWithCalories(
+ searchQuery = SampleSearchEntity.tomatowithpizza,
+ calories =
+ listOf(
+ SampleEntities.tomato,
+ SampleEntities.pizza,
+ ),
+ )
+
+ val pizzaonion =
+ SearchQueryWithCalories(
+ searchQuery = SampleSearchEntity.pizzaonion,
+ calories =
+ listOf(
+ SampleEntities.onion,
+ SampleEntities.pizza,
+ ),
+ )
+}
diff --git a/core/data/src/test/java/com/devmike/sampleData/SampleModels.kt b/core/data/src/test/java/com/devmike/sampleData/SampleModels.kt
new file mode 100644
index 0000000..621c980
--- /dev/null
+++ b/core/data/src/test/java/com/devmike/sampleData/SampleModels.kt
@@ -0,0 +1,53 @@
+package com.devmike.sampleData
+
+import com.devmike.domain.models.CalorieModel
+
+object SampleModels {
+ val onionmodel =
+ CalorieModel(
+ calories = 126.7,
+ carbohydratesTotalG = 28.6,
+ cholesterolMg = 0.0,
+ fatSaturatedG = 0.1,
+ fatTotalG = 0.5,
+ fiberG = 4.0,
+ name = "onion",
+ potassiumMg = 99.0,
+ proteinG = 3.9,
+ servingSizeinGrams = 283.0,
+ sodiumMg = 8.0,
+ sugarG = 13.3,
+ )
+
+ val tomatomodel =
+ CalorieModel(
+ calories = 18.2,
+ carbohydratesTotalG = 3.9,
+ cholesterolMg = 0.0,
+ fatSaturatedG = 0.0,
+ fatTotalG = 0.2,
+ fiberG = 1.2,
+ name = "tomato",
+ potassiumMg = 23.0,
+ proteinG = 0.9,
+ servingSizeinGrams = 100.0,
+ sodiumMg = 4.0,
+ sugarG = 2.6,
+ )
+
+ val pizzamodel =
+ CalorieModel(
+ calories = 262.9,
+ carbohydratesTotalG = 32.9,
+ cholesterolMg = 16.0,
+ fatSaturatedG = 4.5,
+ fatTotalG = 9.8,
+ fiberG = 2.3,
+ name = "pizza",
+ potassiumMg = 217.0,
+ proteinG = 11.4,
+ servingSizeinGrams = 100.0,
+ sodiumMg = 587.0,
+ sugarG = 3.6,
+ )
+}
diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts
index 73f67f3..737cbde 100644
--- a/core/database/build.gradle.kts
+++ b/core/database/build.gradle.kts
@@ -33,5 +33,8 @@ dependencies {
implementation(libs.room.runtime)
implementation(libs.room.ktx)
implementation(libs.room.testing)
+ implementation(libs.androidx.test.ext)
ksp(libs.room.compiler)
+
+ testApi(project(":core:testing"))
}
diff --git a/core/database/src/androidTest/java/com/devmike/database/ExampleInstrumentedTest.kt b/core/database/src/androidTest/java/com/devmike/database/ExampleInstrumentedTest.kt
deleted file mode 100644
index 39b6501..0000000
--- a/core/database/src/androidTest/java/com/devmike/database/ExampleInstrumentedTest.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-package com.devmike.database
-
-import androidx.test.platform.app.InstrumentationRegistry
-import androidx.test.ext.junit.runners.AndroidJUnit4
-
-import org.junit.Test
-import org.junit.runner.RunWith
-
-import org.junit.Assert.*
-
-/**
- * Instrumented test, which will execute on an Android device.
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-@RunWith(AndroidJUnit4::class)
-class ExampleInstrumentedTest {
- @Test
- fun useAppContext() {
- // Context of the app under test.
- val appContext = InstrumentationRegistry.getInstrumentation().targetContext
- assertEquals("com.devmike.database.test", appContext.packageName)
- }
-}
diff --git a/core/database/src/main/java/com/devmike/database/dao/CalorieDao.kt b/core/database/src/main/java/com/devmike/database/dao/CalorieDao.kt
index 9c7d3c8..98d0e69 100644
--- a/core/database/src/main/java/com/devmike/database/dao/CalorieDao.kt
+++ b/core/database/src/main/java/com/devmike/database/dao/CalorieDao.kt
@@ -5,6 +5,7 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.devmike.database.entities.CalorieEntity
+import kotlinx.coroutines.flow.Flow
@Dao
interface CalorieDao {
@@ -16,4 +17,10 @@ interface CalorieDao {
@Query("SELECT * FROM calorie_table WHERE name = :name")
suspend fun getCalorieById(name: String): CalorieEntity?
+
+ @Query("Select * from calorie_table")
+ fun getAllCalories(): Flow>
+
+ @Query("DELETE FROM calorie_table")
+ suspend fun deleteAll()
}
diff --git a/core/database/src/main/java/com/devmike/database/dao/CrossRefDao.kt b/core/database/src/main/java/com/devmike/database/dao/CrossRefDao.kt
index 499b806..f8b6311 100644
--- a/core/database/src/main/java/com/devmike/database/dao/CrossRefDao.kt
+++ b/core/database/src/main/java/com/devmike/database/dao/CrossRefDao.kt
@@ -14,8 +14,8 @@ interface CrossRefDao {
@Query("DELETE FROM search_query_calorie_cross_ref WHERE queryString = :searchId")
suspend fun deleteBySearchId(searchId: String)
- @Query("DELETE FROM search_query_calorie_cross_ref WHERE name = :calorieId")
- suspend fun deleteByCalorieId(calorieId: Long)
+ @Query("DELETE FROM search_query_calorie_cross_ref WHERE name = :caloriename")
+ suspend fun deleteByCalorieId(caloriename: String)
@Query("DELETE FROM search_query_calorie_cross_ref")
suspend fun deleteAll()
diff --git a/core/database/src/test/java/com/devmike/database/CalorieDaoTest.kt b/core/database/src/test/java/com/devmike/database/CalorieDaoTest.kt
new file mode 100644
index 0000000..0422f38
--- /dev/null
+++ b/core/database/src/test/java/com/devmike/database/CalorieDaoTest.kt
@@ -0,0 +1,73 @@
+package com.devmike.database
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.devmike.database.baseDb.BaseDbTest
+import com.devmike.database.dao.CalorieDao
+import com.devmike.database.util.SampleData.onion
+import com.devmike.database.util.SampleData.tomato
+import junit.framework.TestCase.assertEquals
+import junit.framework.TestCase.assertNotNull
+import junit.framework.TestCase.assertNull
+import junit.framework.TestCase.assertTrue
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class CalorieDaoTest : BaseDbTest() {
+ private lateinit var calorieDao: CalorieDao
+
+ @Before
+ fun setUp() {
+ calorieDao = db.calorieDao()
+ }
+
+ @Test
+ fun `insert calorie entity`() {
+ val calorieEntity = onion
+ runTest {
+ calorieDao.insert(calorieEntity)
+ val insertedCalorie = calorieDao.getCalorieById(calorieEntity.name)
+ assertNotNull(insertedCalorie)
+ assertEquals(calorieEntity, insertedCalorie)
+ }
+ }
+
+ @Test
+ fun `insert all calorie entities`() {
+ val calories = listOf(onion, tomato)
+ runTest {
+ calorieDao.insertAll(calories)
+ val allCalories = calorieDao.getAllCalories().first()
+ assertEquals(calories.size, allCalories.size)
+ assertTrue(allCalories.contains(onion))
+ assertTrue(allCalories.contains(tomato))
+ }
+ }
+
+ @Test
+ fun `get calorie entity by name - not found`() {
+ val unknownName = "unknown"
+ runTest {
+ val calorie = calorieDao.getCalorieById(unknownName)
+ assertNull(calorie)
+ }
+ }
+
+ @Test
+ fun `delete all calorie entities`() {
+ val calories = listOf(onion, tomato)
+ runTest {
+ calorieDao.insertAll(calories)
+
+ val insertedCalorie = calorieDao.getCalorieById(calories.first().name)
+ assertEquals(onion, insertedCalorie)
+ calorieDao.deleteAll()
+
+ val allCalories = calorieDao.getAllCalories().first()
+ assertEquals(0, allCalories.size)
+ }
+ }
+}
diff --git a/core/database/src/test/java/com/devmike/database/SearchEntityCrossrefDaoTest.kt b/core/database/src/test/java/com/devmike/database/SearchEntityCrossrefDaoTest.kt
new file mode 100644
index 0000000..cd5d171
--- /dev/null
+++ b/core/database/src/test/java/com/devmike/database/SearchEntityCrossrefDaoTest.kt
@@ -0,0 +1,89 @@
+package com.devmike.database
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.devmike.database.baseDb.BaseDbTest
+import com.devmike.database.dao.CalorieDao
+import com.devmike.database.dao.CrossRefDao
+import com.devmike.database.dao.SearchQueryDao
+import com.devmike.database.entities.RecentSearchEntity
+import com.devmike.database.entities.SearchQueryCalorieCrossRef
+import com.devmike.database.util.SampleData.onion
+import com.devmike.database.util.SampleData.tomato
+import com.google.common.truth.Truth
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.time.Instant
+
+@RunWith(AndroidJUnit4::class)
+class SearchEntityCrossrefDaoTest : BaseDbTest() {
+ private lateinit var calorieDao: CalorieDao
+ private lateinit var crossRefDao: CrossRefDao
+ private lateinit var searchQueryDao: SearchQueryDao
+
+ private val searchQuery = "Onion with Tomatoes"
+
+ @Before
+ fun setUp() {
+ calorieDao = db.calorieDao()
+ searchQueryDao = db.searchQueryDao()
+ crossRefDao = db.crossRefDao()
+ }
+
+ @Test
+ fun `insertCrossRef works`() =
+ runTest {
+ calorieDao.insertAll(listOf(onion, tomato))
+ crossRefDao.insert(SearchQueryCalorieCrossRef(searchQuery, onion.name))
+ crossRefDao.insert(SearchQueryCalorieCrossRef(searchQuery, tomato.name))
+ searchQueryDao.insert(RecentSearchEntity(searchQuery, Instant.now()))
+
+ val searchQueryWithCalories = db.searchQueryDao().getSearchQueryWithCalories(searchQuery)
+ val searchResults = searchQueryWithCalories.map { it.calories }.flatten()
+
+ Truth.assertThat(searchResults).containsAnyOf(onion, tomato)
+ }
+
+ @Test
+ fun `deleteBySearchId works`() =
+ runTest {
+ calorieDao.insertAll(listOf(onion, tomato))
+ crossRefDao.insert(SearchQueryCalorieCrossRef(searchQuery, onion.name))
+ crossRefDao.insert(SearchQueryCalorieCrossRef(searchQuery, tomato.name))
+
+ crossRefDao.deleteBySearchId(searchQuery)
+
+ val searchQueryWithCalories = db.searchQueryDao().getSearchQueryWithCalories(searchQuery)
+
+ Truth.assertThat(searchQueryWithCalories).isEmpty()
+ }
+
+ @Test
+ fun `deleteByCaloriename works`() =
+ runTest {
+ calorieDao.insertAll(listOf(onion, tomato))
+ crossRefDao.insert(SearchQueryCalorieCrossRef(searchQuery, onion.name))
+ crossRefDao.insert(SearchQueryCalorieCrossRef(searchQuery, tomato.name))
+
+ crossRefDao.deleteByCalorieId(onion.name)
+
+ val searchQueryWithCalories = db.searchQueryDao().getSearchQueryWithCalories(searchQuery)
+
+ Truth.assertThat(searchQueryWithCalories).doesNotContain(tomato)
+ }
+
+ @Test
+ fun ` deleteAll works`() =
+ runTest {
+ calorieDao.insertAll(listOf(onion, tomato))
+ crossRefDao.insert(SearchQueryCalorieCrossRef(searchQuery, onion.name))
+ crossRefDao.insert(SearchQueryCalorieCrossRef(searchQuery, tomato.name))
+
+ crossRefDao.deleteAll()
+
+ val searchQueryWithCalories = db.searchQueryDao().getSearchQueryWithCalories(searchQuery)
+
+ Truth.assertThat(searchQueryWithCalories).isEmpty()
+ }
+}
diff --git a/core/database/src/test/java/com/devmike/database/SearchQueryDaoTest.kt b/core/database/src/test/java/com/devmike/database/SearchQueryDaoTest.kt
new file mode 100644
index 0000000..40cfd27
--- /dev/null
+++ b/core/database/src/test/java/com/devmike/database/SearchQueryDaoTest.kt
@@ -0,0 +1,54 @@
+package com.devmike.database
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.devmike.database.baseDb.BaseDbTest
+import com.devmike.database.dao.SearchQueryDao
+import com.devmike.database.entities.RecentSearchEntity
+import junit.framework.TestCase.assertEquals
+import junit.framework.TestCase.assertTrue
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.time.Instant
+
+@RunWith(AndroidJUnit4::class)
+class SearchQueryDaoTest : BaseDbTest() {
+ private lateinit var searchQueryDao: SearchQueryDao
+
+ @Before
+ fun setUp() {
+ searchQueryDao = db.searchQueryDao()
+ }
+
+ @Test
+ fun `insert recent search entity`() {
+ val query = "apple"
+ val searchEntity = RecentSearchEntity(query, Instant.now())
+ runTest {
+ val insertedRowId = searchQueryDao.insert(searchEntity)
+ assertTrue(insertedRowId > 0L)
+ val retrievedQuery = searchQueryDao.getSearchQueryWithCalories(query)
+ assertEquals(1, retrievedQuery.size)
+
+ val retrievedSearch = retrievedQuery.find { it.searchQuery.queryString == query }?.searchQuery?.queryString
+ assertEquals(query, retrievedSearch)
+ }
+ }
+
+ @Test
+ fun `clear recent search queries`() {
+ val query = "pizza"
+
+ runTest {
+ searchQueryDao.insert(RecentSearchEntity(query, Instant.now()))
+ val initialCount = searchQueryDao.getRecentQueries(Int.MAX_VALUE).first()
+ assertTrue(initialCount.isNotEmpty())
+
+ searchQueryDao.clearRecentSearchQueries()
+ val clearedCount = searchQueryDao.getRecentQueries(Int.MAX_VALUE).first()
+ assertTrue(clearedCount.isEmpty())
+ }
+ }
+}
diff --git a/core/database/src/test/java/com/devmike/database/baseDb/BaseDbTest.kt b/core/database/src/test/java/com/devmike/database/baseDb/BaseDbTest.kt
new file mode 100644
index 0000000..21812ee
--- /dev/null
+++ b/core/database/src/test/java/com/devmike/database/baseDb/BaseDbTest.kt
@@ -0,0 +1,38 @@
+package com.devmike.database.baseDb
+
+import android.content.Context
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import androidx.room.Room
+import androidx.test.core.app.ApplicationProvider
+import com.devmike.database.CalorieDatabase
+import com.devmike.database.util.MainCoroutineRule
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+
+abstract class BaseDbTest {
+ @get:Rule
+ val rule = InstantTaskExecutorRule()
+
+ @get:Rule
+ val testCoroutineRule = MainCoroutineRule()
+ lateinit var db: CalorieDatabase
+
+ @Before
+ open fun setup() {
+ val context = ApplicationProvider.getApplicationContext()
+ db =
+ Room.inMemoryDatabaseBuilder(
+ context,
+ CalorieDatabase::class.java,
+ )
+ .allowMainThreadQueries()
+ .build()
+ }
+
+ @After
+ open fun tearDownDb() {
+ db.clearAllTables()
+ db.close()
+ }
+}
diff --git a/core/database/src/test/java/com/devmike/database/util/MainCoroutineRule.kt b/core/database/src/test/java/com/devmike/database/util/MainCoroutineRule.kt
new file mode 100644
index 0000000..554df52
--- /dev/null
+++ b/core/database/src/test/java/com/devmike/database/util/MainCoroutineRule.kt
@@ -0,0 +1,26 @@
+package com.devmike.database.util
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestDispatcher
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import org.junit.rules.TestWatcher
+import org.junit.runner.Description
+
+class MainCoroutineRule
+ @OptIn(ExperimentalCoroutinesApi::class)
+ constructor(
+ val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
+ ) : TestWatcher() {
+ @OptIn(ExperimentalCoroutinesApi::class)
+ override fun starting(description: Description) {
+ Dispatchers.setMain(testDispatcher)
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ override fun finished(description: Description) {
+ Dispatchers.resetMain()
+ }
+ }
diff --git a/core/database/src/test/java/com/devmike/database/util/SampleData.kt b/core/database/src/test/java/com/devmike/database/util/SampleData.kt
new file mode 100644
index 0000000..c3c83fc
--- /dev/null
+++ b/core/database/src/test/java/com/devmike/database/util/SampleData.kt
@@ -0,0 +1,63 @@
+package com.devmike.database.util
+
+import com.devmike.database.entities.CalorieEntity
+import com.devmike.database.entities.RecentSearchEntity
+import java.time.Instant
+
+object SampleData {
+ val onion =
+ CalorieEntity(
+ calories = 126.7,
+ carbohydratesTotalG = 28.6,
+ cholesterolMg = 0.0,
+ fatSaturatedG = 0.1,
+ fatTotalG = 0.5,
+ fiberG = 4.0,
+ name = "onion",
+ potassiumMg = 99.0,
+ proteinG = 3.9,
+ servingSizeG = 283.0,
+ sodiumMg = 8.0,
+ sugarG = 13.3,
+ )
+
+ val tomato =
+ CalorieEntity(
+ calories = 18.2,
+ carbohydratesTotalG = 3.9,
+ cholesterolMg = 0.0,
+ fatSaturatedG = 0.0,
+ fatTotalG = 0.2,
+ fiberG = 1.2,
+ name = "tomato",
+ potassiumMg = 23.0,
+ proteinG = 0.9,
+ servingSizeG = 100.0,
+ sodiumMg = 4.0,
+ sugarG = 2.6,
+ )
+
+ val pizza =
+ CalorieEntity(
+ calories = 262.9,
+ carbohydratesTotalG = 32.9,
+ cholesterolMg = 16.0,
+ fatSaturatedG = 4.5,
+ fatTotalG = 9.8,
+ fiberG = 2.3,
+ name = "pizza",
+ potassiumMg = 217.0,
+ proteinG = 11.4,
+ servingSizeG = 100.0,
+ sodiumMg = 587.0,
+ sugarG = 3.6,
+ )
+}
+
+object SampleSearchEntity {
+ val fruits = RecentSearchEntity("Fruits", Instant.now())
+ val vegetables = RecentSearchEntity("Vegetables", Instant.now())
+ val tomato = RecentSearchEntity("Tomato", Instant.now())
+ val pizza = RecentSearchEntity("Pizza", Instant.now())
+ val onion = RecentSearchEntity("Onion", Instant.now())
+}
diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts
index b196d25..280b0dc 100644
--- a/core/network/build.gradle.kts
+++ b/core/network/build.gradle.kts
@@ -44,5 +44,5 @@ dependencies {
implementation(libs.bundles.ktor)
implementation(libs.kotlinx.serialization.json)
api(project(":domain"))
- testImplementation(libs.ktor.mock)
+ testApi(project(":core:testing"))
}
diff --git a/core/network/src/androidTest/java/com/devmike/network/ExampleInstrumentedTest.kt b/core/network/src/androidTest/java/com/devmike/network/ExampleInstrumentedTest.kt
deleted file mode 100644
index bd100f1..0000000
--- a/core/network/src/androidTest/java/com/devmike/network/ExampleInstrumentedTest.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-package com.devmike.network
-
-import androidx.test.platform.app.InstrumentationRegistry
-import androidx.test.ext.junit.runners.AndroidJUnit4
-
-import org.junit.Test
-import org.junit.runner.RunWith
-
-import org.junit.Assert.*
-
-/**
- * Instrumented test, which will execute on an Android device.
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-@RunWith(AndroidJUnit4::class)
-class ExampleInstrumentedTest {
- @Test
- fun useAppContext() {
- // Context of the app under test.
- val appContext = InstrumentationRegistry.getInstrumentation().targetContext
- assertEquals("com.devmike.network.test", appContext.packageName)
- }
-}
diff --git a/core/network/src/test/java/com/devmike/network/CalorieClientApiTest.kt b/core/network/src/test/java/com/devmike/network/CalorieClientApiTest.kt
new file mode 100644
index 0000000..2beaece
--- /dev/null
+++ b/core/network/src/test/java/com/devmike/network/CalorieClientApiTest.kt
@@ -0,0 +1,107 @@
+package com.devmike.network
+
+import com.devmike.domain.models.AppErrors
+import com.devmike.network.datasource.CalorieNetworkSourceImpl
+import com.google.common.truth.Truth
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.mock.MockEngine
+import io.ktor.client.engine.mock.respond
+import io.ktor.client.plugins.DefaultRequest
+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.client.request.get
+import io.ktor.client.request.header
+import io.ktor.http.ContentType
+import io.ktor.http.HttpHeaders
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.headersOf
+import io.ktor.serialization.kotlinx.json.json
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class CalorieClientApiTest {
+ @Test
+ fun `getCalorie response code 200 deserializes data correctly `() =
+ runTest {
+ val mockEngine =
+ MockEngine {
+ respond(
+ mockValidJsonResponse,
+ headers = headersOf(HttpHeaders.ContentType, "application/json"),
+ )
+ }
+
+ val httpClient =
+ HttpClient(mockEngine) {
+ install(ContentNegotiation) {
+ json()
+ }
+ install(DefaultRequest) {
+ header(HttpHeaders.ContentType, ContentType.Application.Json)
+ }
+ }
+
+ val netWorkRepo = CalorieNetworkSourceImpl(httpClient)
+ val result = netWorkRepo.getCalorieInformation("pizza")
+
+ Truth.assertThat(result.getOrNull()?.items).containsAnyOf(SampleDTO.tomatoDTO, SampleDTO.onionDTO)
+ }
+
+ @Test
+ fun `get client returns failure for errors`() =
+ runTest {
+ val mockEngine =
+ MockEngine {
+ respond(
+ """
+ invalid data
+ """.trimIndent(),
+ headers = headersOf(HttpHeaders.ContentType, "application/json"),
+ status = HttpStatusCode.BadRequest,
+ )
+ }
+
+ val httpClient =
+ HttpClient(mockEngine) {
+ install(ContentNegotiation) {
+ json()
+ }
+ install(DefaultRequest) {
+ header(HttpHeaders.ContentType, ContentType.Application.Json)
+ }
+ }
+
+ val netWorkRepo = CalorieNetworkSourceImpl(httpClient)
+ val result = netWorkRepo.getCalorieInformation("pizza")
+
+ Truth.assertThat(result.getOrNull()).isNull()
+ }
+
+ @Test
+ fun ` given a response 404 the client returns AppErrors not found`() =
+ runTest {
+ val mockEngine =
+ MockEngine {
+ respond(
+ """
+ invalid data
+ """.trimIndent(),
+ headers = headersOf(HttpHeaders.ContentType, "application/json"),
+ status = HttpStatusCode.NotFound,
+ )
+ }
+
+ val httpClient =
+ HttpClient(mockEngine) {
+ install(ContentNegotiation) {
+ json()
+ }
+ install(DefaultRequest) {
+ header(HttpHeaders.ContentType, ContentType.Application.Json)
+ }
+ }
+
+ val netWorkRepo = CalorieNetworkSourceImpl(httpClient)
+ val result = netWorkRepo.getCalorieInformation("pizza")
+ Truth.assertThat(result.exceptionOrNull()).isInstanceOf(AppErrors.NotFound::class.java)
+ }
+}
diff --git a/core/network/src/test/java/com/devmike/network/ExampleUnitTest.kt b/core/network/src/test/java/com/devmike/network/ExampleUnitTest.kt
deleted file mode 100644
index c235972..0000000
--- a/core/network/src/test/java/com/devmike/network/ExampleUnitTest.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.devmike.network
-
-import org.junit.Test
-
-import org.junit.Assert.*
-
-/**
- * Example local unit test, which will execute on the development machine (host).
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-class ExampleUnitTest {
- @Test
- fun addition_isCorrect() {
- assertEquals(4, 2 + 2)
- }
-}
diff --git a/core/network/src/test/java/com/devmike/network/SampleCalorieResponse.kt b/core/network/src/test/java/com/devmike/network/SampleCalorieResponse.kt
index a1cf146..3d26983 100644
--- a/core/network/src/test/java/com/devmike/network/SampleCalorieResponse.kt
+++ b/core/network/src/test/java/com/devmike/network/SampleCalorieResponse.kt
@@ -1,6 +1,8 @@
package com.devmike.network
-internal val mockJsonResponse =
+import com.devmike.network.model.CalorieDTO
+
+internal val mockValidJsonResponse =
"""
{
"items": [
@@ -49,3 +51,53 @@ internal val mockJsonResponse =
]
}
""".trimIndent()
+
+object SampleDTO {
+ val onionDTO =
+ CalorieDTO(
+ calories = 126.7,
+ carbohydratesTotalG = 28.6,
+ cholesterolMg = 0.0,
+ fatSaturatedG = 0.1,
+ fatTotalG = 0.5,
+ fiberG = 4.0,
+ name = "onion",
+ potassiumMg = 99.0,
+ proteinG = 3.9,
+ servingSizeG = 283.0,
+ sodiumMg = 8.0,
+ sugarG = 13.3,
+ )
+
+ val tomatoDTO =
+ CalorieDTO(
+ calories = 18.2,
+ carbohydratesTotalG = 3.9,
+ cholesterolMg = 0.0,
+ fatSaturatedG = 0.0,
+ fatTotalG = 0.2,
+ fiberG = 1.2,
+ name = "tomato",
+ potassiumMg = 23.0,
+ proteinG = 0.9,
+ servingSizeG = 100.0,
+ sodiumMg = 4.0,
+ sugarG = 2.6,
+ )
+
+ val pizzaDTO =
+ CalorieDTO(
+ calories = 262.9,
+ carbohydratesTotalG = 32.9,
+ cholesterolMg = 16.0,
+ fatSaturatedG = 4.5,
+ fatTotalG = 9.8,
+ fiberG = 2.3,
+ name = "pizza",
+ potassiumMg = 217.0,
+ proteinG = 11.4,
+ servingSizeG = 100.0,
+ sodiumMg = 587.0,
+ sugarG = 3.6,
+ )
+}
diff --git a/core/testing/.gitignore b/core/testing/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/core/testing/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts
new file mode 100644
index 0000000..ddcb9b6
--- /dev/null
+++ b/core/testing/build.gradle.kts
@@ -0,0 +1,37 @@
+plugins {
+ alias(libs.plugins.caloriebytez.android.feature)
+ alias(libs.plugins.caloriebytez.library.compose)
+}
+
+android {
+ namespace = "com.devmike.testing"
+ compileSdk = 34
+
+ defaultConfig {
+ minSdk = 26
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles("consumer-rules.pro")
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro",
+ )
+ }
+ }
+}
+
+dependencies {
+ api(libs.ktor.mock)
+ api(libs.androidx.arch.core.testing)
+ api(libs.kotlinx.coroutines.test)
+ api(libs.core.ktx)
+ api(libs.truth)
+ api(libs.robolectric)
+ api(libs.mockk)
+ implementation(project(":domain"))
+}
diff --git a/core/testing/consumer-rules.pro b/core/testing/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/core/testing/proguard-rules.pro b/core/testing/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/core/testing/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/core/testing/src/main/AndroidManifest.xml b/core/testing/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..a5918e6
--- /dev/null
+++ b/core/testing/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/core/testing/src/main/java/com/devmike/testing/fake/FakeCaloriesRepository.kt b/core/testing/src/main/java/com/devmike/testing/fake/FakeCaloriesRepository.kt
new file mode 100644
index 0000000..e5c0f4a
--- /dev/null
+++ b/core/testing/src/main/java/com/devmike/testing/fake/FakeCaloriesRepository.kt
@@ -0,0 +1,52 @@
+package com.devmike.testing.fake
+
+import com.devmike.domain.models.AppErrors
+import com.devmike.domain.models.CalorieModel
+import com.devmike.domain.repositories.CaloriesRepository
+import com.devmike.testing.sampleData.SampleModels
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOf
+
+class FakeCaloriesRepository : CaloriesRepository {
+ private val calorieMap = mutableMapOf>()
+ private val foodItemMap = mutableMapOf()
+
+ private var returnError = false
+
+ fun setReturnError(value: Boolean) {
+ returnError = value
+ }
+
+ fun setCalories(
+ query: String,
+ calories: List,
+ ) {
+ calorieMap[query] = calories
+ }
+
+ fun setFoodItem(
+ name: String,
+ calorieModel: CalorieModel,
+ ) {
+ foodItemMap[name] = calorieModel
+ }
+
+ override suspend fun getCalories(query: String): Result> {
+ if (returnError) return Result.failure(AppErrors.Unknown())
+ return calorieMap[query]?.let { Result.success(it) }
+ ?: Result.failure(Exception("No data found for query: $query"))
+ }
+
+ override suspend fun getFoodItem(name: String): CalorieModel? {
+ return foodItemMap[name]
+ }
+
+ override fun getAllCalories(): Flow> {
+ return flowOf(
+ listOf(
+ SampleModels.tomatomodel,
+ SampleModels.pizzamodel,
+ ),
+ )
+ }
+}
diff --git a/core/testing/src/main/java/com/devmike/testing/fake/FakeRecentSearchesRepository.kt b/core/testing/src/main/java/com/devmike/testing/fake/FakeRecentSearchesRepository.kt
new file mode 100644
index 0000000..3cae3ff
--- /dev/null
+++ b/core/testing/src/main/java/com/devmike/testing/fake/FakeRecentSearchesRepository.kt
@@ -0,0 +1,17 @@
+package com.devmike.testing.fake
+
+import com.devmike.domain.repositories.RecentSearchesRepository
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+
+class FakeRecentSearchesRepository : RecentSearchesRepository {
+ private val recentSearchesFlow = MutableStateFlow(listOf())
+
+ fun setRecentSearches(searches: List) {
+ recentSearchesFlow.value = searches
+ }
+
+ override fun getRecentSearches(limit: Int): Flow> {
+ return recentSearchesFlow
+ }
+}
diff --git a/core/testing/src/main/java/com/devmike/testing/helpers/MainCoroutineRule.kt b/core/testing/src/main/java/com/devmike/testing/helpers/MainCoroutineRule.kt
new file mode 100644
index 0000000..f68f72a
--- /dev/null
+++ b/core/testing/src/main/java/com/devmike/testing/helpers/MainCoroutineRule.kt
@@ -0,0 +1,26 @@
+package com.devmike.testing.helpers
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestDispatcher
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import org.junit.rules.TestWatcher
+import org.junit.runner.Description
+
+class MainCoroutineRule
+ @OptIn(ExperimentalCoroutinesApi::class)
+ constructor(
+ val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
+ ) : TestWatcher() {
+ @OptIn(ExperimentalCoroutinesApi::class)
+ override fun starting(description: Description) {
+ Dispatchers.setMain(testDispatcher)
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ override fun finished(description: Description) {
+ Dispatchers.resetMain()
+ }
+ }
diff --git a/core/testing/src/main/java/com/devmike/testing/sampleData/SampleModels.kt b/core/testing/src/main/java/com/devmike/testing/sampleData/SampleModels.kt
new file mode 100644
index 0000000..72967da
--- /dev/null
+++ b/core/testing/src/main/java/com/devmike/testing/sampleData/SampleModels.kt
@@ -0,0 +1,53 @@
+package com.devmike.testing.sampleData
+
+import com.devmike.domain.models.CalorieModel
+
+object SampleModels {
+ val onionmodel =
+ CalorieModel(
+ calories = 126.7,
+ carbohydratesTotalG = 28.6,
+ cholesterolMg = 0.0,
+ fatSaturatedG = 0.1,
+ fatTotalG = 0.5,
+ fiberG = 4.0,
+ name = "onion",
+ potassiumMg = 99.0,
+ proteinG = 3.9,
+ servingSizeinGrams = 283.0,
+ sodiumMg = 8.0,
+ sugarG = 13.3,
+ )
+
+ val tomatomodel =
+ CalorieModel(
+ calories = 18.2,
+ carbohydratesTotalG = 3.9,
+ cholesterolMg = 0.0,
+ fatSaturatedG = 0.0,
+ fatTotalG = 0.2,
+ fiberG = 1.2,
+ name = "tomato",
+ potassiumMg = 23.0,
+ proteinG = 0.9,
+ servingSizeinGrams = 100.0,
+ sodiumMg = 4.0,
+ sugarG = 2.6,
+ )
+
+ val pizzamodel =
+ CalorieModel(
+ calories = 262.9,
+ carbohydratesTotalG = 32.9,
+ cholesterolMg = 16.0,
+ fatSaturatedG = 4.5,
+ fatTotalG = 9.8,
+ fiberG = 2.3,
+ name = "pizza",
+ potassiumMg = 217.0,
+ proteinG = 11.4,
+ servingSizeinGrams = 100.0,
+ sodiumMg = 587.0,
+ sugarG = 3.6,
+ )
+}
diff --git a/docs/datalayertests.png b/docs/datalayertests.png
new file mode 100644
index 0000000..c14a4c3
Binary files /dev/null and b/docs/datalayertests.png differ
diff --git a/docs/dbtests.png b/docs/dbtests.png
new file mode 100644
index 0000000..52d1247
Binary files /dev/null and b/docs/dbtests.png differ
diff --git a/docs/details.jpg b/docs/details.jpg
new file mode 100644
index 0000000..6f4bf93
Binary files /dev/null and b/docs/details.jpg differ
diff --git a/docs/errorscreen.jpg b/docs/errorscreen.jpg
new file mode 100644
index 0000000..1eb8aee
Binary files /dev/null and b/docs/errorscreen.jpg differ
diff --git a/docs/foodlist.jpg b/docs/foodlist.jpg
new file mode 100644
index 0000000..c1e299e
Binary files /dev/null and b/docs/foodlist.jpg differ
diff --git a/docs/graphviz.svg b/docs/graphviz.svg
new file mode 100644
index 0000000..386a367
--- /dev/null
+++ b/docs/graphviz.svg
@@ -0,0 +1,169 @@
+
\ No newline at end of file
diff --git a/docs/idle.jpg b/docs/idle.jpg
new file mode 100644
index 0000000..95f3e40
Binary files /dev/null and b/docs/idle.jpg differ
diff --git a/docs/loading.jpg b/docs/loading.jpg
new file mode 100644
index 0000000..f4c0c63
Binary files /dev/null and b/docs/loading.jpg differ
diff --git a/docs/networktests.png b/docs/networktests.png
new file mode 100644
index 0000000..4001ba6
Binary files /dev/null and b/docs/networktests.png differ
diff --git a/docs/recentssearch.jpg b/docs/recentssearch.jpg
new file mode 100644
index 0000000..7fadf56
Binary files /dev/null and b/docs/recentssearch.jpg differ
diff --git a/docs/viewmodeltests.png b/docs/viewmodeltests.png
new file mode 100644
index 0000000..93ce192
Binary files /dev/null and b/docs/viewmodeltests.png differ
diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts
index 6529428..cbc54d7 100644
--- a/domain/build.gradle.kts
+++ b/domain/build.gradle.kts
@@ -18,5 +18,5 @@ android {
}
dependencies {
- implementation(libs.kotlinx.serialization.json)
+ api(libs.kotlinx.serialization.json)
}
diff --git a/domain/src/androidTest/java/com/devmike/domain/ExampleInstrumentedTest.kt b/domain/src/androidTest/java/com/devmike/domain/ExampleInstrumentedTest.kt
deleted file mode 100644
index c149fdd..0000000
--- a/domain/src/androidTest/java/com/devmike/domain/ExampleInstrumentedTest.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-package com.devmike.domain
-
-import androidx.test.platform.app.InstrumentationRegistry
-import androidx.test.ext.junit.runners.AndroidJUnit4
-
-import org.junit.Test
-import org.junit.runner.RunWith
-
-import org.junit.Assert.*
-
-/**
- * Instrumented test, which will execute on an Android device.
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-@RunWith(AndroidJUnit4::class)
-class ExampleInstrumentedTest {
- @Test
- fun useAppContext() {
- // Context of the app under test.
- val appContext = InstrumentationRegistry.getInstrumentation().targetContext
- assertEquals("com.devmike.domain.test", appContext.packageName)
- }
-}
diff --git a/core/data/src/main/java/com/devmike/data/repository/CaloriesRepository.kt b/domain/src/main/java/com/devmike/domain/repositories/CaloriesRepository.kt
similarity index 62%
rename from core/data/src/main/java/com/devmike/data/repository/CaloriesRepository.kt
rename to domain/src/main/java/com/devmike/domain/repositories/CaloriesRepository.kt
index 24a1dec..c9a56cd 100644
--- a/core/data/src/main/java/com/devmike/data/repository/CaloriesRepository.kt
+++ b/domain/src/main/java/com/devmike/domain/repositories/CaloriesRepository.kt
@@ -1,9 +1,12 @@
-package com.devmike.data.repository
+package com.devmike.domain.repositories
import com.devmike.domain.models.CalorieModel
+import kotlinx.coroutines.flow.Flow
interface CaloriesRepository {
suspend fun getCalories(query: String): Result>
suspend fun getFoodItem(name: String): CalorieModel?
+
+ fun getAllCalories(): Flow>
}
diff --git a/core/data/src/main/java/com/devmike/data/repository/RecentSearchesRepository.kt b/domain/src/main/java/com/devmike/domain/repositories/RecentSearchesRepository.kt
similarity index 77%
rename from core/data/src/main/java/com/devmike/data/repository/RecentSearchesRepository.kt
rename to domain/src/main/java/com/devmike/domain/repositories/RecentSearchesRepository.kt
index a0d5d85..b33006b 100644
--- a/core/data/src/main/java/com/devmike/data/repository/RecentSearchesRepository.kt
+++ b/domain/src/main/java/com/devmike/domain/repositories/RecentSearchesRepository.kt
@@ -1,4 +1,4 @@
-package com.devmike.data.repository
+package com.devmike.domain.repositories
import kotlinx.coroutines.flow.Flow
diff --git a/domain/src/main/java/com/devmike/domain/repositories/build.gradle.kts b/domain/src/main/java/com/devmike/domain/repositories/build.gradle.kts
new file mode 100644
index 0000000..ddcb9b6
--- /dev/null
+++ b/domain/src/main/java/com/devmike/domain/repositories/build.gradle.kts
@@ -0,0 +1,37 @@
+plugins {
+ alias(libs.plugins.caloriebytez.android.feature)
+ alias(libs.plugins.caloriebytez.library.compose)
+}
+
+android {
+ namespace = "com.devmike.testing"
+ compileSdk = 34
+
+ defaultConfig {
+ minSdk = 26
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles("consumer-rules.pro")
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro",
+ )
+ }
+ }
+}
+
+dependencies {
+ api(libs.ktor.mock)
+ api(libs.androidx.arch.core.testing)
+ api(libs.kotlinx.coroutines.test)
+ api(libs.core.ktx)
+ api(libs.truth)
+ api(libs.robolectric)
+ api(libs.mockk)
+ implementation(project(":domain"))
+}
diff --git a/domain/src/test/java/com/devmike/domain/ExampleUnitTest.kt b/domain/src/test/java/com/devmike/domain/ExampleUnitTest.kt
deleted file mode 100644
index 397f1f8..0000000
--- a/domain/src/test/java/com/devmike/domain/ExampleUnitTest.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.devmike.domain
-
-import org.junit.Test
-
-import org.junit.Assert.*
-
-/**
- * Example local unit test, which will execute on the development machine (host).
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-class ExampleUnitTest {
- @Test
- fun addition_isCorrect() {
- assertEquals(4, 2 + 2)
- }
-}
diff --git a/feature/foodDetails/build.gradle.kts b/feature/foodDetails/build.gradle.kts
index d43575c..9e1a046 100644
--- a/feature/foodDetails/build.gradle.kts
+++ b/feature/foodDetails/build.gradle.kts
@@ -20,4 +20,5 @@ android {
dependencies {
implementation(project(":core:data"))
api(project(":core:common-ui"))
+ testImplementation(project(":core:testing"))
}
diff --git a/feature/foodDetails/src/main/java/com/devmike/fooddetails/FoodDetailViewModel.kt b/feature/foodDetails/src/main/java/com/devmike/fooddetails/FoodDetailViewModel.kt
index fdee369..4113817 100644
--- a/feature/foodDetails/src/main/java/com/devmike/fooddetails/FoodDetailViewModel.kt
+++ b/feature/foodDetails/src/main/java/com/devmike/fooddetails/FoodDetailViewModel.kt
@@ -4,9 +4,9 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
-import com.devmike.data.repository.CaloriesRepository
import com.devmike.domain.models.AppDestinations
import com.devmike.domain.models.CalorieModel
+import com.devmike.domain.repositories.CaloriesRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
diff --git a/feature/foodDetails/src/main/java/com/devmike/fooddetails/FoodDetails.kt b/feature/foodDetails/src/main/java/com/devmike/fooddetails/FoodDetails.kt
index 1bfd8a9..aa1998e 100644
--- a/feature/foodDetails/src/main/java/com/devmike/fooddetails/FoodDetails.kt
+++ b/feature/foodDetails/src/main/java/com/devmike/fooddetails/FoodDetails.kt
@@ -1,5 +1,6 @@
package com.devmike.fooddetails
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -12,11 +13,13 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Dining
+import androidx.compose.material.icons.filled.Earbuds
import androidx.compose.material.icons.filled.EggAlt
-import androidx.compose.material.icons.filled.Fastfood
+import androidx.compose.material.icons.filled.EnergySavingsLeaf
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FitnessCenter
-import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.filled.LineAxis
+import androidx.compose.material.icons.filled.LocalParking
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -35,7 +38,9 @@ fun FoodDetails(calorieModel: CalorieModel) {
Column(
modifier = Modifier.padding(16.dp).fillMaxSize().verticalScroll(rememberScrollState()),
) {
- CalorieItemScreen(calorieModel.calories)
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
+ CalorieItemScreen(calorieModel.calories)
+ }
NutritionDetailItem(
modifier = Modifier,
@@ -58,31 +63,31 @@ fun FoodDetails(calorieModel: CalorieModel) {
)
NutritionDetailItem(
modifier = Modifier,
- icon = Icons.Default.Fastfood,
+ icon = Icons.Default.EggAlt,
value = "${calorieModel.proteinG}g",
label = "Protein",
)
NutritionDetailItem(
modifier = Modifier,
- icon = Icons.Default.Search,
+ icon = Icons.Default.Earbuds,
value = "${calorieModel.sodiumMg}mg",
label = "Sodium",
)
NutritionDetailItem(
modifier = Modifier,
- icon = Icons.Default.EggAlt,
+ icon = Icons.Default.EnergySavingsLeaf,
value = "${calorieModel.sugarG}g",
label = "Sugar",
)
NutritionDetailItem(
modifier = Modifier,
- icon = Icons.Default.Search,
+ icon = Icons.Default.LocalParking,
value = "${calorieModel.potassiumMg}mg",
label = "Potassium",
)
NutritionDetailItem(
modifier = Modifier,
- icon = Icons.Default.Search,
+ icon = Icons.Default.LineAxis,
value = "${calorieModel.fiberG}g",
label = "Fiber",
)
diff --git a/feature/savedItems/src/main/java/com/devmike/saveditems/SavedItemScreen.kt b/feature/savedItems/src/main/java/com/devmike/saveditems/SavedItemScreen.kt
new file mode 100644
index 0000000..fdc0b8f
--- /dev/null
+++ b/feature/savedItems/src/main/java/com/devmike/saveditems/SavedItemScreen.kt
@@ -0,0 +1,40 @@
+package com.devmike.saveditems
+
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.CenterAlignedTopAppBar
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.rememberTopAppBarState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.devmike.commonui.sharedui.FoodList
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SavedItemsScreen(
+ viewmodel: SavedItemsViewmodel = hiltViewModel(),
+ onItemClicked: (name: String) -> Unit,
+) {
+ val fooditems by viewmodel.savedCalorieItems.collectAsStateWithLifecycle()
+ val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
+
+ Scaffold(
+ topBar = {
+ CenterAlignedTopAppBar(
+ title = {
+ Text("Recently Viewed ")
+ },
+ scrollBehavior = scrollBehavior,
+ )
+ },
+ modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+ ) {
+ FoodList(modifier = Modifier.padding(it), fooditems, onItemClicked)
+ }
+}
diff --git a/feature/savedItems/src/main/java/com/devmike/saveditems/SavedItemsViewmodel.kt b/feature/savedItems/src/main/java/com/devmike/saveditems/SavedItemsViewmodel.kt
new file mode 100644
index 0000000..4516562
--- /dev/null
+++ b/feature/savedItems/src/main/java/com/devmike/saveditems/SavedItemsViewmodel.kt
@@ -0,0 +1,21 @@
+package com.devmike.saveditems
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.devmike.domain.repositories.CaloriesRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.stateIn
+import javax.inject.Inject
+
+@HiltViewModel
+class SavedItemsViewmodel
+ @Inject
+ constructor(private val repository: CaloriesRepository) : ViewModel() {
+ val savedCalorieItems =
+ repository.getAllCalories().stateIn(
+ viewModelScope,
+ SharingStarted.Lazily,
+ emptyList(),
+ )
+ }
diff --git a/feature/search/build.gradle.kts b/feature/search/build.gradle.kts
index a1a7c0b..e52f2dc 100644
--- a/feature/search/build.gradle.kts
+++ b/feature/search/build.gradle.kts
@@ -29,4 +29,5 @@ dependencies {
implementation(project(":core:data"))
api(project(":core:common-ui"))
+ implementation(project(":core:testing"))
}
diff --git a/feature/search/src/main/java/com/devmike/search/SearchScreen.kt b/feature/search/src/main/java/com/devmike/search/SearchScreen.kt
index 8c6190f..6ada6ae 100644
--- a/feature/search/src/main/java/com/devmike/search/SearchScreen.kt
+++ b/feature/search/src/main/java/com/devmike/search/SearchScreen.kt
@@ -25,6 +25,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.airbnb.lottie.compose.LottieAnimation
import com.airbnb.lottie.compose.LottieCompositionSpec
import com.airbnb.lottie.compose.rememberLottieComposition
+import com.devmike.commonui.sharedui.ErrorScreen
+import com.devmike.commonui.sharedui.FoodList
import com.devmike.domain.models.CalorieModel
import com.devmike.domain.models.FetchItemState
@@ -72,7 +74,9 @@ fun SearchScreen(
when (val state = calorieSearchState) {
is FetchItemState.Error -> {
- Text(state.err.message)
+ ErrorScreen(state.err) {
+ searchViewModel.searchCalories()
+ }
}
is FetchItemState.Idle -> {
@@ -98,7 +102,7 @@ fun SearchScreen(
}
is FetchItemState.Success -> {
- com.devmike.commonui.sharedui.FoodList(state.data, onFoodItemClicked)
+ FoodList(Modifier, state.data, onFoodItemClicked)
}
}
}
diff --git a/feature/search/src/main/java/com/devmike/search/SearchViewModel.kt b/feature/search/src/main/java/com/devmike/search/SearchViewModel.kt
index 03b5de0..5ea191a 100644
--- a/feature/search/src/main/java/com/devmike/search/SearchViewModel.kt
+++ b/feature/search/src/main/java/com/devmike/search/SearchViewModel.kt
@@ -4,11 +4,11 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import com.devmike.data.repository.CaloriesRepository
-import com.devmike.data.repository.RecentSearchesRepository
import com.devmike.domain.models.AppErrors
import com.devmike.domain.models.CalorieModel
import com.devmike.domain.models.FetchItemState
+import com.devmike.domain.repositories.CaloriesRepository
+import com.devmike.domain.repositories.RecentSearchesRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -21,43 +21,44 @@ import javax.inject.Inject
@HiltViewModel
class SearchViewModel
- @Inject
- constructor(
- private val repository: CaloriesRepository,
- private val searchesRepository: RecentSearchesRepository,
- ) : ViewModel() {
- val recentSearchList =
- searchesRepository.getRecentSearches(10)
- .stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
-
- private var searchJob: Job? = null
- var searchQuery: MutableState = mutableStateOf("")
- private set
-
- private val _caloriesSearchState: MutableStateFlow>> =
- MutableStateFlow(FetchItemState.Idle)
- val caloriesSearchState: StateFlow>> =
- _caloriesSearchState.asStateFlow()
-
- fun modifyQuery(query: String) {
- searchQuery.value = query
- }
-
- fun searchCalories() {
- searchJob?.cancel()
- _caloriesSearchState.value = FetchItemState.Loading
- if (searchQuery.value.length > 3) {
- searchJob =
- viewModelScope.launch {
- repository.getCalories(searchQuery.value.trim()).onSuccess { calories ->
- _caloriesSearchState.value = FetchItemState.Success(calories)
- }.onFailure { throwable ->
-
- if (throwable is AppErrors) {
- _caloriesSearchState.value = FetchItemState.Error(throwable)
- }
- }
- }
- }
- }
- }
+ @Inject
+ constructor(
+ private val repository: CaloriesRepository,
+ private val searchesRepository: RecentSearchesRepository,
+ ) : ViewModel() {
+ val recentSearchList =
+ searchesRepository.getRecentSearches(10)
+ .stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
+
+ private var searchJob: Job? = null
+ var searchQuery: MutableState = mutableStateOf("")
+ private set
+
+ private val _caloriesSearchState: MutableStateFlow>> =
+ MutableStateFlow(FetchItemState.Idle)
+ val caloriesSearchState: StateFlow>> =
+ _caloriesSearchState.asStateFlow()
+
+ fun modifyQuery(query: String) {
+ searchQuery.value = query
+ }
+
+ fun searchCalories() {
+ searchJob?.cancel()
+
+ if (searchQuery.value.length > 3) {
+ _caloriesSearchState.value = FetchItemState.Loading
+ searchJob =
+ viewModelScope.launch {
+ repository.getCalories(searchQuery.value.trim()).onSuccess { calories ->
+ _caloriesSearchState.value = FetchItemState.Success(calories)
+ }.onFailure { throwable ->
+
+ if (throwable is AppErrors) {
+ _caloriesSearchState.value = FetchItemState.Error(throwable)
+ }
+ }
+ }
+ }
+ }
+ }
diff --git a/feature/search/src/test/java/com/devmike/search/ExampleUnitTest.kt b/feature/search/src/test/java/com/devmike/search/ExampleUnitTest.kt
deleted file mode 100644
index a068722..0000000
--- a/feature/search/src/test/java/com/devmike/search/ExampleUnitTest.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.devmike.search
-
-import org.junit.Test
-
-import org.junit.Assert.*
-
-/**
- * Example local unit test, which will execute on the development machine (host).
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-class ExampleUnitTest {
- @Test
- fun addition_isCorrect() {
- assertEquals(4, 2 + 2)
- }
-}
diff --git a/feature/search/src/test/java/com/devmike/search/SearchViewmodelTest.kt b/feature/search/src/test/java/com/devmike/search/SearchViewmodelTest.kt
new file mode 100644
index 0000000..34d4ab0
--- /dev/null
+++ b/feature/search/src/test/java/com/devmike/search/SearchViewmodelTest.kt
@@ -0,0 +1,97 @@
+package com.devmike.search
+
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import com.devmike.domain.models.AppErrors
+import com.devmike.domain.models.FetchItemState
+import com.devmike.testing.fake.FakeCaloriesRepository
+import com.devmike.testing.fake.FakeRecentSearchesRepository
+import com.devmike.testing.helpers.MainCoroutineRule
+import com.devmike.testing.sampleData.SampleModels
+import com.google.common.truth.Truth
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class SearchViewmodelTest {
+ private lateinit var fakeRecentSearchesRepository: FakeRecentSearchesRepository
+
+ private lateinit var fakeCaloriesRepository: FakeCaloriesRepository
+
+ @get:Rule
+ val mainDispatcherRule = MainCoroutineRule()
+
+ @get:Rule
+ val instantTaskExecutor: InstantTaskExecutorRule = InstantTaskExecutorRule()
+
+ private lateinit var viewModel: SearchViewModel
+
+ @Before
+ fun setup() {
+ fakeRecentSearchesRepository = FakeRecentSearchesRepository()
+ fakeCaloriesRepository = FakeCaloriesRepository()
+ viewModel = SearchViewModel(fakeCaloriesRepository, fakeRecentSearchesRepository)
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @Test
+ fun `test getRecentSearches initializes with recent searches`() =
+ runTest {
+ val recentSearches = listOf("apple", "pizza")
+ fakeRecentSearchesRepository.setRecentSearches(recentSearches)
+
+ val result = viewModel.recentSearchList.first()
+
+ Truth.assertThat(result).containsExactly(*recentSearches.toTypedArray())
+ }
+
+ @Test
+ fun `test modifyQuery updates searchQuery`() {
+ viewModel.modifyQuery("onion")
+
+ Truth.assertThat(viewModel.searchQuery.value).isEqualTo("onion")
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @Test
+ fun `test searchCalories sets loading state and fetches calories successfully`() =
+ runTest {
+ val sampleCalories = listOf(SampleModels.tomatomodel)
+ fakeCaloriesRepository.setCalories("tomato", sampleCalories)
+
+ viewModel.modifyQuery("tomato")
+ viewModel.searchCalories()
+
+ advanceUntilIdle()
+
+ Truth.assertThat(viewModel.caloriesSearchState.value).isInstanceOf(FetchItemState.Success::class.java)
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @Test
+ fun `test searchCalories handles error state`() =
+ runTest {
+ fakeCaloriesRepository.setReturnError(true)
+
+ viewModel.modifyQuery("apple")
+ viewModel.searchCalories()
+
+ Truth.assertThat(viewModel.caloriesSearchState.value).isInstanceOf(FetchItemState.Error::class.java)
+
+ val isErr = (viewModel.caloriesSearchState.value as FetchItemState.Error)
+
+ Truth.assertThat(isErr.err).isInstanceOf(AppErrors.Unknown::class.java)
+ }
+
+ @Test
+ fun `test searchCalories does not search for short queries`() =
+ runTest {
+ viewModel.modifyQuery("app")
+ viewModel.searchCalories()
+
+ Truth.assertThat(viewModel.caloriesSearchState.value).isEqualTo(FetchItemState.Idle)
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 9ba0c03..583dc7b 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -4,6 +4,7 @@ activityCompose = "1.9.0"
androidxComposeAlpha = "1.7.0-beta02"
androidxCoreSplashscreen = "1.0.1"
androidxLifecycle = "2.8.1"
+archCore = "2.2.0"
composeNavigation = "2.8.0-beta02"
androidxTestExt = "1.1.5"
coreKtx = "1.13.1"
@@ -23,12 +24,16 @@ ktlint = "11.6.1"
ktor = "2.3.7"
lifecycleRuntimeKtx = "2.8.1"
lottie = "6.0.0"
-
+mockk = "1.13.11"
+moduleGraph = "2.5.0"
+robolectric = "4.12.2"
room = "2.6.1"
spotless = "6.4.2"
timber = "5.0.1"
appcompat = "1.6.1"
material = "1.10.0"
+coreKtxVersion = "1.5.0"
+truth = "1.4.2"
@@ -68,6 +73,7 @@ hilt-android= { module = "com.google.dagger:hilt-android",version.ref = "hilt"}
hilt-testing= { module = "'com.google.dagger:hilt-android-testing",version.ref = "hilt"}
#room
+mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
room-testing = {module = "androidx.room:room-testing",version.ref = "room"}
@@ -88,6 +94,9 @@ kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutine
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
+androidx-arch-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "archCore" }
+robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
+
# Dependencies of the included build-logic
android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" }
@@ -96,6 +105,8 @@ ksp-gradlePlugin = { group = "com.google.devtools.ksp", name = "com.google.devto
compose-gradlePlugin = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
+core-ktx = { group = "androidx.test", name = "core-ktx", version.ref = "coreKtxVersion" }
+truth = { module = "com.google.truth:truth", version.ref = "truth" }
[bundles]
ktor = ["ktor-core", "ktor-android", "ktor-json", "ktor-content-negotiation", "ktor-logging", "ktor-okhttp"]
@@ -118,6 +129,7 @@ hilt-plugin = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlinX-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
room-compiler = { id = "androidx.room", version.ref = "room" }
+module-graph = { id = "com.jraska.module.graph.assertion", version.ref = "moduleGraph" }
# project plugins
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 8a1cfed..a969c26 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -30,3 +30,4 @@ include(":feature:search")
include(":feature:foodDetails")
include(":feature:savedItems")
include(":core:common-ui")
+include(":core:testing")