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 { plugins {
alias(libs.plugins.androidApplication) alias(libs.plugins.androidApplication)
alias(libs.plugins.jetbrainsKotlinAndroid) alias(libs.plugins.jetbrainsKotlinAndroid)
id("com.google.devtools.ksp") version "1.9.20-1.0.14"
} }
android { android {
@ -69,4 +70,9 @@ dependencies {
implementation("com.squareup.retrofit2:retrofit:2.11.0") implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-gson:2.11.0") implementation("com.squareup.retrofit2:converter-gson:2.11.0")
implementation("io.coil-kt:coil-compose:2.6.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" /> <uses-permission android:name="android.permission.INTERNET" />
<application <application
android:name=".PokeSearch"
android:allowBackup="true" android:allowBackup="true"
android:enableOnBackInvokedCallback="true" android:enableOnBackInvokedCallback="true"
android:dataExtractionRules="@xml/data_extraction_rules" 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 id: Int,
val name: String, val name: String,
val types: List<Type>, val types: List<Type>,
@SerializedName("sprites") val sprites: Sprites @SerializedName("sprites") val sprites: Sprites,
var isFavorite: Boolean = false
) )
data class Type( 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.Icons
import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Home 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.FavoriteBorder
import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
@ -22,6 +20,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@ -34,8 +33,6 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.ti.mobpo.ui.screens.Favourites import com.ti.mobpo.ui.screens.Favourites
import com.ti.mobpo.ui.screens.PokeSearchScreen import com.ti.mobpo.ui.screens.PokeSearchScreen
import com.ti.mobpo.ui.screens.Profile
@Composable @Composable
fun Navigation() { fun Navigation() {
@ -51,13 +48,7 @@ fun Navigation() {
selectedIcon = Icons.Filled.Favorite, selectedIcon = Icons.Filled.Favorite,
unselectedItem = Icons.Outlined.FavoriteBorder, unselectedItem = Icons.Outlined.FavoriteBorder,
route = Screen.Favourites.route route = Screen.Favourites.route
), )
BottomNavigationItem(
title = "Profile",
selectedIcon = Icons.Filled.Person,
unselectedItem = Icons.Outlined.Person,
route = Screen.Profile.route
),
) )
var selectedItemIndex by rememberSaveable { var selectedItemIndex by rememberSaveable {
@ -70,8 +61,6 @@ fun Navigation() {
val updatedSelectedIndex = items.indexOfFirst { it.route == currentRoute } val updatedSelectedIndex = items.indexOfFirst { it.route == currentRoute }
selectedItemIndex = rememberUpdatedState(updatedSelectedIndex).value selectedItemIndex = rememberUpdatedState(updatedSelectedIndex).value
val pokeSearchViewModel: PokeSearchViewModel = viewModel()
Scaffold ( Scaffold (
bottomBar = { bottomBar = {
NavigationBar { NavigationBar {
@ -115,16 +104,12 @@ fun Navigation() {
ExitTransition.None ExitTransition.None
}) { }) {
composable(route = Screen.PokeSearch.route) { composable(route = Screen.PokeSearch.route) {
PokeSearchScreen(pokeSearchViewModel) PokeSearchScreen()
} }
composable( composable(
route = Screen.Favourites.route) { route = Screen.Favourites.route) {
Favourites() Favourites()
} }
composable(
route = Screen.Profile.route) {
Profile()
}
} }
} }
) )
@ -137,7 +122,6 @@ fun Navigation() {
@Composable @Composable
fun NavigationPreview() { fun NavigationPreview() {
Surface { Surface {
val pokeSearchViewModel: PokeSearchViewModel = viewModel() PokeSearchScreen()
PokeSearchScreen(pokeSearchViewModel)
} }
} }

View File

@ -2,7 +2,5 @@ package com.ti.mobpo.ui
sealed class Screen (val route: String) { sealed class Screen (val route: String) {
object PokeSearch : Screen("default_screen") object PokeSearch : Screen("default_screen")
object TopNav : Screen("navigation_bar")
object Favourites : Screen("favourites_page") 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 = "") 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.ViewModel
import androidx.lifecycle.viewModelScope 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.model.PokemonDetails
import com.ti.mobpo.network.PokeApi import com.ti.mobpo.network.PokeApi
import kotlinx.coroutines.flow.MutableStateFlow 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 service = PokeApi.retrofitService;
private val _pokemonDetails = MutableStateFlow<List<PokemonDetails>?>(null) private val _pokemonDetails = MutableStateFlow<List<PokemonDetails>?>(null)
@ -26,7 +28,8 @@ class PokeSearchViewModel : ViewModel() {
val detailsList = mutableListOf<PokemonDetails>() val detailsList = mutableListOf<PokemonDetails>()
for (pokemonSpecies in filteredList) { for (pokemonSpecies in filteredList) {
val details = service.getPokemonDetails(extractPokemonId(pokemonSpecies.url)) 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 _pokemonDetails.value = detailsList
} catch (e: IOException) { } catch (e: IOException) {
@ -39,4 +42,26 @@ class PokeSearchViewModel : ViewModel() {
val parts = url.split("/") val parts = url.split("/")
return parts[parts.size - 2].toInt() 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 package com.ti.mobpo.ui.screens
import androidx.compose.foundation.Image import com.ti.mobpo.ui.pokesearch.PokeSearchViewModel
import com.ti.mobpo.ui.PokeSearchViewModel
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize 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.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items 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.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.ti.mobpo.R import com.ti.mobpo.R
import com.ti.mobpo.ui.SearchBar import com.ti.mobpo.ui.SearchBar
@ -34,13 +45,13 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.ti.mobpo.model.PokemonDetails import com.ti.mobpo.model.PokemonDetails
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.request.ImageRequest import coil.request.ImageRequest
import com.ti.mobpo.AppViewModelProvider
import com.ti.mobpo.capitalizeFirstLetterAfterHyphens import com.ti.mobpo.capitalizeFirstLetterAfterHyphens
import com.ti.mobpo.ui.theme.MobileSecurityTheme import com.ti.mobpo.ui.theme.MobileSecurityTheme
import java.util.Locale
@Composable @Composable
fun PokeSearchScreen(pokeSearchViewModel: PokeSearchViewModel) { fun PokeSearchScreen(viewModel: PokeSearchViewModel = viewModel(factory = AppViewModelProvider.Factory)) {
PokeSearch(pokeSearchViewModel) PokeSearch(viewModel)
} }
@Composable @Composable
@ -68,7 +79,9 @@ fun PokeSearch(pokeSearchViewModel: PokeSearchViewModel) {
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
items(items = results, key = { pokemon -> pokemon.id }) { pokemon -> items(items = results, key = { pokemon -> pokemon.id }) { pokemon ->
PokemonCard(pokemon) PokemonCard(pokemon, toggleFavorite = { pokemonId ->
pokeSearchViewModel.toggleFavorite(pokemonId)
})
} }
} }
} else { } else {
@ -80,14 +93,15 @@ fun PokeSearch(pokeSearchViewModel: PokeSearchViewModel) {
@Composable @Composable
fun PokemonCard(pokemon: PokemonDetails) { fun PokemonCard(pokemon: PokemonDetails, toggleFavorite: (Int) -> Unit) {
var isFavourite by remember { mutableStateOf(false) }
Card( Card(
shape = MaterialTheme.shapes.medium, shape = MaterialTheme.shapes.medium,
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
modifier = Modifier modifier = Modifier
.padding(4.dp) .padding(4.dp)
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(1f) .aspectRatio(0.75f)
) { ) {
Column( Column(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@ -102,14 +116,38 @@ fun PokemonCard(pokemon: PokemonDetails) {
.fillMaxWidth() .fillMaxWidth()
.weight(1f) .weight(1f)
) )
Text( Row(
text = capitalizeFirstLetterAfterHyphens(pokemon.name), verticalAlignment = Alignment.CenterVertically,
style = MaterialTheme.typography.bodySmall, modifier = Modifier.fillMaxWidth()
modifier = Modifier ) {
.fillMaxWidth() Text(
.padding(horizontal = 8.dp, vertical = 4.dp) // Adjust padding as needed text = capitalizeFirstLetterAfterHyphens(pokemon.name),
.wrapContentSize(Alignment.Center) 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"
)
}
}
} }
} }
} }

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

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