Merge branch 'simple_sub_system' into 'master'

Simple sub system

See merge request ti/2023-2024/s4/mobile-security/students/joren-schipman/pokesearch!3
This commit is contained in:
Schipman Joren 2024-04-30 10:00:48 +00:00
commit 6b7cd89f86
14 changed files with 284 additions and 52 deletions

View File

@ -1,11 +1,11 @@
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
import com.ti.mobpo.ui.viewmodels.FavouritesViewModel
import com.ti.mobpo.ui.viewmodels.PokeSearchViewModel
object AppViewModelProvider {
val Factory = viewModelFactory {
@ -13,9 +13,17 @@ object AppViewModelProvider {
PokeSearchViewModel(
pokesearchApplication().appContainer.favouritesRepository
)
}
initializer {
FavouritesViewModel(
pokesearchApplication().appContainer.favouritesRepository, pokesearchApplication().featureManager
)
}
}
}
fun CreationExtras.pokesearchApplication(): PokeSearch =
(this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as PokeSearch)
(this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as PokeSearch)

View File

@ -3,17 +3,26 @@ package com.ti.mobpo
import android.app.Application
import com.ti.mobpo.data.AppContainer
import com.ti.mobpo.data.AppDataContainer
import com.ti.mobpo.ui.util.FeatureManager
class PokeSearch : Application() {
lateinit var appContainer: AppContainer
private set
lateinit var featureManager: FeatureManager
private set
override fun onCreate() {
super.onCreate()
appContainer = createAppContainer()
featureManager = createFeatrureManager()
}
private fun createAppContainer(): AppContainer {
return AppDataContainer(this)
}
private fun createFeatrureManager(): FeatureManager {
return FeatureManager(this)
}
}

View File

@ -2,8 +2,6 @@ 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(

View File

@ -14,9 +14,8 @@ interface FavouriteDao {
@Delete
suspend fun delete(favourite: Favourite)
@Query("SELECT id FROM favourites")
fun getAllFavoriteIds(): Flow<List<Int>>
@Query("SELECT * from favourites ORDER BY id ASC")
fun getAllItems(): Flow<List<Favourite>>
@Query("SELECT EXISTS(SELECT 1 FROM favourites WHERE id = :id LIMIT 1)")
suspend fun isFavourite(id: Int): Boolean

View File

@ -9,7 +9,7 @@ interface FavouritesRepository {
/**
* Retrieve all the items from the the given data source.
*/
fun getAllFavoriteIds(): Flow<List<Int>>
fun getAllItems(): Flow<List<Favourite>>
/**
* Insert item in the data source

View File

@ -3,8 +3,8 @@ 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 fun getAllItems(): Flow<List<Favourite>> {
return favouriteDao.getAllItems()
}
override suspend fun insertItem(item: Favourite) {
favouriteDao.insert(item)

View File

@ -0,0 +1,5 @@
package com.ti.mobpo.model
import com.ti.mobpo.data.Favourite
data class FavouriteUiState(val favourites: List<Favourite> = listOf())

View File

@ -20,7 +20,6 @@ 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
@ -31,8 +30,11 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.ti.mobpo.ui.screens.Favourites
import com.ti.mobpo.AppViewModelProvider
import com.ti.mobpo.ui.screens.FavoritesScreen
import com.ti.mobpo.ui.screens.PokeSearchScreen
import com.ti.mobpo.ui.viewmodels.FavouritesViewModel
import com.ti.mobpo.ui.viewmodels.PokeSearchViewModel
@Composable
fun Navigation() {
@ -61,6 +63,9 @@ fun Navigation() {
val updatedSelectedIndex = items.indexOfFirst { it.route == currentRoute }
selectedItemIndex = rememberUpdatedState(updatedSelectedIndex).value
val pokeSearchVM = viewModel<PokeSearchViewModel>(factory = AppViewModelProvider.Factory)
val favoritesVM = viewModel<FavouritesViewModel>(factory = AppViewModelProvider.Factory)
Scaffold (
bottomBar = {
NavigationBar {
@ -104,11 +109,11 @@ fun Navigation() {
ExitTransition.None
}) {
composable(route = Screen.PokeSearch.route) {
PokeSearchScreen()
PokeSearchScreen(pokeSearchVM)
}
composable(
route = Screen.Favourites.route) {
Favourites()
FavoritesScreen(favoritesVM)
}
}
}
@ -122,6 +127,6 @@ fun Navigation() {
@Composable
fun NavigationPreview() {
Surface {
PokeSearchScreen()
PokeSearchScreen(viewModel(factory = AppViewModelProvider.Factory))
}
}

View File

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

View File

@ -1,10 +1,89 @@
package com.ti.mobpo.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.dp
import com.ti.mobpo.ui.viewmodels.FavouritesViewModel
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
@Composable
fun Favourites() {
println("Favourites")
Text("Favourites Page")
fun FavoritesScreen(viewModel: FavouritesViewModel) {
Favorites(viewModel)
}
@Composable
fun Favorites(favoritesViewModel: FavouritesViewModel) {
LaunchedEffect(Unit) {
favoritesViewModel.loadFavourites()
}
val favorites by favoritesViewModel.pokemonDetails.collectAsState()
Column(
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.padding(20.dp)
) {
Spacer(modifier = Modifier.height(16.dp))
// Show AlertDialog if access check failed
if (favoritesViewModel.accessCheckFailed.value) {
AlertDialog(
onDismissRequest = {
// Dismiss the dialog
favoritesViewModel.accessCheckFailed.value = false
},
title = {
Text("Access Denied")
},
text = {
Text("You do not have access to this feature.")
},
confirmButton = {
Button(
onClick = {
favoritesViewModel.accessCheckFailed.value = false
}
) {
Text("OK")
}
}
)
}
favorites?.let { favoritesList ->
if (favoritesList.isNotEmpty()) {
LazyVerticalGrid(
columns = GridCells.Adaptive(150.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(items = favoritesList, key = { pokemon -> pokemon.id }) { pokemon ->
PokemonCard(pokemon, toggleFavorite = { pokemonId ->
favoritesViewModel.toggleFavorite(pokemonId)
})
}
}
} else {
Text("No favorites found")
}
}
}
}

View File

@ -1,6 +1,6 @@
package com.ti.mobpo.ui.screens
import com.ti.mobpo.ui.pokesearch.PokeSearchViewModel
import com.ti.mobpo.ui.viewmodels.PokeSearchViewModel
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -19,6 +19,7 @@ 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.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@ -26,9 +27,6 @@ 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
@ -45,18 +43,18 @@ 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
@Composable
fun PokeSearchScreen(viewModel: PokeSearchViewModel = viewModel(factory = AppViewModelProvider.Factory)) {
fun PokeSearchScreen(viewModel: PokeSearchViewModel) {
PokeSearch(viewModel)
}
@Composable
fun PokeSearch(pokeSearchViewModel: PokeSearchViewModel) {
val searchResults by pokeSearchViewModel.pokemonDetails.collectAsState()
val isLoading by pokeSearchViewModel.isLoading.collectAsState()
Column(
verticalArrangement = Arrangement.Top,
@ -71,21 +69,25 @@ fun PokeSearch(pokeSearchViewModel: PokeSearchViewModel) {
Spacer(modifier = Modifier.height(16.dp))
searchResults?.let { results ->
if (results.isNotEmpty()) {
LazyVerticalGrid(
columns = GridCells.Adaptive(150.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(items = results, key = { pokemon -> pokemon.id }) { pokemon ->
PokemonCard(pokemon, toggleFavorite = { pokemonId ->
pokeSearchViewModel.toggleFavorite(pokemonId)
})
if(isLoading) {
CircularProgressIndicator()
} else {
searchResults?.let { results ->
if (results.isNotEmpty()) {
LazyVerticalGrid(
columns = GridCells.Adaptive(150.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(items = results, key = { pokemon -> pokemon.id }) { pokemon ->
PokemonCard(pokemon, toggleFavorite = { pokemonId ->
pokeSearchViewModel.toggleFavorite(pokemonId)
})
}
}
} else {
Text("No results found")
}
} else {
Text("No results found")
}
}
}
@ -94,7 +96,6 @@ fun PokeSearch(pokeSearchViewModel: PokeSearchViewModel) {
@Composable
fun PokemonCard(pokemon: PokemonDetails, toggleFavorite: (Int) -> Unit) {
var isFavourite by remember { mutableStateOf(false) }
Card(
shape = MaterialTheme.shapes.medium,
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
@ -130,7 +131,8 @@ fun PokemonCard(pokemon: PokemonDetails, toggleFavorite: (Int) -> Unit) {
)
IconButton(
onClick = { toggleFavorite(pokemon.id) },
modifier = Modifier.wrapContentSize()
modifier = Modifier
.wrapContentSize()
.layout { measurable, constraints ->
if (constraints.maxHeight == Constraints.Infinity) {
layout(0, 0) {}

View File

@ -0,0 +1,18 @@
package com.ti.mobpo.ui.util
import android.content.Context
class FeatureManager(private val context: Context) {
private val PREFS_NAME = "FeaturePrefs"
private val KEY_PAID_FEATURE_ENABLED = "paid_feature_enabled"
fun hasAccessToPaidFeature(): Boolean {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getBoolean(KEY_PAID_FEATURE_ENABLED, false)
}
fun setPaidFeatureEnabled(enabled: Boolean) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().putBoolean(KEY_PAID_FEATURE_ENABLED, enabled).apply()
}
}

View File

@ -0,0 +1,82 @@
package com.ti.mobpo.ui.viewmodels
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
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.FavouriteUiState
import com.ti.mobpo.model.PokemonDetails
import com.ti.mobpo.network.PokeApi
import com.ti.mobpo.ui.util.FeatureManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.io.IOException
class FavouritesViewModel(private val favouritesRepository: FavouritesRepository,
private val featureManager: FeatureManager
) : ViewModel() {
private val service = PokeApi.retrofitService
private val _pokemonDetails = MutableStateFlow<List<PokemonDetails>?>(null)
val pokemonDetails: StateFlow<List<PokemonDetails>?> = _pokemonDetails.asStateFlow()
val accessCheckFailed: MutableState<Boolean> = mutableStateOf(false)
fun loadFavourites() {
// featureManager.setPaidFeatureEnabled(false) enable and disable acccess
// Ugly workaround to make sure all the favourites are loaded before displaying them
viewModelScope.launch {
if(!featureManager.hasAccessToPaidFeature()) {
accessCheckFailed.value = true
return@launch
}
try {
val favouritesList: StateFlow<FavouriteUiState> =
favouritesRepository.getAllItems().map { FavouriteUiState(it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = FavouriteUiState()
)
favouritesList.collect { state ->
val detailsList = mutableListOf<PokemonDetails>()
for (favourite in state.favourites) {
val details = service.getPokemonDetails(favourite.id)
detailsList.add(details.copy(isFavorite = true))
}
_pokemonDetails.value = detailsList
}
} catch (e: IOException) {
/* Handle error */
}
}
}
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,10 +1,11 @@
package com.ti.mobpo.ui.pokesearch
package com.ti.mobpo.ui.viewmodels
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.model.PokemonSpecies
import com.ti.mobpo.network.PokeApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -13,27 +14,56 @@ import kotlinx.coroutines.launch
import java.io.IOException
private const val SHOW_LIMIT = 20
class PokeSearchViewModel(private val favouritesRepository: FavouritesRepository) : ViewModel() {
private val service = PokeApi.retrofitService;
private val service = PokeApi.retrofitService
private val _pokemonDetails = MutableStateFlow<List<PokemonDetails>?>(null)
val pokemonDetails: StateFlow<List<PokemonDetails>?> = _pokemonDetails.asStateFlow()
private val _initialPokemonList = MutableStateFlow<List<PokemonSpecies>?>(null)
fun search(query: String) {
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
init {
fetchPokemonSpecies()
}
private fun fetchPokemonSpecies() {
viewModelScope.launch {
try {
val response = service.getPokemon()
val filteredList = response.results.filter { it.name.contains(query, ignoreCase = true) }
_initialPokemonList.value = response.results.sortedBy { it.name.lowercase() }
} catch (e: IOException) {
/*TODO*/
}
}
}
fun search(query: String) {
_isLoading.value = true
viewModelScope.launch {
try {
val firstIndex = _initialPokemonList.value?.indexOfFirst { it.name.startsWith(query, ignoreCase = true) }
val lastIndex = _initialPokemonList.value?.indexOfLast { it.name.startsWith(query, ignoreCase = true) }
val detailsList = mutableListOf<PokemonDetails>()
for (pokemonSpecies in filteredList) {
val details = service.getPokemonDetails(extractPokemonId(pokemonSpecies.url))
val isFavorite = favouritesRepository.isFavourite(details.id)
detailsList.add(details.copy(isFavorite = isFavorite))
if (firstIndex != null && lastIndex != null) {
val endIndex = minOf(firstIndex + SHOW_LIMIT, lastIndex + 1)
val startIndex = maxOf(firstIndex, endIndex - SHOW_LIMIT)
for (index in startIndex until endIndex) {
val pokemonSpecies = _initialPokemonList.value!![index]
val details = service.getPokemonDetails(extractPokemonId(pokemonSpecies.url))
val isFavorite = favouritesRepository.isFavourite(details.id)
detailsList.add(details.copy(isFavorite = isFavorite))
}
}
_pokemonDetails.value = detailsList
} catch (e: IOException) {
/* Handle error */
} finally {
_isLoading.value = false
}
}
}