diff --git a/.github/workflows/cd-android.yml b/.github/workflows/cd-android.yml index b0dbdb4..7864cef 100644 --- a/.github/workflows/cd-android.yml +++ b/.github/workflows/cd-android.yml @@ -19,16 +19,15 @@ jobs: cache: gradle - name: Decode keystore and create jks - run: echo "${{ secrets.KEY_KEYSTORE_BASE64 }}" | base64 --decode > app/purrfect-pics-key.jks + run: echo "${{ secrets.KEY_KEYSTORE_BASE64 }}" | base64 --decode > app/keystore.jks - name: Generate keystore.properties run: | cat < keystore.properties - PEXELS_API_KEY:${{ secrets.PEXELS_API_KEY }} - KEYSTORE_PASSWORD:${{ secrets.KEYSTORE_PASSWORD }} - KEY_PASSWORD:${{ secrets.KEY_PASSWORD }} - KEY_ALIAS:${{ secrets.KEY_ALIAS }} - KEYSTORE_FILE:${{ secrets.KEYSTORE_FILE }} + PEXELS_API_KEY=${{ secrets.PEXELS_API_KEY }} + KEY_PASSWORD=${{ secrets.KEY_PASSWORD }} + KEY_ALIAS=${{ secrets.KEY_ALIAS }} + KEYSTORE_FILE=${{ secrets.KEYSTORE_FILE }} EOF - name: Generate file name env var @@ -36,7 +35,7 @@ jobs: DATE=$(date +'%d.%m.%Y') BRANCH_NAME=${GITHUB_REF##*/} MESSAGE=$(cat << EOF - AppName-release-${BRANCH_NAME}-${DATE} + PurrfectPics-release-${BRANCH_NAME}-${DATE} EOF) echo OUTPUT_NAME=$MESSAGE >> $GITHUB_ENV diff --git a/README.md b/README.md index 5435a8f..475aa1e 100644 --- a/README.md +++ b/README.md @@ -9,57 +9,62 @@ heart. - **Discover:** Explore a vast collection of random cat images sourced from the web. - **Share:** Share delightful cat images with friends, family, and fellow cat enthusiasts with just a tap. -- **Save Favorites:** (Coming soon) Save your favorite cat images to easily revisit them later and - create your - personalized collection. +- **Save Favorites: (Coming soon)** Save your favorite cat images to easily revisit them later and + create your personalized collection. ## Tools/Libraries ### Android Libraries -- **UI Components:** Essential libraries for building UI components and handling UI-related - tasks. [AndroidX Core KTX](https://developer.android.com/jetpack/androidx/releases/core) | [Material Components for Android](https://github.com/material-components/material-components-android) | [Compose UI](https://developer.android.com/jetpack/androidx/releases/compose-ui) -- **Testing:** Frameworks and tools for writing and running tests to ensure code quality and - reliability. [JUnit](https://junit.org/junit5/) | [MockK](https://mockk.io/) | [Turbine](https://github.com/cashapp/turbine) -- **Dependency Injection:** Tools for managing dependencies and implementing dependency injection in - your project. [Hilt](https://developer.android.com/training/dependency-injection/hilt-android) -- **Coroutines:** Kotlin coroutine libraries for handling asynchronous programming tasks - efficiently. [Kotlin Coroutines](https://kotlinlang.org/docs/coroutines-overview.html) -- **Networking:** Libraries for making network requests and handling network - communication. [Retrofit](https://square.github.io/retrofit/) -- **Image Loading:** Libraries for loading and displaying images efficiently in your - app. [Coil](https://coil-kt.github.io/coil/) +- **UI Components:** [AndroidX Core KTX](https://developer.android.com/jetpack/androidx/releases/core) | [Material Components for Android](https://github.com/material-components/material-components-android) | [Compose UI](https://developer.android.com/jetpack/androidx/releases/compose-ui) +- **Testing:** [JUnit](https://junit.org/junit5/) | [MockK](https://mockk.io/) | [Turbine](https://github.com/cashapp/turbine) +- **Dependency Injection:** [Hilt](https://developer.android.com/training/dependency-injection/hilt-android) +- **Coroutines:** [Kotlin Coroutines](https://kotlinlang.org/docs/coroutines-overview.html) +- **Networking:** [Retrofit](https://square.github.io/retrofit/) +- **Image Loading:** [Coil](https://coil-kt.github.io/coil/) ### Compose Libraries -- **UI Components:** Libraries for building UI components using Jetpack - Compose. [Material3](https://developer.android.com/jetpack/androidx/releases/compose-material3) | [Compose UI](https://developer.android.com/jetpack/androidx/releases/compose-ui) -- **Navigation:** Libraries for implementing navigation in Jetpack Compose - apps. [Navigation Compose](https://developer.android.com/jetpack/androidx/releases/navigation) | [Hilt Navigation Compose](https://developer.android.com/training/dependency-injection/hilt-android#navigation-compose) -- **Material Design:** Libraries for implementing Material Design components and theming in Jetpack - Compose. [Material Components for Android](https://github.com/material-components/material-components-android) +- **UI Components:** [Material3](https://developer.android.com/jetpack/androidx/releases/compose-material3) | [Compose UI](https://developer.android.com/jetpack/androidx/releases/compose-ui) +- **Navigation:** [Navigation Compose](https://developer.android.com/jetpack/androidx/releases/navigation) | [Hilt Navigation Compose](https://developer.android.com/training/dependency-injection/hilt-android#navigation-compose) +- **Material Design:** [Material Components for Android](https://github.com/material-components/material-components-android) -### Other +## Screenshots -- **Build Tools:** Essential tools for building, testing, and packaging your Android - app. [Android Gradle Plugin](https://developer.android.com/studio/releases/gradle-plugin) | [Kotlin Symbol Processing (KSP)](https://github.com/google/ksp) +| ![Screenshot 2024-04-15 at 6 34 16 PM](https://github.com/manununhez/purrfect-pics/assets/5048531/a8024c0c-e31f-4189-b268-1167048658ad) | ![Screenshot 2024-04-15 at 6 35 33 PM](https://github.com/manununhez/purrfect-pics/assets/5048531/1a72c5ea-ee22-4470-8e98-6215ebb86924) | ![Screenshot 2024-04-15 at 6 36 08 PM](https://github.com/manununhez/purrfect-pics/assets/5048531/c31637d7-ddfe-436a-8e16-d27244d638ea) | +|---|---|---| -## Screenshots +## Considerations -[Include screenshots or GIFs of the app in action] +- FREE Cat APIs used in this version: [Cataas](https://cataas.com/) + and [Pexels](https://www.pexels.com/). In particular, Pexels API + is [rate-limited](https://www.pexels.com/api/documentation/#guidelines), in case of errors for + this, a new API key will be necessary to be generated. +- Not persisted in DB or preferences because this API has dynamic and frequently updated data. On + the other hand, Coil image lib does use disk and memory cache to smooth image loading. +- KtfmtFormat plugin applied for code formatting. +- There is a known issue with the splash screen not showing on Android 12. A temporary + solution is to **open the app from the app tray**, as indicated [here](https://stackoverflow.com/questions/69812590/android-12-splash-screen-icon-not-displaying)) ## Getting Started To get started with PurrfectPics, follow these steps: -1. Clone the repository to your local machine: - git clone git@github.com:manununhez/purrfect-pics.git - - +1. Clone the repository to your local + machine: `git clone git@github.com:manununhez/purrfect-pics.git` 2. Open the project in Android Studio. - 3. Build and run the app on your device or emulator. +## Branching Strategy + +- **Main Branch:** The primary branch of the project, reflecting the latest stable release. +- **Feature Branches:** Used for developing new features. Branch names should be descriptive of the + feature being developed. +- **Epic Branches:** Used for grouping related features or significant enhancements. Branch names + should reflect the overarching theme of the epic. +- **Release Branches:** Used for preparing releases. Branch names should follow the + pattern `release/v/..`. + ## Contributing We welcome contributions from the open-source community to help improve PurrfectPics. Whether you're @@ -76,6 +81,5 @@ PurrfectPics is licensed under the [MIT License](LICENSE). ## About -PurrfectPics is developed and maintained by [Manuel Nuñez]. For inquiries, please -contact [manuel.nunhez90@gmail.com]. - +PurrfectPics is developed and maintained by [Manuel Nuñez](mailto:manuel.nunhez90@gmail.com). For +inquiries, please contact [manuel.nunhez90@gmail.com]. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 71460ef..a25818e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,6 +12,11 @@ android { namespace = "com.manuelnunez.apps" compileSdk = 34 + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + defaultConfig { applicationId = "com.manuelnunez.apps.purrfectpics" minSdk = 21 @@ -31,12 +36,20 @@ android { storeFile = file(getProperty("KEYSTORE_FILE")) keyAlias = getProperty("KEY_ALIAS") keyPassword = getProperty("KEY_PASSWORD") - storePassword = getProperty("KEYSTORE_PASSWORD") + storePassword = getProperty("KEY_PASSWORD") } } } } + buildFeatures { compose = true } + + composeOptions { kotlinCompilerExtensionVersion = libs.versions.androidxComposeCompiler.get() } + + tasks.withType { useJUnitPlatform() } + + packaging { resources { excludes.add("META-INF/{LICENSE-notice.md,LICENSE.md}") } } + buildTypes { getByName("release") { proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") @@ -53,21 +66,6 @@ android { proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro") } } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlinOptions { jvmTarget = JavaVersion.VERSION_17.toString() } - - buildFeatures { compose = true } - - composeOptions { kotlinCompilerExtensionVersion = libs.versions.androidxComposeCompiler.get() } - - tasks.withType { useJUnitPlatform() } - - packaging { resources { excludes.add("META-INF/{LICENSE-notice.md,LICENSE.md}") } } } dependencies { diff --git a/core/common-ui/build.gradle.kts b/core/common-ui/build.gradle.kts index 3b2ecdb..c2d373d 100644 --- a/core/common-ui/build.gradle.kts +++ b/core/common-ui/build.gradle.kts @@ -7,13 +7,13 @@ android { namespace = "com.manuelnunez.apps.core.ui" compileSdk = 34 - defaultConfig { minSdk = 21 } - compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } + defaultConfig { minSdk = 21 } + kotlinOptions { jvmTarget = JavaVersion.VERSION_17.toString() } buildFeatures { compose = true } @@ -29,8 +29,6 @@ dependencies { implementation(libs.coil.kt.compose) - implementation(libs.androidx.navigation.compose) - // Compose val composeBom = platform(libs.androidx.compose.bom) implementation(composeBom) diff --git a/core/common-ui/src/main/kotlin/com/manuelnunez/apps/core/ui/component/AdaptableVerticalGrid.kt b/core/common-ui/src/main/kotlin/com/manuelnunez/apps/core/ui/component/AdaptableVerticalGrid.kt index 1021d48..311c191 100644 --- a/core/common-ui/src/main/kotlin/com/manuelnunez/apps/core/ui/component/AdaptableVerticalGrid.kt +++ b/core/common-ui/src/main/kotlin/com/manuelnunez/apps/core/ui/component/AdaptableVerticalGrid.kt @@ -1,10 +1,22 @@ package com.manuelnunez.apps.core.ui.component +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.material3.Card +import androidx.compose.material3.Icon import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.manuelnunez.apps.core.ui.R +import com.manuelnunez.apps.core.ui.theme.MainTheme +import com.manuelnunez.apps.core.ui.utils.ThemePreviews import kotlin.math.roundToInt @Composable @@ -28,3 +40,49 @@ data class AdaptableVerticalGridDecoration( val itemWidth: Dp, val itemHorizontalMargin: Dp, ) + +/** A simple grid which lays elements out vertically in evenly sized [columns]. */ +@Composable +fun VerticalGrid(modifier: Modifier = Modifier, columns: Int = 2, content: @Composable () -> Unit) { + Layout(content = content, modifier = modifier) { measurables, constraints -> + val itemWidth = constraints.maxWidth / columns + // Keep given height constraints, but set an exact width + val itemConstraints = constraints.copy(minWidth = itemWidth, maxWidth = itemWidth) + // Measure each item with these constraints + val placeables = measurables.map { it.measure(itemConstraints) } + // Track each columns height so we can calculate the overall height + val columnHeights = Array(columns) { 0 } + placeables.forEachIndexed { index, placeable -> + val column = index % columns + columnHeights[column] += placeable.height + } + val height = + (columnHeights.maxOrNull() ?: constraints.minHeight).coerceAtMost(constraints.maxHeight) + layout(width = constraints.maxWidth, height = height) { + // Track the Y co-ord per column we have placed up to + val columnY = Array(columns) { 0 } + placeables.forEachIndexed { index, placeable -> + val column = index % columns + placeable.placeRelative(x = column * itemWidth, y = columnY[column]) + columnY[column] += placeable.height + } + } + } +} + +@ThemePreviews +@Composable +fun AdaptableVerticalGridPreview() { + MainTheme { + AdaptableVerticalGrid(decoration = AdaptableVerticalGridDecoration(20.dp, 25.dp, 55.dp)) { + List(10) { + Card(Modifier.size(50.dp, 80.dp).padding(horizontal = 10.dp).padding(bottom = 20.dp)) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Icon( + painter = painterResource(id = R.drawable.ic_broken_image), contentDescription = "") + } + } + } + } + } +} diff --git a/core/common-ui/src/main/kotlin/com/manuelnunez/apps/core/ui/component/AlertDialog.kt b/core/common-ui/src/main/kotlin/com/manuelnunez/apps/core/ui/component/AlertDialog.kt index fce14cb..9b55bdd 100644 --- a/core/common-ui/src/main/kotlin/com/manuelnunez/apps/core/ui/component/AlertDialog.kt +++ b/core/common-ui/src/main/kotlin/com/manuelnunez/apps/core/ui/component/AlertDialog.kt @@ -10,6 +10,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.graphics.vector.ImageVector +import com.manuelnunez.apps.core.ui.theme.MainTheme +import com.manuelnunez.apps.core.ui.utils.ThemePreviews @Composable fun ErrorDialog(dialogTitle: String, dialogText: String, onConfirmation: () -> Unit) { @@ -46,3 +48,14 @@ fun ErrorAlertDialog( confirmButton = { TextButton(onClick = { onConfirmation() }) { Text("Confirm") } }, dismissButton = { TextButton(onClick = { onDismissRequest() }) { Text("Dismiss") } }) } + +@ThemePreviews +@Composable +fun ErrorDialogPreview() { + MainTheme { + ErrorDialog( + onConfirmation = {}, + dialogTitle = "Title", + dialogText = "https://picsum.photos/id/237/200/300") + } +} diff --git a/core/common-ui/src/main/kotlin/com/manuelnunez/apps/core/ui/component/TextCard.kt b/core/common-ui/src/main/kotlin/com/manuelnunez/apps/core/ui/component/TextCard.kt index ea40133..24baa60 100644 --- a/core/common-ui/src/main/kotlin/com/manuelnunez/apps/core/ui/component/TextCard.kt +++ b/core/common-ui/src/main/kotlin/com/manuelnunez/apps/core/ui/component/TextCard.kt @@ -3,6 +3,7 @@ package com.manuelnunez.apps.core.ui.component import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme @@ -12,6 +13,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign import com.manuelnunez.apps.core.ui.theme.MainTheme import com.manuelnunez.apps.core.ui.utils.ThemePreviews @@ -20,12 +22,13 @@ fun TextCard(modifier: Modifier = Modifier, text: String, onClick: () -> Unit) { Card( colors = CardDefaults.cardColors().copy(containerColor = MaterialTheme.colorScheme.onBackground), - modifier = - modifier.clickable(onClick = { onClick.invoke() }).semantics { - contentDescription = text - }) { + modifier = modifier.clickable(onClick = onClick).semantics { contentDescription = text }) { Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { - Text(text = text, color = MaterialTheme.colorScheme.background) + Text( + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + text = text, + color = MaterialTheme.colorScheme.background) } } } diff --git a/core/common-ui/src/main/kotlin/com/manuelnunez/apps/core/ui/component/TitleText.kt b/core/common-ui/src/main/kotlin/com/manuelnunez/apps/core/ui/component/TitleText.kt index 6ce2ff9..a653a0e 100644 --- a/core/common-ui/src/main/kotlin/com/manuelnunez/apps/core/ui/component/TitleText.kt +++ b/core/common-ui/src/main/kotlin/com/manuelnunez/apps/core/ui/component/TitleText.kt @@ -18,8 +18,24 @@ fun TitleText(modifier: Modifier = Modifier, title: String, textAlign: TextAlign style = MaterialTheme.typography.titleLarge) } +@Composable +fun ErrorText(modifier: Modifier = Modifier, title: String, textAlign: TextAlign? = null) { + Text( + modifier = modifier, + text = title, + textAlign = textAlign, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleSmall) +} + @ThemePreviews @Composable fun TitleTextPreview() { MainTheme { TitleText(title = "See more") } } + +@ThemePreviews +@Composable +fun ErrorTextPreview() { + MainTheme { ErrorText(title = "See more") } +} diff --git a/core/common-ui/src/main/kotlin/com/manuelnunez/apps/core/ui/component/VerticalGrid.kt b/core/common-ui/src/main/kotlin/com/manuelnunez/apps/core/ui/component/VerticalGrid.kt deleted file mode 100644 index 17fcc7f..0000000 --- a/core/common-ui/src/main/kotlin/com/manuelnunez/apps/core/ui/component/VerticalGrid.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.manuelnunez.apps.core.ui.component - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.Layout - -/** A simple grid which lays elements out vertically in evenly sized [columns]. */ -@Composable -fun VerticalGrid(modifier: Modifier = Modifier, columns: Int = 2, content: @Composable () -> Unit) { - Layout(content = content, modifier = modifier) { measurables, constraints -> - val itemWidth = constraints.maxWidth / columns - // Keep given height constraints, but set an exact width - val itemConstraints = constraints.copy(minWidth = itemWidth, maxWidth = itemWidth) - // Measure each item with these constraints - val placeables = measurables.map { it.measure(itemConstraints) } - // Track each columns height so we can calculate the overall height - val columnHeights = Array(columns) { 0 } - placeables.forEachIndexed { index, placeable -> - val column = index % columns - columnHeights[column] += placeable.height - } - val height = - (columnHeights.maxOrNull() ?: constraints.minHeight).coerceAtMost(constraints.maxHeight) - layout(width = constraints.maxWidth, height = height) { - // Track the Y co-ord per column we have placed up to - val columnY = Array(columns) { 0 } - placeables.forEachIndexed { index, placeable -> - val column = index % columns - placeable.placeRelative(x = column * itemWidth, y = columnY[column]) - columnY[column] += placeable.height - } - } - } -} diff --git a/core/common-ui/src/main/kotlin/com/manuelnunez/apps/core/ui/utils/Preview.kt b/core/common-ui/src/main/kotlin/com/manuelnunez/apps/core/ui/utils/Preview.kt index a80edc0..84e5dc1 100644 --- a/core/common-ui/src/main/kotlin/com/manuelnunez/apps/core/ui/utils/Preview.kt +++ b/core/common-ui/src/main/kotlin/com/manuelnunez/apps/core/ui/utils/Preview.kt @@ -19,19 +19,19 @@ annotation class DevicePreviews */ @Preview( name = "Small font", - group = "font scales", + group = "Font scaling", fontScale = 0.5f, showBackground = true, ) @Preview( name = "Large font", - group = "font scales", - fontScale = 2f, + group = "Font scaling", + fontScale = 2.5f, showBackground = true, ) @Preview( name = "Normal font", - group = "font scales", + group = "Font scaling", backgroundColor = 0xFFFFFFFF, showBackground = true, ) diff --git a/core/common-ui/src/main/res/values/strings.xml b/core/common-ui/src/main/res/values/strings.xml index 2a9c7b7..427cdee 100644 --- a/core/common-ui/src/main/res/values/strings.xml +++ b/core/common-ui/src/main/res/values/strings.xml @@ -1,5 +1,7 @@ + Featured + Popular Go back An Error has occurred. Please try again. An Error has occurred diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index af1447e..4809a9a 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -8,13 +8,13 @@ android { namespace = "com.manuelnunez.apps.core.common" compileSdk = 34 - defaultConfig { minSdk = 21 } - compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } + defaultConfig { minSdk = 21 } + kotlinOptions { jvmTarget = JavaVersion.VERSION_17.toString() } packaging { resources { excludes.add("META-INF/{LICENSE-notice.md,LICENSE.md}") } } @@ -23,6 +23,9 @@ android { dependencies { implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.test) + + implementation(libs.androidx.navigation.compose) + implementation(libs.junit.api) implementation(libs.mockk) diff --git a/core/common/src/main/kotlin/com/manuelnunez/apps/core/common/Either.kt b/core/common/src/main/kotlin/com/manuelnunez/apps/core/common/Either.kt index 07c8d84..ee0be35 100644 --- a/core/common/src/main/kotlin/com/manuelnunez/apps/core/common/Either.kt +++ b/core/common/src/main/kotlin/com/manuelnunez/apps/core/common/Either.kt @@ -6,8 +6,6 @@ sealed class Either { data class Error(val value: R) : Either() } -fun eitherEmpty() = Either.Success(Unit) - fun eitherSuccess(data: T) = Either.Success(data) fun eitherError(error: T) = Either.Error(error) diff --git a/core/common/src/main/kotlin/com/manuelnunez/apps/core/common/di/DispatchersModule.kt b/core/common/src/main/kotlin/com/manuelnunez/apps/core/common/di/DispatchersModule.kt index 65679f8..1490daa 100644 --- a/core/common/src/main/kotlin/com/manuelnunez/apps/core/common/di/DispatchersModule.kt +++ b/core/common/src/main/kotlin/com/manuelnunez/apps/core/common/di/DispatchersModule.kt @@ -1,6 +1,6 @@ package com.manuelnunez.apps.core.common.di -import com.manuelnunez.apps.core.common.dispatcher.DispatcherProvider +import com.manuelnunez.apps.core.common.dispatcher.CoroutineDispatcherProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -13,5 +13,6 @@ object DispatchersModule { @Provides @Singleton - fun provideDispatcherProvider(): DispatcherProvider = object : DispatcherProvider {} + fun provideCoroutineDispatcherProvider(): CoroutineDispatcherProvider = + object : CoroutineDispatcherProvider {} } diff --git a/core/common/src/main/kotlin/com/manuelnunez/apps/core/common/dispatcher/Dispatchers.kt b/core/common/src/main/kotlin/com/manuelnunez/apps/core/common/dispatcher/Dispatchers.kt index 2bc5353..49903d7 100644 --- a/core/common/src/main/kotlin/com/manuelnunez/apps/core/common/dispatcher/Dispatchers.kt +++ b/core/common/src/main/kotlin/com/manuelnunez/apps/core/common/dispatcher/Dispatchers.kt @@ -3,7 +3,7 @@ package com.manuelnunez.apps.core.common.dispatcher import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers -interface DispatcherProvider { +interface CoroutineDispatcherProvider { fun main(): CoroutineDispatcher = Dispatchers.Main diff --git a/core/common-ui/src/main/kotlin/com/manuelnunez/apps/core/ui/Navigation.kt b/core/common/src/main/kotlin/com/manuelnunez/apps/core/common/navigation/Navigation.kt similarity index 89% rename from core/common-ui/src/main/kotlin/com/manuelnunez/apps/core/ui/Navigation.kt rename to core/common/src/main/kotlin/com/manuelnunez/apps/core/common/navigation/Navigation.kt index d9658c9..6c20264 100644 --- a/core/common-ui/src/main/kotlin/com/manuelnunez/apps/core/ui/Navigation.kt +++ b/core/common/src/main/kotlin/com/manuelnunez/apps/core/common/navigation/Navigation.kt @@ -1,4 +1,4 @@ -package com.manuelnunez.apps.core.ui +package com.manuelnunez.apps.core.common.navigation import android.os.Bundle import androidx.navigation.NavController diff --git a/core/common/src/main/kotlin/com/manuelnunez/apps/core/common/test/MainDispatcherRule.kt b/core/common/src/main/kotlin/com/manuelnunez/apps/core/common/test/MockkAllRule.kt similarity index 78% rename from core/common/src/main/kotlin/com/manuelnunez/apps/core/common/test/MainDispatcherRule.kt rename to core/common/src/main/kotlin/com/manuelnunez/apps/core/common/test/MockkAllRule.kt index 9113e90..3e3047c 100644 --- a/core/common/src/main/kotlin/com/manuelnunez/apps/core/common/test/MainDispatcherRule.kt +++ b/core/common/src/main/kotlin/com/manuelnunez/apps/core/common/test/MockkAllRule.kt @@ -1,6 +1,7 @@ package com.manuelnunez.apps.core.common.test -import com.manuelnunez.apps.core.common.dispatcher.DispatcherProvider +import com.manuelnunez.apps.core.common.dispatcher.CoroutineDispatcherProvider +import io.mockk.unmockkAll import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -14,13 +15,13 @@ import org.junit.jupiter.api.extension.BeforeEachCallback import org.junit.jupiter.api.extension.ExtensionContext @ExperimentalCoroutinesApi -class MainDispatcherRule : BeforeEachCallback, AfterEachCallback { +class MockkAllRule : BeforeEachCallback, AfterEachCallback { private val testCoroutinesDispatcher = StandardTestDispatcher() private val testScope = TestScope(testCoroutinesDispatcher) - val testDispatcherProvider = - object : DispatcherProvider { + val testCoroutineDispatcherProvider = + object : CoroutineDispatcherProvider { override fun default(): CoroutineDispatcher = testCoroutinesDispatcher override fun io(): CoroutineDispatcher = testCoroutinesDispatcher @@ -40,3 +41,10 @@ class MainDispatcherRule : BeforeEachCallback, AfterEachCallback { fun runTest(testBody: suspend TestScope.() -> Unit) = testScope.runTest(testBody = testBody) } + +class UnMockkAllRule : AfterEachCallback { + + override fun afterEach(context: ExtensionContext?) { + unmockkAll() + } +} diff --git a/core/common/src/main/kotlin/com/manuelnunez/apps/core/common/test/UnMockkAllRule.kt b/core/common/src/main/kotlin/com/manuelnunez/apps/core/common/test/UnMockkAllRule.kt deleted file mode 100644 index 935ccbd..0000000 --- a/core/common/src/main/kotlin/com/manuelnunez/apps/core/common/test/UnMockkAllRule.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.manuelnunez.apps.core.common.test - -import io.mockk.unmockkAll -import org.junit.jupiter.api.extension.AfterEachCallback -import org.junit.jupiter.api.extension.ExtensionContext - -/** - * If you use mockks in your test class, you can add this rule to your test class in order to do an - * unmockkAll after each test execution, This makes the Test class faster. - * - * In the future if we use jUnit5 we could mark our classes with @ExtendWith(MockKExtension::class) - * to achieve the same behaviour - */ -class UnMockkAllRule : AfterEachCallback { - - override fun afterEach(context: ExtensionContext?) { - unmockkAll() - } -} diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index eb40e92..a8cf0be 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -8,13 +8,13 @@ android { namespace = "com.manuelnunez.apps.core.data" compileSdk = 34 - defaultConfig { minSdk = 21 } - compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } + defaultConfig { minSdk = 21 } + kotlinOptions { jvmTarget = JavaVersion.VERSION_17.toString() } tasks.withType { useJUnitPlatform() } @@ -25,7 +25,7 @@ android { dependencies { implementation(projects.core.common) implementation(projects.core.services) - implementation(projects.core.domain) // TODO: data knows domain? + implementation(projects.core.domain) implementation(projects.features.home.domain) implementation(projects.features.seemore.domain) @@ -39,5 +39,6 @@ dependencies { testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.junit) + testImplementation(libs.turbine) testImplementation(libs.mockk) } diff --git a/core/data/src/main/kotlin/com/manuelnunez/apps/core/data/ServiceConstants.kt b/core/data/src/main/kotlin/com/manuelnunez/apps/core/data/ServiceConstants.kt new file mode 100644 index 0000000..b576493 --- /dev/null +++ b/core/data/src/main/kotlin/com/manuelnunez/apps/core/data/ServiceConstants.kt @@ -0,0 +1,4 @@ +package com.manuelnunez.apps.core.data + +const val PAGE_SIZE = 10 +const val PREFETCH_DISTANCE = 5 diff --git a/core/data/src/main/kotlin/com/manuelnunez/apps/core/data/datasource/CataasCatsRemoteDataSource.kt b/core/data/src/main/kotlin/com/manuelnunez/apps/core/data/datasource/CataasCatsRemoteDataSource.kt index 9bc3c6d..e4545fc 100644 --- a/core/data/src/main/kotlin/com/manuelnunez/apps/core/data/datasource/CataasCatsRemoteDataSource.kt +++ b/core/data/src/main/kotlin/com/manuelnunez/apps/core/data/datasource/CataasCatsRemoteDataSource.kt @@ -1,18 +1,28 @@ package com.manuelnunez.apps.core.data.datasource +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData import com.manuelnunez.apps.core.common.Either import com.manuelnunez.apps.core.common.eitherError import com.manuelnunez.apps.core.common.eitherSuccess +import com.manuelnunez.apps.core.data.PAGE_SIZE +import com.manuelnunez.apps.core.data.PREFETCH_DISTANCE +import com.manuelnunez.apps.core.data.datasource.paging.CataasCatsPagingSource +import com.manuelnunez.apps.core.domain.model.Item import com.manuelnunez.apps.core.services.dto.CataasResponseDTO import com.manuelnunez.apps.core.services.executors.RetrofitServiceRequest import com.manuelnunez.apps.core.services.executors.ServiceError import com.manuelnunez.apps.core.services.executors.ServicesExecutor import com.manuelnunez.apps.core.services.service.CataasService import com.manuelnunez.apps.core.services.util.Result +import kotlinx.coroutines.flow.Flow import javax.inject.Inject interface CataasCatsRemoteDataSource { fun getItems(): Either, ServiceError> + + fun getAllItems(): Flow> } class CataasCatsRemoteDataSourceImpl @@ -21,9 +31,15 @@ constructor(private val servicesExecutor: ServicesExecutor, private val apiServi CataasCatsRemoteDataSource { override fun getItems(): Either, ServiceError> { - val response = servicesExecutor.execute(RetrofitServiceRequest(apiService.search())) + val response = servicesExecutor.execute(RetrofitServiceRequest(apiService.searchCats())) return if (response is Result.Success) eitherSuccess(response.data.data) else eitherError((response as Result.Error).exception) } + + override fun getAllItems(): Flow> = + Pager( + config = PagingConfig(pageSize = PAGE_SIZE, prefetchDistance = PREFETCH_DISTANCE), + pagingSourceFactory = { CataasCatsPagingSource(apiService) }) + .flow } diff --git a/core/data/src/main/kotlin/com/manuelnunez/apps/core/data/datasource/PexeelsCatsRemoteDataSource.kt b/core/data/src/main/kotlin/com/manuelnunez/apps/core/data/datasource/PexeelsCatsRemoteDataSource.kt index 4819099..0888244 100644 --- a/core/data/src/main/kotlin/com/manuelnunez/apps/core/data/datasource/PexeelsCatsRemoteDataSource.kt +++ b/core/data/src/main/kotlin/com/manuelnunez/apps/core/data/datasource/PexeelsCatsRemoteDataSource.kt @@ -1,18 +1,28 @@ package com.manuelnunez.apps.core.data.datasource +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData import com.manuelnunez.apps.core.common.Either import com.manuelnunez.apps.core.common.eitherError import com.manuelnunez.apps.core.common.eitherSuccess +import com.manuelnunez.apps.core.data.PAGE_SIZE +import com.manuelnunez.apps.core.data.PREFETCH_DISTANCE +import com.manuelnunez.apps.core.data.datasource.paging.PexeelsCatsPagingSource +import com.manuelnunez.apps.core.domain.model.Item import com.manuelnunez.apps.core.services.dto.PexelsSearchResponseDTO import com.manuelnunez.apps.core.services.executors.RetrofitServiceRequest import com.manuelnunez.apps.core.services.executors.ServiceError import com.manuelnunez.apps.core.services.executors.ServicesExecutor import com.manuelnunez.apps.core.services.service.PexelsService import com.manuelnunez.apps.core.services.util.Result +import kotlinx.coroutines.flow.Flow import javax.inject.Inject interface PexelsCatsRemoteDataSource { fun getItems(): Either + + fun getAllItems(): Flow> } class PexelsCatsRemoteDataSourceImpl @@ -26,4 +36,10 @@ constructor(private val servicesExecutor: ServicesExecutor, private val apiServi return if (response is Result.Success) eitherSuccess(response.data.data) else eitherError((response as Result.Error).exception) } + + override fun getAllItems(): Flow> = + Pager( + config = PagingConfig(pageSize = PAGE_SIZE, prefetchDistance = PREFETCH_DISTANCE), + pagingSourceFactory = { PexeelsCatsPagingSource(apiService) }) + .flow } diff --git a/core/data/src/main/kotlin/com/manuelnunez/apps/core/data/datasource/paging/CataasCatsPagingSource.kt b/core/data/src/main/kotlin/com/manuelnunez/apps/core/data/datasource/paging/CataasCatsPagingSource.kt new file mode 100644 index 0000000..6334690 --- /dev/null +++ b/core/data/src/main/kotlin/com/manuelnunez/apps/core/data/datasource/paging/CataasCatsPagingSource.kt @@ -0,0 +1,46 @@ +package com.manuelnunez.apps.core.data.datasource.paging + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.manuelnunez.apps.core.data.PAGE_SIZE +import com.manuelnunez.apps.core.data.mapper.toItems +import com.manuelnunez.apps.core.domain.model.Item +import com.manuelnunez.apps.core.services.exception.NetworkException +import com.manuelnunez.apps.core.services.exception.ServiceException +import com.manuelnunez.apps.core.services.service.CataasService +import retrofit2.HttpException +import java.io.IOException + +class CataasCatsPagingSource(private val apiService: CataasService) : PagingSource() { + override suspend fun load(params: LoadParams): LoadResult { + return try { + val page = params.key ?: 1 // Default to first page if key is null + + val response = apiService.searchCatsPaginated(skip = page * PAGE_SIZE) + + val items = response.toItems() + LoadResult.Page( + data = items, + prevKey = if (page == 1) null else page - 1, + nextKey = if (items.isEmpty()) null else page + 1) + } catch (exception: NetworkException) { + LoadResult.Error(exception) + } catch (exception: ServiceException) { + LoadResult.Error(exception) + } catch (exception: IOException) { + LoadResult.Error(exception) + } catch (exception: HttpException) { + LoadResult.Error(exception) + } catch (exception: Exception) { + LoadResult.Error(Exception()) + } + } + + // Optional: Implement invalidate to handle refreshing data + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition?.let { anchorPosition -> + state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) + ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) + } + } +} diff --git a/core/data/src/main/kotlin/com/manuelnunez/apps/core/data/datasource/PexeelsCatsPagingSource.kt b/core/data/src/main/kotlin/com/manuelnunez/apps/core/data/datasource/paging/PexeelsCatsPagingSource.kt similarity index 96% rename from core/data/src/main/kotlin/com/manuelnunez/apps/core/data/datasource/PexeelsCatsPagingSource.kt rename to core/data/src/main/kotlin/com/manuelnunez/apps/core/data/datasource/paging/PexeelsCatsPagingSource.kt index bf66bbb..eb20397 100644 --- a/core/data/src/main/kotlin/com/manuelnunez/apps/core/data/datasource/PexeelsCatsPagingSource.kt +++ b/core/data/src/main/kotlin/com/manuelnunez/apps/core/data/datasource/paging/PexeelsCatsPagingSource.kt @@ -1,4 +1,4 @@ -package com.manuelnunez.apps.core.data.datasource +package com.manuelnunez.apps.core.data.datasource.paging import androidx.paging.PagingSource import androidx.paging.PagingState diff --git a/core/data/src/main/kotlin/com/manuelnunez/apps/core/data/di/DataModule.kt b/core/data/src/main/kotlin/com/manuelnunez/apps/core/data/di/DataModule.kt index e517b35..8b7eb99 100644 --- a/core/data/src/main/kotlin/com/manuelnunez/apps/core/data/di/DataModule.kt +++ b/core/data/src/main/kotlin/com/manuelnunez/apps/core/data/di/DataModule.kt @@ -37,10 +37,4 @@ abstract class DataModule { abstract fun provideCataasCatsRemoteDataSource( remoteDataSourceImpl: CataasCatsRemoteDataSourceImpl ): CataasCatsRemoteDataSource - - @Singleton - @Binds - abstract fun provideItemsPagingSource( - remoteDataSourceImpl: CataasCatsRemoteDataSourceImpl - ): CataasCatsRemoteDataSource } diff --git a/core/data/src/main/kotlin/com/manuelnunez/apps/core/data/di/PagingModule.kt b/core/data/src/main/kotlin/com/manuelnunez/apps/core/data/di/PagingModule.kt deleted file mode 100644 index bbf9d46..0000000 --- a/core/data/src/main/kotlin/com/manuelnunez/apps/core/data/di/PagingModule.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.manuelnunez.apps.core.data.di - -import com.manuelnunez.apps.core.data.datasource.PexeelsCatsPagingSource -import com.manuelnunez.apps.core.services.service.PexelsService -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent - -@Module -@InstallIn(SingletonComponent::class) -object PagingModule { - - @Provides - fun providePexeelsCatsPagingSource(apiService: PexelsService): PexeelsCatsPagingSource { - return PexeelsCatsPagingSource(apiService) - } -} diff --git a/core/data/src/main/kotlin/com/manuelnunez/apps/core/data/repository/SeeMoreRepositoryImpl.kt b/core/data/src/main/kotlin/com/manuelnunez/apps/core/data/repository/SeeMoreRepositoryImpl.kt index d9a16ed..deb97ba 100644 --- a/core/data/src/main/kotlin/com/manuelnunez/apps/core/data/repository/SeeMoreRepositoryImpl.kt +++ b/core/data/src/main/kotlin/com/manuelnunez/apps/core/data/repository/SeeMoreRepositoryImpl.kt @@ -1,21 +1,12 @@ package com.manuelnunez.apps.core.data.repository -import androidx.paging.Pager -import androidx.paging.PagingConfig -import com.manuelnunez.apps.core.data.datasource.PexeelsCatsPagingSource +import com.manuelnunez.apps.core.data.datasource.PexelsCatsRemoteDataSource import com.manuelnunez.apps.feature.seemore.domain.repository.SeeMoreRepository import javax.inject.Inject -class SeeMoreRepositoryImpl @Inject constructor(private val pagingSource: PexeelsCatsPagingSource) : - SeeMoreRepository { - override fun getAllItems() = - Pager( - config = PagingConfig(pageSize = PAGE_SIZE, prefetchDistance = PREFETCH_DISTANCE), - pagingSourceFactory = { pagingSource }) - .flow +class SeeMoreRepositoryImpl +@Inject +constructor(private val remoteDataSource: PexelsCatsRemoteDataSource) : SeeMoreRepository { - companion object { - private const val PAGE_SIZE = 10 - private const val PREFETCH_DISTANCE = 5 - } + override fun getAllItems() = remoteDataSource.getAllItems() } diff --git a/core/data/src/test/kotlin/com/manuelnunez/apps/core/data/repository/SeeMoreRepositoryTest.kt b/core/data/src/test/kotlin/com/manuelnunez/apps/core/data/repository/SeeMoreRepositoryTest.kt index 204bcab..cd3567b 100644 --- a/core/data/src/test/kotlin/com/manuelnunez/apps/core/data/repository/SeeMoreRepositoryTest.kt +++ b/core/data/src/test/kotlin/com/manuelnunez/apps/core/data/repository/SeeMoreRepositoryTest.kt @@ -1,14 +1,15 @@ package com.manuelnunez.apps.core.data.repository import androidx.paging.PagingData -import androidx.paging.PagingSource -import com.manuelnunez.apps.core.data.datasource.PexeelsCatsPagingSource +import com.manuelnunez.apps.core.data.datasource.PexelsCatsRemoteDataSource import com.manuelnunez.apps.core.data.utils.mockItems import com.manuelnunez.apps.feature.seemore.domain.repository.SeeMoreRepository -import io.mockk.coEvery +import io.mockk.confirmVerified +import io.mockk.every import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map -import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -16,21 +17,22 @@ import org.junit.jupiter.api.Test class SeeMoreRepositoryTest { private lateinit var repository: SeeMoreRepository - private val pagingSource = mockk() + private val remoteDataSource = mockk() @BeforeEach fun setUp() { - repository = SeeMoreRepositoryImpl(pagingSource) + repository = SeeMoreRepositoryImpl(remoteDataSource) } @Test fun `GIVEN getAllItems call, WHEN success, THEN return items`() { - coEvery { pagingSource.load(any()) } returns - PagingSource.LoadResult.Page(data = mockItems, prevKey = null, nextKey = null) + val expectedResult = PagingData.from(mockItems) - val response = runBlocking { repository.getAllItems() } - val expectingResult = PagingData.from(mockItems) + every { remoteDataSource.getAllItems() } returns flow { emit(expectedResult) } - response.map { assertEquals(expectingResult, it) } + repository.getAllItems().map { assertEquals(expectedResult, it) } + + verify(exactly = 1) { remoteDataSource.getAllItems() } + confirmVerified(remoteDataSource) } } diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts index 5fbeba2..d766e44 100644 --- a/core/domain/build.gradle.kts +++ b/core/domain/build.gradle.kts @@ -9,13 +9,13 @@ android { namespace = "com.manuelnunez.apps.core.domain" compileSdk = 34 - defaultConfig { minSdk = 21 } - compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } + defaultConfig { minSdk = 21 } + kotlinOptions { jvmTarget = JavaVersion.VERSION_17.toString() } packaging { resources { excludes.add("META-INF/{LICENSE-notice.md,LICENSE.md}") } } diff --git a/core/domain/src/main/kotlin/com/manuelnunez/apps/core/domain/usecase/FlowUseCase.kt b/core/domain/src/main/kotlin/com/manuelnunez/apps/core/domain/usecase/FlowUseCase.kt index a9ca46d..ee0ff64 100644 --- a/core/domain/src/main/kotlin/com/manuelnunez/apps/core/domain/usecase/FlowUseCase.kt +++ b/core/domain/src/main/kotlin/com/manuelnunez/apps/core/domain/usecase/FlowUseCase.kt @@ -1,17 +1,19 @@ package com.manuelnunez.apps.core.domain.usecase -import com.manuelnunez.apps.core.common.dispatcher.DispatcherProvider +import com.manuelnunez.apps.core.common.dispatcher.CoroutineDispatcherProvider import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOn import kotlin.coroutines.CoroutineContext -abstract class FlowUseCase(private val dispatcherProvider: DispatcherProvider) { +abstract class FlowUseCase( + private val coroutineDispatcherProvider: CoroutineDispatcherProvider +) { - protected open fun dispatcher(): CoroutineContext = dispatcherProvider.io() + protected open fun dispatcher(): CoroutineContext = coroutineDispatcherProvider.io() /** Returns a [Flow] that will be executed in the specified [CoroutineContext]. */ protected abstract fun execute(input: T): Flow /** Prepares and returns the [Flow] with the specified input. */ - fun prepare(input: T): Flow = execute(input).flowOn(dispatcherProvider.io()) + fun prepare(input: T): Flow = execute(input).flowOn(coroutineDispatcherProvider.io()) } diff --git a/core/services/src/main/kotlin/com/manuelnunez/apps/core/services/di/NetworkModule.kt b/core/services/src/main/kotlin/com/manuelnunez/apps/core/services/di/NetworkModule.kt index 656e229..fccf105 100644 --- a/core/services/src/main/kotlin/com/manuelnunez/apps/core/services/di/NetworkModule.kt +++ b/core/services/src/main/kotlin/com/manuelnunez/apps/core/services/di/NetworkModule.kt @@ -3,7 +3,7 @@ package com.manuelnunez.apps.core.services.di import android.content.Context import com.google.gson.Gson import com.google.gson.GsonBuilder -import com.manuelnunez.apps.core.services.executors.RetrofitServicesExecutor +import com.manuelnunez.apps.core.services.executors.ServiceExecutorRetrofitImpl import com.manuelnunez.apps.core.services.executors.ServicesExecutor import com.manuelnunez.apps.core.services.interceptor.ConnectivityInterceptor import com.manuelnunez.apps.core.services.interceptor.PexelsKeyAuthenticator @@ -25,8 +25,13 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) @Module object NetworkModule { - private const val BASE_URL = "https://api.pexels.com/v1/" - private const val BASE_URL2 = "https://cataas.com/" + /** + * API interchangeable. In case of using PEXELS API, use PEXELS_BASE_URL with the + * PexelKeyAuthenticator interceptor. In case of using CATAAS API, use CATAAS_BASE_URL, an remove + * PexelsKeyAuth interceptor. Finally, select RemoteDataSources accordingly. + */ + private const val PEXELS_BASE_URL = "https://api.pexels.com/v1/" + private const val CATAAS_BASE_URL = "https://cataas.com/" @Provides @Singleton @@ -35,21 +40,15 @@ object NetworkModule { .addInterceptor(HttpLoggingInterceptor().setLevel(Level.BODY)) .addInterceptor(connectivityInterceptor) .authenticator(PexelsKeyAuthenticator()) - .connectTimeout(60, TimeUnit.SECONDS) - .readTimeout(60, TimeUnit.SECONDS) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) .build() - @Singleton - @Provides - fun provideGson(): Gson { - return GsonBuilder().create() - } - @Singleton @Provides fun provideRetrofit(okHttpClient: OkHttpClient, gson: Gson): Retrofit { return Retrofit.Builder() - .baseUrl(BASE_URL) + .baseUrl(PEXELS_BASE_URL) .addConverterFactory(GsonConverterFactory.create(gson)) .client(okHttpClient) .build() @@ -73,5 +72,13 @@ object NetworkModule { return ConnectivityInterceptor(context) } - @Provides @Singleton fun provideServicesExecutor(): ServicesExecutor = RetrofitServicesExecutor() + @Provides + @Singleton + fun provideServicesExecutor(): ServicesExecutor = ServiceExecutorRetrofitImpl() + + @Singleton + @Provides + fun provideGson(): Gson { + return GsonBuilder().create() + } } diff --git a/core/services/src/main/kotlin/com/manuelnunez/apps/core/services/executors/RetrofitServiceExecutor.kt b/core/services/src/main/kotlin/com/manuelnunez/apps/core/services/executors/ServiceExecutorRetrofitImpl.kt similarity index 50% rename from core/services/src/main/kotlin/com/manuelnunez/apps/core/services/executors/RetrofitServiceExecutor.kt rename to core/services/src/main/kotlin/com/manuelnunez/apps/core/services/executors/ServiceExecutorRetrofitImpl.kt index e4db24b..6df6ea0 100644 --- a/core/services/src/main/kotlin/com/manuelnunez/apps/core/services/executors/RetrofitServiceExecutor.kt +++ b/core/services/src/main/kotlin/com/manuelnunez/apps/core/services/executors/ServiceExecutorRetrofitImpl.kt @@ -1,26 +1,37 @@ package com.manuelnunez.apps.core.services.executors import com.google.gson.JsonSyntaxException +import com.manuelnunez.apps.core.services.exception.NetworkException import com.manuelnunez.apps.core.services.util.Result import retrofit2.Call +import java.util.concurrent.TimeoutException -class RetrofitServicesExecutor : ServicesExecutor { +class ServiceExecutorRetrofitImpl : ServicesExecutor { override fun execute(request: ServiceRequest): Result> { if (request !is RetrofitServiceRequest) { - throw IllegalArgumentException("RetrofitServicesExecutor only accepts RetrofitServiceRequest") + throw IllegalArgumentException("Accepted only RetrofitServiceRequest") } return try { val response = request.retrofitCall.execute() if (response.isSuccessful) { Result.Success( - ServiceResponse(response.body(), response.code(), response.headers().toMultimap())) + ServiceResponse( + optData = response.body(), + statusCode = response.code(), + headers = response.headers().toMultimap())) } else { Result.Error( ServiceError( - response.errorBody()?.string(), response.code(), response.headers().toMultimap())) + description = response.errorBody()?.string(), + statusCode = response.code(), + headers = response.headers().toMultimap())) } + } catch (ex: TimeoutException) { + Result.Error(ServiceError("Timeout error: ${ex.message}", -1, emptyMap())) + } catch (ex: NetworkException) { + Result.Error(ServiceError("Network error: ${ex.message}", -1, emptyMap())) } catch (ex: JsonSyntaxException) { Result.Error(ServiceError("Json data parsing error: ${ex.message}", -1, emptyMap())) } catch (e: Exception) { diff --git a/core/services/src/main/kotlin/com/manuelnunez/apps/core/services/service/CataasService.kt b/core/services/src/main/kotlin/com/manuelnunez/apps/core/services/service/CataasService.kt index d5d65be..d5d17b7 100644 --- a/core/services/src/main/kotlin/com/manuelnunez/apps/core/services/service/CataasService.kt +++ b/core/services/src/main/kotlin/com/manuelnunez/apps/core/services/service/CataasService.kt @@ -10,10 +10,16 @@ import retrofit2.http.Query interface CataasService { @GET("api/cats") - fun search( + fun searchCats( @Query("skip") skip: Int = 0, @Query("limit") limit: Int = 10 ): Call> + @GET("api/cats") + suspend fun searchCatsPaginated( + @Query("skip") skip: Int = 0, + @Query("limit") limit: Int = 10 + ): List + @GET("photos/{id}") fun searchCatsById(@Path("id") id: Long): Call } diff --git a/features/detail/ui/build.gradle.kts b/features/detail/ui/build.gradle.kts index 370a70a..daa113e 100644 --- a/features/detail/ui/build.gradle.kts +++ b/features/detail/ui/build.gradle.kts @@ -8,28 +8,27 @@ android { namespace = "com.manuelnunez.apps.features.detail.ui" compileSdk = 34 - defaultConfig { - minSdk = 21 - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - - buildTypes { release { isMinifyEnabled = false } } - compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } - composeOptions { kotlinCompilerExtensionVersion = libs.versions.androidxComposeCompiler.get() } + defaultConfig { + minSdk = 21 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } kotlinOptions { jvmTarget = JavaVersion.VERSION_17.toString() } buildFeatures { compose = true } + composeOptions { kotlinCompilerExtensionVersion = libs.versions.androidxComposeCompiler.get() } + tasks.withType { useJUnitPlatform() } packaging { resources { excludes.add("META-INF/{LICENSE-notice.md,LICENSE.md}") } } + + buildTypes { release { isMinifyEnabled = false } } } dependencies { diff --git a/features/detail/ui/src/androidTest/kotlin/com/manuelnunez/apps/feature/detail/ui/DetailViewTest.kt b/features/detail/ui/src/androidTest/kotlin/com/manuelnunez/apps/feature/detail/ui/DetailViewTest.kt index 7d79b8c..dcfdccd 100644 --- a/features/detail/ui/src/androidTest/kotlin/com/manuelnunez/apps/feature/detail/ui/DetailViewTest.kt +++ b/features/detail/ui/src/androidTest/kotlin/com/manuelnunez/apps/feature/detail/ui/DetailViewTest.kt @@ -6,12 +6,12 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import com.manuelnunez.apps.core.domain.model.Item -import com.manuelnunez.apps.core.ui.R as RCU import com.manuelnunez.apps.feature.detail.ui.components.DetailErrorScreen import com.manuelnunez.apps.feature.detail.ui.components.DetailScreen import com.manuelnunez.apps.features.detail.ui.R import org.junit.Rule import org.junit.Test +import com.manuelnunez.apps.core.ui.R as RCU class DetailViewTest { diff --git a/features/detail/ui/src/main/kotlin/com/manuelnunez/apps/feature/detail/ui/components/DetailScreen.kt b/features/detail/ui/src/main/kotlin/com/manuelnunez/apps/feature/detail/ui/components/DetailScreen.kt index f2be927..cf35c5f 100644 --- a/features/detail/ui/src/main/kotlin/com/manuelnunez/apps/feature/detail/ui/components/DetailScreen.kt +++ b/features/detail/ui/src/main/kotlin/com/manuelnunez/apps/feature/detail/ui/components/DetailScreen.kt @@ -8,10 +8,11 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.windowInsetsBottomHeight -import androidx.compose.foundation.layout.windowInsetsTopHeight +import androidx.compose.foundation.layout.safeContent +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack @@ -52,13 +53,15 @@ fun DetailScreen(item: Item, onBackClick: () -> Unit) { @Composable private fun DetailPortrait(item: Item, onBackClick: () -> Unit) { - Column { - Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) + Column(Modifier.windowInsetsPadding(WindowInsets.safeContent)) { + Spacer(modifier = Modifier.height(20.dp)) DetailToolbar(onBackClick) + Spacer(modifier = Modifier.height(10.dp)) + Column( - modifier = Modifier.weight(1f).wrapContentSize(), + modifier = Modifier.weight(1f).wrapContentSize().heightIn(100.dp), horizontalAlignment = Alignment.CenterHorizontally) { DynamicAsyncImage( modifier = Modifier.fillMaxWidth().padding(horizontal = 6.dp), @@ -74,18 +77,16 @@ private fun DetailPortrait(item: Item, onBackClick: () -> Unit) { text = item.description, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface) - - Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) } - Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) + Spacer(modifier = Modifier.height(20.dp)) } } @Composable private fun DetailToolbar(onBackClick: () -> Unit) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = { onBackClick() }) { + IconButton(onClick = onBackClick) { Icon( imageVector = Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(id = RCU.string.button_back), @@ -98,11 +99,13 @@ private fun DetailToolbar(onBackClick: () -> Unit) { @Composable private fun DetailLandscape(item: Item, onBackClick: () -> Unit) { - Column(modifier = Modifier.fillMaxSize()) { - Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) + Column(modifier = Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeContent)) { + Spacer(modifier = Modifier.height(20.dp)) DetailToolbar(onBackClick) + Spacer(modifier = Modifier.height(10.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { DynamicAsyncImage( modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp).weight(0.7f), @@ -123,7 +126,7 @@ private fun DetailLandscape(item: Item, onBackClick: () -> Unit) { style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface) } - Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) + Spacer(modifier = Modifier.height(20.dp)) } } diff --git a/features/detail/ui/src/main/kotlin/com/manuelnunez/apps/feature/detail/ui/navigation/DetailNavigation.kt b/features/detail/ui/src/main/kotlin/com/manuelnunez/apps/feature/detail/ui/navigation/DetailNavigation.kt index 006935b..1758f3b 100644 --- a/features/detail/ui/src/main/kotlin/com/manuelnunez/apps/feature/detail/ui/navigation/DetailNavigation.kt +++ b/features/detail/ui/src/main/kotlin/com/manuelnunez/apps/feature/detail/ui/navigation/DetailNavigation.kt @@ -6,8 +6,8 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptionsBuilder import androidx.navigation.compose.composable import androidx.navigation.navOptions +import com.manuelnunez.apps.core.common.navigation.navigate import com.manuelnunez.apps.core.domain.model.Item -import com.manuelnunez.apps.core.ui.navigate import com.manuelnunez.apps.feature.detail.ui.DetailView const val DETAIL_ITEM = "myItem" diff --git a/features/home/domain/build.gradle.kts b/features/home/domain/build.gradle.kts index cac631a..4a25a1b 100644 --- a/features/home/domain/build.gradle.kts +++ b/features/home/domain/build.gradle.kts @@ -8,13 +8,13 @@ android { namespace = "com.manuelnunez.apps.features.home.domain" compileSdk = 34 - defaultConfig { minSdk = 21 } - compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } + defaultConfig { minSdk = 21 } + kotlinOptions { jvmTarget = JavaVersion.VERSION_17.toString() } tasks.withType { useJUnitPlatform() } diff --git a/features/home/domain/src/main/kotlin/com/manuelnunez/apps/features/home/domain/usecase/GetFeaturedItemsUseCase.kt b/features/home/domain/src/main/kotlin/com/manuelnunez/apps/features/home/domain/usecase/GetFeaturedItemsUseCase.kt index deb0d00..48b091f 100644 --- a/features/home/domain/src/main/kotlin/com/manuelnunez/apps/features/home/domain/usecase/GetFeaturedItemsUseCase.kt +++ b/features/home/domain/src/main/kotlin/com/manuelnunez/apps/features/home/domain/usecase/GetFeaturedItemsUseCase.kt @@ -1,7 +1,7 @@ package com.manuelnunez.apps.features.home.domain.usecase import com.manuelnunez.apps.core.common.Either -import com.manuelnunez.apps.core.common.dispatcher.DispatcherProvider +import com.manuelnunez.apps.core.common.dispatcher.CoroutineDispatcherProvider import com.manuelnunez.apps.core.domain.model.ErrorModel import com.manuelnunez.apps.core.domain.model.Item import com.manuelnunez.apps.core.domain.usecase.FlowUseCase @@ -12,8 +12,10 @@ import javax.inject.Inject class GetFeaturedItemsUseCase @Inject -constructor(private val homeRepository: HomeRepository, dispatcherProvider: DispatcherProvider) : - FlowUseCase, ErrorModel>>(dispatcherProvider) { +constructor( + private val homeRepository: HomeRepository, + coroutineDispatcherProvider: CoroutineDispatcherProvider +) : FlowUseCase, ErrorModel>>(coroutineDispatcherProvider) { override fun execute(input: Unit): Flow, ErrorModel>> = flow { emit(homeRepository.getFeaturedItems()) diff --git a/features/home/domain/src/main/kotlin/com/manuelnunez/apps/features/home/domain/usecase/GetPopularItemsUseCase.kt b/features/home/domain/src/main/kotlin/com/manuelnunez/apps/features/home/domain/usecase/GetPopularItemsUseCase.kt index 3c530eb..5aacc6e 100644 --- a/features/home/domain/src/main/kotlin/com/manuelnunez/apps/features/home/domain/usecase/GetPopularItemsUseCase.kt +++ b/features/home/domain/src/main/kotlin/com/manuelnunez/apps/features/home/domain/usecase/GetPopularItemsUseCase.kt @@ -1,7 +1,7 @@ package com.manuelnunez.apps.features.home.domain.usecase import com.manuelnunez.apps.core.common.Either -import com.manuelnunez.apps.core.common.dispatcher.DispatcherProvider +import com.manuelnunez.apps.core.common.dispatcher.CoroutineDispatcherProvider import com.manuelnunez.apps.core.domain.model.ErrorModel import com.manuelnunez.apps.core.domain.model.Item import com.manuelnunez.apps.core.domain.usecase.FlowUseCase @@ -12,8 +12,10 @@ import javax.inject.Inject class GetPopularItemsUseCase @Inject -constructor(private val homeRepository: HomeRepository, dispatcherProvider: DispatcherProvider) : - FlowUseCase, ErrorModel>>(dispatcherProvider) { +constructor( + private val homeRepository: HomeRepository, + coroutineDispatcherProvider: CoroutineDispatcherProvider +) : FlowUseCase, ErrorModel>>(coroutineDispatcherProvider) { override fun execute(input: Unit): Flow, ErrorModel>> = flow { emit(homeRepository.getPopularItems()) diff --git a/features/home/domain/src/test/kotlin/com/manuelnunez/apps/features/home/domain/usecase/GetFeaturedItemsUseCaseTest.kt b/features/home/domain/src/test/kotlin/com/manuelnunez/apps/features/home/domain/usecase/GetFeaturedItemsUseCaseTest.kt index 5b52b47..85d1962 100644 --- a/features/home/domain/src/test/kotlin/com/manuelnunez/apps/features/home/domain/usecase/GetFeaturedItemsUseCaseTest.kt +++ b/features/home/domain/src/test/kotlin/com/manuelnunez/apps/features/home/domain/usecase/GetFeaturedItemsUseCaseTest.kt @@ -1,7 +1,7 @@ package com.manuelnunez.apps.features.home.domain.usecase import app.cash.turbine.test -import com.manuelnunez.apps.core.common.test.MainDispatcherRule +import com.manuelnunez.apps.core.common.test.MockkAllRule import com.manuelnunez.apps.core.common.test.UnMockkAllRule import com.manuelnunez.apps.features.home.domain.repository.HomeRepository import io.mockk.confirmVerified @@ -14,7 +14,7 @@ import org.junit.jupiter.api.extension.RegisterExtension @OptIn(ExperimentalCoroutinesApi::class) class GetFeaturedItemsUseCaseTest { - @RegisterExtension private val mainDispatcherRule = MainDispatcherRule() + @RegisterExtension private val mockkAllExtension = MockkAllRule() @RegisterExtension private val unMockkAllExtension = UnMockkAllRule() private val homeRepository = mockk() @@ -22,12 +22,13 @@ class GetFeaturedItemsUseCaseTest { @BeforeEach fun setUp() { - useCase = GetFeaturedItemsUseCase(homeRepository, mainDispatcherRule.testDispatcherProvider) + useCase = + GetFeaturedItemsUseCase(homeRepository, mockkAllExtension.testCoroutineDispatcherProvider) } @Test fun `call GetFeaturedItemsUseCase invokes getFeatureItems from repository`() = - mainDispatcherRule.runTest { + mockkAllExtension.runTest { useCase.prepare(Unit).test {} verify(exactly = 1) { homeRepository.getFeaturedItems() } diff --git a/features/home/domain/src/test/kotlin/com/manuelnunez/apps/features/home/domain/usecase/GetPopularItemsUseCaseTest.kt b/features/home/domain/src/test/kotlin/com/manuelnunez/apps/features/home/domain/usecase/GetPopularItemsUseCaseTest.kt index a7b3539..97b31ab 100644 --- a/features/home/domain/src/test/kotlin/com/manuelnunez/apps/features/home/domain/usecase/GetPopularItemsUseCaseTest.kt +++ b/features/home/domain/src/test/kotlin/com/manuelnunez/apps/features/home/domain/usecase/GetPopularItemsUseCaseTest.kt @@ -1,7 +1,7 @@ package com.manuelnunez.apps.features.home.domain.usecase import app.cash.turbine.test -import com.manuelnunez.apps.core.common.test.MainDispatcherRule +import com.manuelnunez.apps.core.common.test.MockkAllRule import com.manuelnunez.apps.core.common.test.UnMockkAllRule import com.manuelnunez.apps.features.home.domain.repository.HomeRepository import io.mockk.confirmVerified @@ -14,7 +14,7 @@ import org.junit.jupiter.api.extension.RegisterExtension @OptIn(ExperimentalCoroutinesApi::class) class GetPopularItemsUseCaseTest { - @RegisterExtension private val mainDispatcherRule = MainDispatcherRule() + @RegisterExtension private val mockkAllExtension = MockkAllRule() @RegisterExtension private val unMockkAllExtension = UnMockkAllRule() private val homeRepository = mockk() @@ -22,12 +22,13 @@ class GetPopularItemsUseCaseTest { @BeforeEach fun setUp() { - useCase = GetPopularItemsUseCase(homeRepository, mainDispatcherRule.testDispatcherProvider) + useCase = + GetPopularItemsUseCase(homeRepository, mockkAllExtension.testCoroutineDispatcherProvider) } @Test fun `call GetItemUseCase invokes getFeatureItems from repository`() = - mainDispatcherRule.runTest { + mockkAllExtension.runTest { useCase.prepare(Unit).test {} verify(exactly = 1) { homeRepository.getPopularItems() } diff --git a/features/home/ui/build.gradle.kts b/features/home/ui/build.gradle.kts index ef7ba80..14eb1ab 100644 --- a/features/home/ui/build.gradle.kts +++ b/features/home/ui/build.gradle.kts @@ -8,19 +8,18 @@ android { namespace = "com.manuelnunez.apps.features.home.ui" compileSdk = 34 + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + defaultConfig { minSdk = 21 - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { isMinifyEnabled = false } } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - composeOptions { kotlinCompilerExtensionVersion = libs.versions.androidxComposeCompiler.get() } kotlinOptions { jvmTarget = JavaVersion.VERSION_17.toString() } diff --git a/features/home/ui/src/androidTest/kotlin/com/manuelnunez/apps/features/home/ui/HomeViewTest.kt b/features/home/ui/src/androidTest/kotlin/com/manuelnunez/apps/features/home/ui/HomeViewTest.kt index 1a1b742..6c9e24c 100644 --- a/features/home/ui/src/androidTest/kotlin/com/manuelnunez/apps/features/home/ui/HomeViewTest.kt +++ b/features/home/ui/src/androidTest/kotlin/com/manuelnunez/apps/features/home/ui/HomeViewTest.kt @@ -5,13 +5,13 @@ import androidx.compose.ui.test.assertHasClickAction import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText -import com.manuelnunez.apps.core.ui.R as RCU import com.manuelnunez.apps.features.home.ui.components.HomeErrorScreen import com.manuelnunez.apps.features.home.ui.components.HomeScreen import com.manuelnunez.apps.features.home.ui.utils.mockFeaturedPhotos import com.manuelnunez.apps.features.home.ui.utils.mockPopularPhotos import org.junit.Rule import org.junit.Test +import com.manuelnunez.apps.core.ui.R as RCU class HomeViewTest { @@ -31,14 +31,14 @@ class HomeViewTest { // Feature loader composeTestRule .onNodeWithContentDescription( - composeTestRule.activity.resources.getString(R.string.section_feature), + composeTestRule.activity.resources.getString(RCU.string.section_feature), ) .assertExists() // Popular loader composeTestRule .onNodeWithContentDescription( - composeTestRule.activity.resources.getString(R.string.section_popular), + composeTestRule.activity.resources.getString(RCU.string.section_popular), ) .assertExists() } @@ -60,7 +60,7 @@ class HomeViewTest { // Feature title composeTestRule .onNodeWithText( - composeTestRule.activity.resources.getString(R.string.section_feature), + composeTestRule.activity.resources.getString(RCU.string.section_feature), substring = true, ) .assertExists() @@ -76,7 +76,7 @@ class HomeViewTest { // Popular title composeTestRule .onNodeWithText( - composeTestRule.activity.resources.getString(R.string.section_popular), + composeTestRule.activity.resources.getString(RCU.string.section_popular), substring = true, ) .assertExists() @@ -100,14 +100,6 @@ class HomeViewTest { substring = true, ) .assertExists() - - composeTestRule - .onNodeWithContentDescription( - composeTestRule.activity.resources.getString(RCU.string.button_retry), - substring = true, - ) - .assertExists() - .assertHasClickAction() } @Test diff --git a/features/home/ui/src/main/kotlin/com/manuelnunez/apps/features/home/ui/components/HomeErrorScreen.kt b/features/home/ui/src/main/kotlin/com/manuelnunez/apps/features/home/ui/components/HomeErrorScreen.kt index 5c07f7a..d094fea 100644 --- a/features/home/ui/src/main/kotlin/com/manuelnunez/apps/features/home/ui/components/HomeErrorScreen.kt +++ b/features/home/ui/src/main/kotlin/com/manuelnunez/apps/features/home/ui/components/HomeErrorScreen.kt @@ -1,12 +1,14 @@ package com.manuelnunez.apps.features.home.ui.components +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.manuelnunez.apps.core.ui.component.ErrorDialog -import com.manuelnunez.apps.core.ui.component.TitleText +import com.manuelnunez.apps.core.ui.component.ErrorText import com.manuelnunez.apps.core.ui.theme.MainTheme import com.manuelnunez.apps.core.ui.utils.FontScalingPreviews import com.manuelnunez.apps.core.ui.utils.ThemePreviews @@ -23,15 +25,17 @@ fun HomeErrorScreen(retry: () -> Unit) { @Composable fun FeatureError() { - TitleText( - modifier = Modifier.padding(vertical = 6.dp, horizontal = 20.dp), + ErrorText( + modifier = Modifier.padding(vertical = 6.dp, horizontal = 20.dp).fillMaxWidth(), + textAlign = TextAlign.Center, title = stringResource(id = R.string.alert_error_feature)) } @Composable fun PopularError() { - TitleText( - modifier = Modifier.padding(vertical = 6.dp, horizontal = 20.dp), + ErrorText( + modifier = Modifier.padding(vertical = 6.dp, horizontal = 20.dp).fillMaxWidth(), + textAlign = TextAlign.Center, title = stringResource(id = R.string.alert_error_popular)) } diff --git a/features/home/ui/src/main/kotlin/com/manuelnunez/apps/features/home/ui/components/HomeScreen.kt b/features/home/ui/src/main/kotlin/com/manuelnunez/apps/features/home/ui/components/HomeScreen.kt index b671bdd..33f75c5 100644 --- a/features/home/ui/src/main/kotlin/com/manuelnunez/apps/features/home/ui/components/HomeScreen.kt +++ b/features/home/ui/src/main/kotlin/com/manuelnunez/apps/features/home/ui/components/HomeScreen.kt @@ -9,9 +9,9 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.safeContent import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items @@ -37,6 +37,7 @@ import com.manuelnunez.apps.features.home.ui.HomeScreenViewModel.FeaturedItemsSt import com.manuelnunez.apps.features.home.ui.HomeScreenViewModel.HomeUiState import com.manuelnunez.apps.features.home.ui.HomeScreenViewModel.PopularItemsState import com.manuelnunez.apps.features.home.ui.R +import com.manuelnunez.apps.core.ui.R as RCU @Composable fun HomeScreen( @@ -44,35 +45,35 @@ fun HomeScreen( navigateToDetails: (Item) -> Unit, navigateToSeeMore: () -> Unit ) { - LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(vertical = 40.dp)) { - when (items.featuredItemsState) { - is FeaturedItemsState.ShowList -> - item { FeaturedItem(items.featuredItemsState.items, navigateToDetails) } - FeaturedItemsState.Loading -> - item { - LoadingIndicator( - loaderContentDescription = stringResource(id = R.string.section_feature)) - } - FeaturedItemsState.Error -> item { FeatureError() } - else -> {} - } + LazyColumn( + modifier = Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeContent), + contentPadding = PaddingValues(vertical = 20.dp)) { + when (items.featuredItemsState) { + is FeaturedItemsState.ShowList -> + item { FeaturedItem(items.featuredItemsState.items, navigateToDetails) } + FeaturedItemsState.Loading -> + item { + LoadingIndicator( + loaderContentDescription = stringResource(id = RCU.string.section_feature)) + } + FeaturedItemsState.Error -> item { FeatureError() } + else -> {} + } - when (items.popularItemsState) { - is PopularItemsState.ShowList -> - item { PopularItem(items.popularItemsState.items, navigateToDetails, navigateToSeeMore) } - PopularItemsState.Loading -> - item { - LoadingIndicator( - loaderContentDescription = stringResource(id = R.string.section_popular)) - } - PopularItemsState.Error -> { - item { PopularError() } + when (items.popularItemsState) { + is PopularItemsState.ShowList -> + item { + PopularItem(items.popularItemsState.items, navigateToDetails, navigateToSeeMore) + } + PopularItemsState.Loading -> + item { + LoadingIndicator( + loaderContentDescription = stringResource(id = RCU.string.section_popular)) + } + PopularItemsState.Error -> item { PopularError() } + else -> {} + } } - else -> {} - } - - item { Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) } - } } @Composable @@ -80,7 +81,9 @@ private fun FeaturedItem(items: List, navigateToDetails: (Item) -> Unit) { Column { TitleText( modifier = Modifier.padding(vertical = 6.dp, horizontal = 20.dp), - title = stringResource(id = R.string.section_feature)) + title = stringResource(id = RCU.string.section_feature)) + + Spacer(modifier = Modifier.height(10.dp)) LazyRow( modifier = Modifier.fillMaxSize(), @@ -114,7 +117,9 @@ private fun PopularItem( Column { TitleText( modifier = Modifier.padding(vertical = 6.dp, horizontal = 20.dp), - title = stringResource(id = R.string.section_popular)) + title = stringResource(id = RCU.string.section_popular)) + + Spacer(modifier = Modifier.height(10.dp)) AdaptableVerticalGrid( modifier = Modifier.padding(horizontal = gridPadding), @@ -132,15 +137,13 @@ private fun PopularItem( imageUrl = item.thumbnailUrl) } - if (items.size >= 10) { // TODO: check popular size with a Constant? - TextCard( - modifier = - Modifier.size(height = itemHeight, width = itemWidth) - .padding(horizontal = horizontalMarginItem) - .padding(bottom = verticalMarginItem), - onClick = navigateToSeeMore, - text = stringResource(id = R.string.see_more_text)) - } + TextCard( + modifier = + Modifier.size(height = itemHeight, width = itemWidth) + .padding(horizontal = horizontalMarginItem) + .padding(bottom = verticalMarginItem), + onClick = navigateToSeeMore, + text = stringResource(id = R.string.see_more_text)) } } } diff --git a/features/home/ui/src/main/res/values/strings.xml b/features/home/ui/src/main/res/values/strings.xml index 7923cc4..2e9e4c9 100644 --- a/features/home/ui/src/main/res/values/strings.xml +++ b/features/home/ui/src/main/res/values/strings.xml @@ -1,6 +1,4 @@ - Featured - Popular See more An Error has occurred in Featured section An Error has occurred in Popular section diff --git a/features/home/ui/src/test/kotlin/com/manuelnunez/apps/features/home/ui/viewmodel/HomeScreenViewModelTest.kt b/features/home/ui/src/test/kotlin/com/manuelnunez/apps/features/home/ui/viewmodel/HomeScreenViewModelTest.kt index b0fed17..4d8c002 100644 --- a/features/home/ui/src/test/kotlin/com/manuelnunez/apps/features/home/ui/viewmodel/HomeScreenViewModelTest.kt +++ b/features/home/ui/src/test/kotlin/com/manuelnunez/apps/features/home/ui/viewmodel/HomeScreenViewModelTest.kt @@ -3,7 +3,7 @@ package com.manuelnunez.apps.features.home.ui.viewmodel import app.cash.turbine.test import com.manuelnunez.apps.core.common.eitherError import com.manuelnunez.apps.core.common.eitherSuccess -import com.manuelnunez.apps.core.common.test.MainDispatcherRule +import com.manuelnunez.apps.core.common.test.MockkAllRule import com.manuelnunez.apps.core.common.test.UnMockkAllRule import com.manuelnunez.apps.core.domain.model.ErrorModel import com.manuelnunez.apps.core.domain.model.Item @@ -26,7 +26,7 @@ import kotlin.properties.Delegates @OptIn(ExperimentalCoroutinesApi::class) class HomeScreenViewModelTest { - @RegisterExtension private val mainDispatcherRule = MainDispatcherRule() + @RegisterExtension private val mockkAllExtension = MockkAllRule() @RegisterExtension private val unMockkAllExtension = UnMockkAllRule() private val getFeaturedItemsUseCase = mockk() @@ -36,7 +36,7 @@ class HomeScreenViewModelTest { @Test fun `GIVEN viewmodel init, WHEN onSuccess, THEN set state with popular and featured items`() = - mainDispatcherRule.runTest { + mockkAllExtension.runTest { every { getFeaturedItemsUseCase.prepare(Unit) } returns flow { emit(eitherSuccess(mockPhotos)) } every { getPopularItemsUseCase.prepare(Unit) } returns @@ -70,7 +70,7 @@ class HomeScreenViewModelTest { @Test fun `GIVEN viewmodel init, WHEN onFailure, THEN set state with ERROR`() = - mainDispatcherRule.runTest { + mockkAllExtension.runTest { every { getFeaturedItemsUseCase.prepare(Unit) } returns flow { emit(eitherError(ErrorModel.ServiceError)) } every { getPopularItemsUseCase.prepare(Unit) } returns diff --git a/features/seemore/domain/build.gradle.kts b/features/seemore/domain/build.gradle.kts index 6d2e226..c1eb6fb 100644 --- a/features/seemore/domain/build.gradle.kts +++ b/features/seemore/domain/build.gradle.kts @@ -8,13 +8,13 @@ android { namespace = "com.manuelnunez.apps.features.seemore.domain" compileSdk = 34 - defaultConfig { minSdk = 21 } - compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } + defaultConfig { minSdk = 21 } + kotlinOptions { jvmTarget = JavaVersion.VERSION_17.toString() } tasks.withType { useJUnitPlatform() } diff --git a/features/seemore/domain/src/main/kotlin/com/manuelnunez/apps/feature/seemore/domain/usecase/GetAllItemUseCase.kt b/features/seemore/domain/src/main/kotlin/com/manuelnunez/apps/feature/seemore/domain/usecase/GetAllItemUseCase.kt index 3c44f92..20cad66 100644 --- a/features/seemore/domain/src/main/kotlin/com/manuelnunez/apps/feature/seemore/domain/usecase/GetAllItemUseCase.kt +++ b/features/seemore/domain/src/main/kotlin/com/manuelnunez/apps/feature/seemore/domain/usecase/GetAllItemUseCase.kt @@ -1,7 +1,7 @@ package com.manuelnunez.apps.feature.seemore.domain.usecase import androidx.paging.PagingData -import com.manuelnunez.apps.core.common.dispatcher.DispatcherProvider +import com.manuelnunez.apps.core.common.dispatcher.CoroutineDispatcherProvider import com.manuelnunez.apps.core.domain.model.Item import com.manuelnunez.apps.core.domain.usecase.FlowUseCase import com.manuelnunez.apps.feature.seemore.domain.repository.SeeMoreRepository @@ -12,8 +12,8 @@ class GetAllItemUseCase @Inject constructor( private val seeMoreRepository: SeeMoreRepository, - dispatcherProvider: DispatcherProvider -) : FlowUseCase>(dispatcherProvider) { + coroutineDispatcherProvider: CoroutineDispatcherProvider +) : FlowUseCase>(coroutineDispatcherProvider) { override fun execute(input: Unit): Flow> = seeMoreRepository.getAllItems() } diff --git a/features/seemore/domain/src/test/kotlin/com/manuelnunez/apps/feature/seemore/domain/GetAllItemUseCaseTest.kt b/features/seemore/domain/src/test/kotlin/com/manuelnunez/apps/feature/seemore/domain/GetAllItemUseCaseTest.kt index 2274275..d97e480 100644 --- a/features/seemore/domain/src/test/kotlin/com/manuelnunez/apps/feature/seemore/domain/GetAllItemUseCaseTest.kt +++ b/features/seemore/domain/src/test/kotlin/com/manuelnunez/apps/feature/seemore/domain/GetAllItemUseCaseTest.kt @@ -2,7 +2,7 @@ package com.manuelnunez.apps.feature.seemore.domain import androidx.paging.PagingData import app.cash.turbine.test -import com.manuelnunez.apps.core.common.test.MainDispatcherRule +import com.manuelnunez.apps.core.common.test.MockkAllRule import com.manuelnunez.apps.core.common.test.UnMockkAllRule import com.manuelnunez.apps.feature.seemore.domain.repository.SeeMoreRepository import com.manuelnunez.apps.feature.seemore.domain.usecase.GetAllItemUseCase @@ -18,7 +18,7 @@ import org.junit.jupiter.api.extension.RegisterExtension @OptIn(ExperimentalCoroutinesApi::class) class GetAllItemUseCaseTest { - @RegisterExtension private val mainDispatcherRule = MainDispatcherRule() + @RegisterExtension private val mockkAllExtension = MockkAllRule() @RegisterExtension private val unMockkAllExtension = UnMockkAllRule() private val seeMoreRepository = mockk() @@ -26,12 +26,13 @@ class GetAllItemUseCaseTest { @BeforeEach fun setUp() { - useCase = GetAllItemUseCase(seeMoreRepository, mainDispatcherRule.testDispatcherProvider) + useCase = + GetAllItemUseCase(seeMoreRepository, mockkAllExtension.testCoroutineDispatcherProvider) } @Test fun `call GetItemUseCase invokes getFeatureItems from repository`() = - mainDispatcherRule.runTest { + mockkAllExtension.runTest { every { seeMoreRepository.getAllItems() } returns flow { emit(PagingData.empty()) } useCase.prepare(Unit).test {} diff --git a/features/seemore/ui/build.gradle.kts b/features/seemore/ui/build.gradle.kts index c1f1c38..39e3ff9 100644 --- a/features/seemore/ui/build.gradle.kts +++ b/features/seemore/ui/build.gradle.kts @@ -8,6 +8,11 @@ android { namespace = "com.manuelnunez.apps.features.seemore.ui" compileSdk = 34 + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + defaultConfig { minSdk = 21 @@ -16,11 +21,6 @@ android { buildTypes { release { isMinifyEnabled = false } } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - composeOptions { kotlinCompilerExtensionVersion = libs.versions.androidxComposeCompiler.get() } kotlinOptions { jvmTarget = JavaVersion.VERSION_17.toString() } diff --git a/features/seemore/ui/src/androidTest/kotlin/com/manuelnunez/apps/feature/seemore/ui/SeeMoreViewTest.kt b/features/seemore/ui/src/androidTest/kotlin/com/manuelnunez/apps/feature/seemore/ui/SeeMoreViewTest.kt new file mode 100644 index 0000000..95d9f2b --- /dev/null +++ b/features/seemore/ui/src/androidTest/kotlin/com/manuelnunez/apps/feature/seemore/ui/SeeMoreViewTest.kt @@ -0,0 +1,67 @@ +package com.manuelnunez.apps.feature.seemore.ui + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.paging.PagingData +import androidx.paging.compose.collectAsLazyPagingItems +import com.manuelnunez.apps.core.domain.model.Item +import com.manuelnunez.apps.feature.seemore.ui.components.SeeMoreErrorScreen +import com.manuelnunez.apps.feature.seemore.ui.components.SeeMoreScreen +import kotlinx.coroutines.flow.flowOf +import org.junit.Rule +import org.junit.Test +import com.manuelnunez.apps.core.ui.R as RCU + +class SeeMoreViewTest { + + @get:Rule(order = 0) val composeTestRule = createAndroidComposeRule() + + @Test + fun photo_whenScreenIsLoaded_showsPhotoShareAndDescription() { + composeTestRule.setContent { + SeeMoreScreen( + items = flowOf(PagingData.from(mockItems)).collectAsLazyPagingItems(), + onBackClick = {}, + navigateToDetails = {}) + } + + composeTestRule + .onNodeWithText( + composeTestRule.activity.resources.getString(RCU.string.section_popular), + substring = true, + ) + .assertExists() + + composeTestRule + .onNodeWithContentDescription( + mockItems[0].description, + substring = true, + ) + .assertExists() + .assertHasClickAction() + } + + @Test + fun error_whenError_showsTextAndButtonForGoBack() { + composeTestRule.setContent { SeeMoreErrorScreen(retry = {}) } + + composeTestRule + .onNodeWithText( + composeTestRule.activity.resources.getString(RCU.string.alert_error_try_again), + substring = true, + ) + .assertExists() + } + + private val mockItems = + List(5) { index -> + Item( + "$index", + "https://example.com/$index", + description = "description: $index", + thumbnailUrl = "https://example.com/$index") + } +} diff --git a/features/seemore/ui/src/main/kotlin/com/manuelnunez/apps/feature/seemore/ui/components/SeeMoreScreen.kt b/features/seemore/ui/src/main/kotlin/com/manuelnunez/apps/feature/seemore/ui/components/SeeMoreScreen.kt index 435f0f5..9d9a582 100644 --- a/features/seemore/ui/src/main/kotlin/com/manuelnunez/apps/feature/seemore/ui/components/SeeMoreScreen.kt +++ b/features/seemore/ui/src/main/kotlin/com/manuelnunez/apps/feature/seemore/ui/components/SeeMoreScreen.kt @@ -9,12 +9,11 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.safeContent import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.windowInsetsBottomHeight -import androidx.compose.foundation.layout.windowInsetsTopHeight +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.rememberLazyGridState @@ -50,14 +49,14 @@ fun SeeMoreScreen( onBackClick: () -> Unit, navigateToDetails: (Item) -> Unit ) { - Column(Modifier.fillMaxSize()) { - Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) + Column(Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeContent)) { + Spacer(modifier = Modifier.height(20.dp)) SeeMoreToolbar(onBackClick) PopularItems(navigateToDetails = navigateToDetails, itemsPagingItems = items) - Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) + Spacer(modifier = Modifier.height(20.dp)) } } @@ -72,9 +71,9 @@ fun PopularItems(itemsPagingItems: LazyPagingItems, navigateToDetails: (It LazyVerticalGrid( columns = cellConfiguration, - modifier = Modifier.imePadding().fillMaxSize(), + modifier = Modifier.fillMaxSize(), state = gridState, - contentPadding = PaddingValues(horizontal = 20.dp, vertical = 40.dp), + contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 10.dp, bottom = 20.dp), verticalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { items( @@ -102,14 +101,14 @@ fun PopularItems(itemsPagingItems: LazyPagingItems, navigateToDetails: (It @Composable private fun SeeMoreToolbar(onBackClick: () -> Unit) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = { onBackClick() }) { + IconButton(onClick = onBackClick) { Icon( imageVector = Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(id = RCU.string.button_back), tint = MaterialTheme.colorScheme.onSurface) } - TitleText(title = "Popular") + TitleText(title = stringResource(id = RCU.string.section_popular)) } } diff --git a/features/seemore/ui/src/test/kotlin/com/manuelnunez/apps/feature/seemore/ui/viewmodel/SeeMoreViewModelTest.kt b/features/seemore/ui/src/test/kotlin/com/manuelnunez/apps/feature/seemore/ui/viewmodel/SeeMoreViewModelTest.kt index a3a7ff5..f1f8090 100644 --- a/features/seemore/ui/src/test/kotlin/com/manuelnunez/apps/feature/seemore/ui/viewmodel/SeeMoreViewModelTest.kt +++ b/features/seemore/ui/src/test/kotlin/com/manuelnunez/apps/feature/seemore/ui/viewmodel/SeeMoreViewModelTest.kt @@ -1,7 +1,7 @@ package com.manuelnunez.apps.feature.seemore.ui.viewmodel import androidx.paging.PagingData -import com.manuelnunez.apps.core.common.test.MainDispatcherRule +import com.manuelnunez.apps.core.common.test.MockkAllRule import com.manuelnunez.apps.core.common.test.UnMockkAllRule import com.manuelnunez.apps.core.domain.model.Item import com.manuelnunez.apps.feature.seemore.domain.usecase.GetAllItemUseCase @@ -19,7 +19,7 @@ import kotlin.properties.Delegates @OptIn(ExperimentalCoroutinesApi::class) class SeeMoreViewModelTest { - @RegisterExtension private val mainDispatcherRule = MainDispatcherRule() + @RegisterExtension private val mockkAllExtension = MockkAllRule() @RegisterExtension private val unMockkAllExtension = UnMockkAllRule() private val getAllItemUseCase = mockk() @@ -28,7 +28,7 @@ class SeeMoreViewModelTest { @Test fun `WHEN viewmodel init, THEN getAllItemUseCase is called`() = - mainDispatcherRule.runTest { + mockkAllExtension.runTest { val pagingData = PagingData.from(mockItems) every { getAllItemUseCase.prepare(Unit) } returns flow { emit(pagingData) }