Merge branch 'addFavourites' into 'master'

Add favourites

See merge request ti/2023-2024/s4/mobile-security/students/joren-schipman/pokesearch!2
This commit is contained in:
Schipman Joren 2024-04-29 21:20:49 +00:00
commit 3af9a606ae
18 changed files with 265 additions and 52 deletions

View File

@ -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"]}")
}

View File

@ -4,6 +4,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".PokeSearch"
android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:dataExtractionRules="@xml/data_extraction_rules"

View File

@ -0,0 +1,21 @@
package com.ti.mobpo
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.viewmodel.CreationExtras
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.ti.mobpo.ui.pokesearch.PokeSearchViewModel
object AppViewModelProvider {
val Factory = viewModelFactory {
initializer {
PokeSearchViewModel(
pokesearchApplication().appContainer.favouritesRepository
)
}
}
}
fun CreationExtras.pokesearchApplication(): PokeSearch =
(this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as PokeSearch)

View File

@ -0,0 +1,19 @@
package com.ti.mobpo
import android.app.Application
import com.ti.mobpo.data.AppContainer
import com.ti.mobpo.data.AppDataContainer
class PokeSearch : Application() {
lateinit var appContainer: AppContainer
private set
override fun onCreate() {
super.onCreate()
appContainer = createAppContainer()
}
private fun createAppContainer(): AppContainer {
return AppDataContainer(this)
}
}

View File

@ -0,0 +1,16 @@
package com.ti.mobpo.data
import android.content.Context
interface AppContainer {
val favouritesRepository: FavouritesRepository
}
class AppDataContainer(private val context: Context) : AppContainer {
override val favouritesRepository: FavouritesRepository by lazy {
OfflineFavouritesRepository(PokemonDatabase.getDatabase(context).FavouriteDao())
}
}

View File

@ -0,0 +1,12 @@
package com.ti.mobpo.data
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.ti.mobpo.model.Sprites
import com.ti.mobpo.model.Type
@Entity(tableName = "favourites")
data class Favourite(
@PrimaryKey val id: Int,
val name: String
)

View File

@ -0,0 +1,23 @@
package com.ti.mobpo.data
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface FavouriteDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insert(favourite: Favourite)
@Delete
suspend fun delete(favourite: Favourite)
@Query("SELECT id FROM favourites")
fun getAllFavoriteIds(): Flow<List<Int>>
@Query("SELECT EXISTS(SELECT 1 FROM favourites WHERE id = :id LIMIT 1)")
suspend fun isFavourite(id: Int): Boolean
}

View File

@ -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<List<Int>>
/**
* 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
}

View File

@ -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<List<Int>> {
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)
}
}

View File

@ -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 }
}
}
}
}

View File

@ -15,7 +15,8 @@ data class PokemonDetails(
val id: Int,
val name: String,
val types: List<Type>,
@SerializedName("sprites") val sprites: Sprites
@SerializedName("sprites") val sprites: Sprites,
var isFavorite: Boolean = false
)
data class Type(

View File

@ -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()
}
}

View File

@ -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")
}

View File

@ -1,3 +1,3 @@
package com.ti.mobpo.ui
package com.ti.mobpo.ui.pokesearch
data class PokeSearchUiState(val searchQuery: String = "")

View File

@ -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<List<PokemonDetails>?>(null)
@ -26,7 +28,8 @@ class PokeSearchViewModel : ViewModel() {
val detailsList = mutableListOf<PokemonDetails>()
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))
}
}
}
}
}
}

View File

@ -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)
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
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)
.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"
)
}
}
}
}
}

View File

@ -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")
}

View File

@ -3,3 +3,9 @@ plugins {
alias(libs.plugins.androidApplication) apply false
alias(libs.plugins.jetbrainsKotlinAndroid) apply false
}
buildscript {
extra.apply {
set("room_version", "2.6.0")
}
}