Skip to content

Commit

Permalink
Merge pull request #50 from manununhez/epic/45-favorites-or-saved-ui-…
Browse files Browse the repository at this point in the history
…optional

epic/45-favorites-saved-UI
  • Loading branch information
manununhez authored Apr 19, 2024
2 parents a157ff7 + 5ce3b9e commit e139875
Show file tree
Hide file tree
Showing 93 changed files with 1,886 additions and 284 deletions.
20 changes: 8 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,38 +9,34 @@ 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
- **Save Favorites:** Save your favorite cat images to easily revisit them later and
create your personalized collection.

## Tools/Libraries

### Android Libraries

- **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)
- **Compose UI:** [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)
- **DataStore Proto:** [DataStore Proto](https://developer.android.com/topic/libraries/architecture/datastore#prefs-vs-proto)
- **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:** [Material3](https://developer.android.com/jetpack/androidx/releases/compose-material3) [Material Components for Android](https://github.com/material-components/material-components-android)
- **Networking:** [Retrofit](https://square.github.io/retrofit/)
- **Image Loading:** [Coil](https://coil-kt.github.io/coil/)

### Compose Libraries

- **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)

## Screenshots

| ![Screenshot 2024-04-16 at 10 26 30 AM](https://github.com/manununhez/purrfect-pics/assets/5048531/c08f645e-a362-4d2e-bfff-c99d8966e2b8) | ![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) |
|------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------|
| ![Screenshot 2024-04-16 at 10 26 30 AM](https://github.com/manununhez/purrfect-pics/assets/5048531/c08f645e-a362-4d2e-bfff-c99d8966e2b8) | ![Screenshot 2024-04-19 at 12 06 41 AM](https://github.com/manununhez/purrfect-pics/assets/5048531/f7c9873c-8ab8-4215-b4f3-ed801dd8f6c9) | ![Screenshot 2024-04-19 at 12 17 14 AM](https://github.com/manununhez/purrfect-pics/assets/5048531/534f2f34-5f9d-4945-83bf-0076f667bc19)| ![Screenshot 2024-04-19 at 12 10 40 AM](https://github.com/manununhez/purrfect-pics/assets/5048531/69042702-71aa-4eae-b508-871d9c630ff8) | ![Screenshot 2024-04-19 at 12 16 02 AM](https://github.com/manununhez/purrfect-pics/assets/5048531/e55d1bae-6aa4-4401-a6b6-8754b0756efd)|
|----------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|

## Considerations

- 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
- Localdata persistence of favorite cats only. Responses from API are not cached because these are 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
Expand Down
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ dependencies {
implementation(projects.features.home.ui)
implementation(projects.features.detail.ui)
implementation(projects.features.seemore.ui)
implementation(projects.features.favorites.ui)

implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.core.ktx)
Expand Down
7 changes: 1 addition & 6 deletions app/src/main/kotlin/com/manuelnunez/apps/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,7 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.navigation.compose.rememberNavController
import com.manuelnunez.apps.core.ui.theme.MainTheme
import com.manuelnunez.apps.navigation.MainNavigation
import dagger.hilt.android.AndroidEntryPoint

@AndroidEntryPoint
Expand All @@ -23,9 +21,6 @@ class MainActivity : ComponentActivity() {

enableEdgeToEdge()

setContent {
val navController = rememberNavController()
MainTheme { MainNavigation(navController = navController) }
}
setContent { MainTheme { MainApp() } }
}
}
140 changes: 140 additions & 0 deletions app/src/main/kotlin/com/manuelnunez/apps/MainApp.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package com.manuelnunez.apps

import android.content.res.Configuration
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.navigation.NavController
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.compose.rememberNavController
import com.manuelnunez.apps.core.ui.component.MainGradientBackground
import com.manuelnunez.apps.core.ui.component.MainNavigationBar
import com.manuelnunez.apps.core.ui.component.MainNavigationBarItem
import com.manuelnunez.apps.core.ui.component.MainNavigationRail
import com.manuelnunez.apps.core.ui.component.MainNavigationRailItem
import com.manuelnunez.apps.navigation.MainNavigation
import com.manuelnunez.apps.navigation.RootScreen
import com.manuelnunez.apps.navigation.mainDestinations
import com.manuelnunez.apps.navigation.navigateToRootScreen

@Composable
fun MainApp() {
val navController = rememberNavController()
val currentSelectedScreen by navController.currentScreenAsState()
val shouldShowBottomBar =
LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT

Scaffold(
bottomBar = {
if (shouldShowBottomBar) {
MainBottomNavBar(navController, currentSelectedScreen)
}
}) { paddingValues ->
Row(
Modifier.fillMaxSize()
.padding(paddingValues)
.consumeWindowInsets(paddingValues)
.windowInsetsPadding(
WindowInsets.safeDrawing.only(
WindowInsetsSides.Horizontal,
),
),
) {
if (!shouldShowBottomBar) {
MainNavRail(navController, currentSelectedScreen)
}
MainGradientBackground { MainNavigation(navController) }
}
}
}

@Composable
private fun MainBottomNavBar(navController: NavController, currentSelectedScreen: RootScreen) {
MainNavigationBar {
mainDestinations().forEach { rootScreen ->
MainNavigationBarItem(
selected = currentSelectedScreen == rootScreen,
onClick = { navController.navigateToRootScreen(rootScreen) },
icon = {
Icon(
imageVector = rootScreen.unselectedIcon,
contentDescription = null,
)
},
selectedIcon = {
Icon(
imageVector = rootScreen.selectedIcon,
contentDescription = null,
)
},
label = { Text(stringResource(rootScreen.iconTextId)) })
}
}
}

@Composable
private fun MainNavRail(navController: NavController, currentSelectedScreen: RootScreen) {
MainNavigationRail {
mainDestinations().forEach { rootScreen ->
MainNavigationRailItem(
selected = currentSelectedScreen == rootScreen,
onClick = { navController.navigateToRootScreen(rootScreen) },
icon = {
Icon(
imageVector = rootScreen.unselectedIcon,
contentDescription = null,
)
},
selectedIcon = {
Icon(
imageVector = rootScreen.selectedIcon,
contentDescription = null,
)
},
label = { Text(stringResource(rootScreen.iconTextId)) })
}
}
}

@Stable
@Composable
private fun NavController.currentScreenAsState(): State<RootScreen> {
val selectedItem = remember { mutableStateOf(RootScreen.HOME) }
DisposableEffect(key1 = this) {
val listener =
NavController.OnDestinationChangedListener { _, destination, _ ->
when {
destination.hierarchy.any { it.route == RootScreen.HOME.route } -> {
selectedItem.value = RootScreen.HOME
}
destination.hierarchy.any { it.route == RootScreen.FAVORITES.route } -> {
selectedItem.value = RootScreen.FAVORITES
}
}
}

addOnDestinationChangedListener(listener)

onDispose { removeOnDestinationChangedListener(listener) }
}
return selectedItem
}
Original file line number Diff line number Diff line change
@@ -1,23 +1,41 @@
package com.manuelnunez.apps.navigation

import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material.icons.outlined.Home
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.navigation.NavController
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import com.manuelnunez.apps.feature.detail.ui.navigation.detailScreen
import com.manuelnunez.apps.feature.detail.ui.navigation.navigateToDetail
import com.manuelnunez.apps.feature.seemore.ui.navigation.navigateToSeeMore
import com.manuelnunez.apps.feature.seemore.ui.navigation.seeMoreScreen
import androidx.navigation.navigation
import com.manuelnunez.apps.R
import com.manuelnunez.apps.features.detail.ui.navigation.detailFavScreen
import com.manuelnunez.apps.features.detail.ui.navigation.detailScreen
import com.manuelnunez.apps.features.detail.ui.navigation.navigateToDetail
import com.manuelnunez.apps.features.detail.ui.navigation.navigateToDetailFav
import com.manuelnunez.apps.features.favorites.ui.navigation.FAVORITES_ROUTE
import com.manuelnunez.apps.features.favorites.ui.navigation.favoritesScreen
import com.manuelnunez.apps.features.home.ui.navigation.HOME_ROUTE
import com.manuelnunez.apps.features.home.ui.navigation.homeScreen
import com.manuelnunez.apps.features.seemore.ui.navigation.navigateToSeeMore
import com.manuelnunez.apps.features.seemore.ui.navigation.seeMoreScreen

@Composable
fun MainNavigation(
modifier: Modifier = Modifier,
navController: NavHostController,
startDestination: String = HOME_ROUTE,
) {
NavHost(modifier = modifier, navController = navController, startDestination = startDestination) {
fun MainNavigation(navController: NavHostController) {
NavHost(navController = navController, startDestination = RootScreen.HOME.route) {
addHomeRoute(navController)
addFavoriteRoute(navController)
}
}

// home navigation
private fun NavGraphBuilder.addHomeRoute(navController: NavController) {
navigation(route = RootScreen.HOME.route, startDestination = HOME_ROUTE) {
homeScreen(
navigateToDetails = navController::navigateToDetail,
navigateToSeeMore = navController::navigateToSeeMore)
Expand All @@ -27,3 +45,45 @@ fun MainNavigation(
navigateToDetails = navController::navigateToDetail)
}
}

// favorite navigation
private fun NavGraphBuilder.addFavoriteRoute(navController: NavController) {
navigation(route = RootScreen.FAVORITES.route, startDestination = FAVORITES_ROUTE) {
favoritesScreen(
navigateToDetails = navController::navigateToDetailFav,
onBackClick = { navController.navigateUp() },
)
detailFavScreen(onBackClick = { navController.navigateUp() })
}
}

enum class RootScreen(
val route: String,
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector,
val iconTextId: Int,
val titleTextId: Int,
) {
HOME(
route = "home_root",
selectedIcon = Icons.Default.Home,
unselectedIcon = Icons.Outlined.Home,
iconTextId = R.string.home_destination_title,
titleTextId = R.string.home_destination_title),
FAVORITES(
route = "favorite_root",
selectedIcon = Icons.Default.Favorite,
unselectedIcon = Icons.Outlined.FavoriteBorder,
iconTextId = R.string.favorites_destination_title,
titleTextId = R.string.favorites_destination_title)
}

fun NavController.navigateToRootScreen(rootScreen: RootScreen) {
navigate(rootScreen.route) {
launchSingleTop = true
restoreState = true
popUpTo(graph.findStartDestination().id) { saveState = true }
}
}

fun mainDestinations() = listOf(RootScreen.HOME, RootScreen.FAVORITES)
2 changes: 2 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
<resources>
<string name="app_name">Purrfect Pics</string>
<string name="home_destination_title">Home</string>
<string name="favorites_destination_title">Favorites</string>
</resources>
8 changes: 7 additions & 1 deletion core/data/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,21 @@ dependencies {
implementation(projects.core.common)
implementation(projects.core.services)
implementation(projects.core.domain)
implementation(projects.core.datastoreProto)
implementation(projects.features.home.domain)
implementation(projects.features.seemore.domain)
implementation(projects.features.favorites.domain)
implementation(projects.features.detail.domain)

implementation(libs.androidx.dataStore.core)
implementation(libs.protobuf.kotlin.lite)

implementation(libs.retrofit.core)
implementation(libs.retrofit.gsonConverter)

implementation(libs.androidx.paging.common.ktx)
// HILT
implementation(libs.hilt.android)
implementation(libs.androidx.paging.common.ktx)
ksp(libs.hilt.compiler)

testImplementation(libs.kotlinx.coroutines.test)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.manuelnunez.apps.core.data.datasource.local

import androidx.datastore.core.DataStore
import com.manuelnunez.apps.core.datastore.proto.Item
import com.manuelnunez.apps.core.datastore.proto.ItemList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import com.manuelnunez.apps.core.domain.model.Item as ItemModel

class FavoritesDataSource @Inject constructor(private val itemDataStore: DataStore<ItemList>) {
val favorites: Flow<List<ItemModel>> =
itemDataStore.data.map {
it.itemsList.map { item ->
ItemModel(item.photoId, item.imageUrl, item.thumbnailUrl, item.description)
}
}

suspend fun addItemToFavorites(newItem: ItemModel) {
itemDataStore.updateData { itemList ->
val updatedItems =
itemList
.toBuilder()
.addItems(
Item.newBuilder()
.setPhotoId(newItem.photoId)
.setImageUrl(newItem.imageUrl)
.setThumbnailUrl(newItem.thumbnailUrl)
.setDescription(newItem.description)
.build())
.build()
updatedItems
}
}

suspend fun removeItemFromFavorites(itemPhotoId: String) {
itemDataStore.updateData { itemList ->
val updatedItems =
itemList
.toBuilder()
.removeItems(itemList.itemsList.indexOfFirst { it.photoId == itemPhotoId })
.build()
updatedItems
}
}

fun isItemFavorite(itemPhotoId: String): Flow<Boolean> =
favorites.map { it.any { item -> item.photoId == itemPhotoId } }
}
Loading

0 comments on commit e139875

Please sign in to comment.