diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 796bb03..47d87af 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.androidApplication) alias(libs.plugins.jetbrainsKotlinAndroid) + id("com.google.devtools.ksp") version "1.9.20-1.0.14" } android { @@ -69,4 +70,9 @@ dependencies { implementation("com.squareup.retrofit2:retrofit:2.11.0") implementation("com.squareup.retrofit2:converter-gson:2.11.0") implementation("io.coil-kt:coil-compose:2.6.0") + + implementation("androidx.room:room-runtime:${rootProject.extra["room_version"]}") + implementation("androidx.core:core-ktx:1.12.0") + ksp("androidx.room:room-compiler:${rootProject.extra["room_version"]}") + implementation("androidx.room:room-ktx:${rootProject.extra["room_version"]}") } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8df0ded..9a329ab 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ > + + @Query("SELECT EXISTS(SELECT 1 FROM favourites WHERE id = :id LIMIT 1)") + suspend fun isFavourite(id: Int): Boolean +} diff --git a/app/src/main/java/com/ti/mobpo/data/FavouritesRepository.kt b/app/src/main/java/com/ti/mobpo/data/FavouritesRepository.kt new file mode 100644 index 0000000..4083c1b --- /dev/null +++ b/app/src/main/java/com/ti/mobpo/data/FavouritesRepository.kt @@ -0,0 +1,29 @@ +package com.ti.mobpo.data + +import kotlinx.coroutines.flow.Flow + +/** + * Repository that provides insert, update, delete, and retrieve of [Item] from a given data source. + */ +interface FavouritesRepository { + /** + * Retrieve all the items from the the given data source. + */ + fun getAllFavoriteIds(): Flow> + + /** + * Insert item in the data source + */ + suspend fun insertItem(item: Favourite) + + /** + * Delete item from the data source + */ + suspend fun deleteItem(item: Favourite) + + /** + * Check if the item is a favourite + */ + + suspend fun isFavourite(id: Int): Boolean +} diff --git a/app/src/main/java/com/ti/mobpo/data/OfflineFavouritesRepository.kt b/app/src/main/java/com/ti/mobpo/data/OfflineFavouritesRepository.kt new file mode 100644 index 0000000..6f4b000 --- /dev/null +++ b/app/src/main/java/com/ti/mobpo/data/OfflineFavouritesRepository.kt @@ -0,0 +1,20 @@ +package com.ti.mobpo.data + +import kotlinx.coroutines.flow.Flow + +class OfflineFavouritesRepository(private val favouriteDao: FavouriteDao) : FavouritesRepository { + override fun getAllFavoriteIds(): Flow> { + return favouriteDao.getAllFavoriteIds() + } + override suspend fun insertItem(item: Favourite) { + favouriteDao.insert(item) + } + + override suspend fun deleteItem(item: Favourite) { + favouriteDao.delete(item) + } + + override suspend fun isFavourite(id: Int): Boolean { + return favouriteDao.isFavourite(id) + } +} diff --git a/app/src/main/java/com/ti/mobpo/data/PokemonDatabase.kt b/app/src/main/java/com/ti/mobpo/data/PokemonDatabase.kt new file mode 100644 index 0000000..24b5509 --- /dev/null +++ b/app/src/main/java/com/ti/mobpo/data/PokemonDatabase.kt @@ -0,0 +1,23 @@ +package com.ti.mobpo.data + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase + +@Database(entities = [Favourite::class], version = 1, exportSchema = false) +abstract class PokemonDatabase : RoomDatabase() { + abstract fun FavouriteDao(): FavouriteDao + + companion object { + @Volatile + private var Instance: PokemonDatabase? = null + fun getDatabase(context: Context): PokemonDatabase { + return Instance ?: synchronized(this) { + Room.databaseBuilder(context, PokemonDatabase::class.java, "favorites_database") + .build() + .also { Instance = it } + } + } + } +} diff --git a/app/src/main/java/com/ti/mobpo/model/PokeModels.kt b/app/src/main/java/com/ti/mobpo/model/PokeModels.kt index 81c8799..4312980 100644 --- a/app/src/main/java/com/ti/mobpo/model/PokeModels.kt +++ b/app/src/main/java/com/ti/mobpo/model/PokeModels.kt @@ -15,7 +15,8 @@ data class PokemonDetails( val id: Int, val name: String, val types: List, - @SerializedName("sprites") val sprites: Sprites + @SerializedName("sprites") val sprites: Sprites, + var isFavorite: Boolean = false ) data class Type( diff --git a/app/src/main/java/com/ti/mobpo/ui/Navigation.kt b/app/src/main/java/com/ti/mobpo/ui/Navigation.kt index af60fa4..e09a3c7 100644 --- a/app/src/main/java/com/ti/mobpo/ui/Navigation.kt +++ b/app/src/main/java/com/ti/mobpo/ui/Navigation.kt @@ -8,10 +8,8 @@ import androidx.compose.foundation.layout.padding 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.filled.Person import androidx.compose.material.icons.outlined.FavoriteBorder import androidx.compose.material.icons.outlined.Home -import androidx.compose.material.icons.outlined.Person import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar @@ -22,6 +20,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -34,8 +33,6 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import com.ti.mobpo.ui.screens.Favourites import com.ti.mobpo.ui.screens.PokeSearchScreen -import com.ti.mobpo.ui.screens.Profile - @Composable fun Navigation() { @@ -51,13 +48,7 @@ fun Navigation() { selectedIcon = Icons.Filled.Favorite, unselectedItem = Icons.Outlined.FavoriteBorder, route = Screen.Favourites.route - ), - BottomNavigationItem( - title = "Profile", - selectedIcon = Icons.Filled.Person, - unselectedItem = Icons.Outlined.Person, - route = Screen.Profile.route - ), + ) ) var selectedItemIndex by rememberSaveable { @@ -70,8 +61,6 @@ fun Navigation() { val updatedSelectedIndex = items.indexOfFirst { it.route == currentRoute } selectedItemIndex = rememberUpdatedState(updatedSelectedIndex).value - val pokeSearchViewModel: PokeSearchViewModel = viewModel() - Scaffold ( bottomBar = { NavigationBar { @@ -115,16 +104,12 @@ fun Navigation() { ExitTransition.None }) { composable(route = Screen.PokeSearch.route) { - PokeSearchScreen(pokeSearchViewModel) + PokeSearchScreen() } composable( route = Screen.Favourites.route) { Favourites() } - composable( - route = Screen.Profile.route) { - Profile() - } } } ) @@ -137,7 +122,6 @@ fun Navigation() { @Composable fun NavigationPreview() { Surface { - val pokeSearchViewModel: PokeSearchViewModel = viewModel() - PokeSearchScreen(pokeSearchViewModel) + PokeSearchScreen() } } \ No newline at end of file diff --git a/app/src/main/java/com/ti/mobpo/ui/Screen.kt b/app/src/main/java/com/ti/mobpo/ui/Screen.kt index a564e79..d6e109b 100644 --- a/app/src/main/java/com/ti/mobpo/ui/Screen.kt +++ b/app/src/main/java/com/ti/mobpo/ui/Screen.kt @@ -2,7 +2,5 @@ package com.ti.mobpo.ui sealed class Screen (val route: String) { object PokeSearch : Screen("default_screen") - object TopNav : Screen("navigation_bar") object Favourites : Screen("favourites_page") - object Profile : Screen("profile_page") } \ No newline at end of file diff --git a/app/src/main/java/com/ti/mobpo/ui/PokeSearchUiState.kt b/app/src/main/java/com/ti/mobpo/ui/pokesearch/PokeSearchUiState.kt similarity index 63% rename from app/src/main/java/com/ti/mobpo/ui/PokeSearchUiState.kt rename to app/src/main/java/com/ti/mobpo/ui/pokesearch/PokeSearchUiState.kt index 2bd3d0f..5012bf5 100644 --- a/app/src/main/java/com/ti/mobpo/ui/PokeSearchUiState.kt +++ b/app/src/main/java/com/ti/mobpo/ui/pokesearch/PokeSearchUiState.kt @@ -1,3 +1,3 @@ -package com.ti.mobpo.ui +package com.ti.mobpo.ui.pokesearch data class PokeSearchUiState(val searchQuery: String = "") diff --git a/app/src/main/java/com/ti/mobpo/ui/PokeSearchViewModel.kt b/app/src/main/java/com/ti/mobpo/ui/pokesearch/PokeSearchViewModel.kt similarity index 53% rename from app/src/main/java/com/ti/mobpo/ui/PokeSearchViewModel.kt rename to app/src/main/java/com/ti/mobpo/ui/pokesearch/PokeSearchViewModel.kt index a36b747..5bdc5a6 100644 --- a/app/src/main/java/com/ti/mobpo/ui/PokeSearchViewModel.kt +++ b/app/src/main/java/com/ti/mobpo/ui/pokesearch/PokeSearchViewModel.kt @@ -1,7 +1,9 @@ -package com.ti.mobpo.ui +package com.ti.mobpo.ui.pokesearch import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.ti.mobpo.data.Favourite +import com.ti.mobpo.data.FavouritesRepository import com.ti.mobpo.model.PokemonDetails import com.ti.mobpo.network.PokeApi import kotlinx.coroutines.flow.MutableStateFlow @@ -12,7 +14,7 @@ import java.io.IOException -class PokeSearchViewModel : ViewModel() { +class PokeSearchViewModel(private val favouritesRepository: FavouritesRepository) : ViewModel() { private val service = PokeApi.retrofitService; private val _pokemonDetails = MutableStateFlow?>(null) @@ -26,7 +28,8 @@ class PokeSearchViewModel : ViewModel() { val detailsList = mutableListOf() for (pokemonSpecies in filteredList) { val details = service.getPokemonDetails(extractPokemonId(pokemonSpecies.url)) - detailsList.add(details) + val isFavorite = favouritesRepository.isFavourite(details.id) + detailsList.add(details.copy(isFavorite = isFavorite)) } _pokemonDetails.value = detailsList } catch (e: IOException) { @@ -39,4 +42,26 @@ class PokeSearchViewModel : ViewModel() { val parts = url.split("/") return parts[parts.size - 2].toInt() } + + fun toggleFavorite(pokemonId: Int) { + _pokemonDetails.value = _pokemonDetails.value?.map { pokemon -> + if (pokemon.id == pokemonId) { + pokemon.copy(isFavorite = !pokemon.isFavorite) + } else { + pokemon + } + } + viewModelScope.launch { + if (_pokemonDetails.value != null) { + val pokemon = _pokemonDetails.value!!.find { it.id == pokemonId } + pokemon?.let { + if (it.isFavorite) { + favouritesRepository.insertItem(Favourite(it.id, it.name)) + } else { + favouritesRepository.deleteItem(Favourite(it.id, it.name)) + } + } + } + } + } } diff --git a/app/src/main/java/com/ti/mobpo/ui/screens/PokeSearch.kt b/app/src/main/java/com/ti/mobpo/ui/screens/PokeSearch.kt index 105daa3..a2aa2a7 100644 --- a/app/src/main/java/com/ti/mobpo/ui/screens/PokeSearch.kt +++ b/app/src/main/java/com/ti/mobpo/ui/screens/PokeSearch.kt @@ -1,9 +1,9 @@ package com.ti.mobpo.ui.screens -import androidx.compose.foundation.Image -import com.ti.mobpo.ui.PokeSearchViewModel +import com.ti.mobpo.ui.pokesearch.PokeSearchViewModel import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize @@ -14,19 +14,30 @@ import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.outlined.FavoriteBorder import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.dp import com.ti.mobpo.R import com.ti.mobpo.ui.SearchBar @@ -34,13 +45,13 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.ti.mobpo.model.PokemonDetails import coil.compose.AsyncImage import coil.request.ImageRequest +import com.ti.mobpo.AppViewModelProvider import com.ti.mobpo.capitalizeFirstLetterAfterHyphens import com.ti.mobpo.ui.theme.MobileSecurityTheme -import java.util.Locale @Composable -fun PokeSearchScreen(pokeSearchViewModel: PokeSearchViewModel) { - PokeSearch(pokeSearchViewModel) +fun PokeSearchScreen(viewModel: PokeSearchViewModel = viewModel(factory = AppViewModelProvider.Factory)) { + PokeSearch(viewModel) } @Composable @@ -68,7 +79,9 @@ fun PokeSearch(pokeSearchViewModel: PokeSearchViewModel) { horizontalArrangement = Arrangement.spacedBy(8.dp) ) { items(items = results, key = { pokemon -> pokemon.id }) { pokemon -> - PokemonCard(pokemon) + PokemonCard(pokemon, toggleFavorite = { pokemonId -> + pokeSearchViewModel.toggleFavorite(pokemonId) + }) } } } else { @@ -80,14 +93,15 @@ fun PokeSearch(pokeSearchViewModel: PokeSearchViewModel) { @Composable -fun PokemonCard(pokemon: PokemonDetails) { +fun PokemonCard(pokemon: PokemonDetails, toggleFavorite: (Int) -> Unit) { + var isFavourite by remember { mutableStateOf(false) } Card( shape = MaterialTheme.shapes.medium, elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), modifier = Modifier .padding(4.dp) .fillMaxWidth() - .aspectRatio(1f) + .aspectRatio(0.75f) ) { Column( modifier = Modifier.fillMaxSize(), @@ -102,14 +116,38 @@ fun PokemonCard(pokemon: PokemonDetails) { .fillMaxWidth() .weight(1f) ) - Text( - text = capitalizeFirstLetterAfterHyphens(pokemon.name), - style = MaterialTheme.typography.bodySmall, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp) // Adjust padding as needed - .wrapContentSize(Alignment.Center) - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = capitalizeFirstLetterAfterHyphens(pokemon.name), + modifier = Modifier + .padding(8.dp) + .weight(1f), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + IconButton( + onClick = { toggleFavorite(pokemon.id) }, + modifier = Modifier.wrapContentSize() + .layout { measurable, constraints -> + if (constraints.maxHeight == Constraints.Infinity) { + layout(0, 0) {} + } else { + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + placeable.place(0, 0) + } + } + } + ) { + Icon( + imageVector = if (pokemon.isFavorite) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder, + contentDescription = "Favourite" + ) + } + } } } } diff --git a/app/src/main/java/com/ti/mobpo/ui/screens/Profile.kt b/app/src/main/java/com/ti/mobpo/ui/screens/Profile.kt deleted file mode 100644 index cf3f3b8..0000000 --- a/app/src/main/java/com/ti/mobpo/ui/screens/Profile.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.ti.mobpo.ui.screens - -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable - -@Composable -fun Profile(){ - Text("Profile Page") -} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index a0985ef..a2062cf 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,4 +2,10 @@ plugins { alias(libs.plugins.androidApplication) apply false alias(libs.plugins.jetbrainsKotlinAndroid) apply false +} + +buildscript { + extra.apply { + set("room_version", "2.6.0") + } } \ No newline at end of file