diff --git a/app/src/main/java/com/ti/mobpo/AppViewModelProvider.kt b/app/src/main/java/com/ti/mobpo/AppViewModelProvider.kt index 84a07fc..81f6270 100644 --- a/app/src/main/java/com/ti/mobpo/AppViewModelProvider.kt +++ b/app/src/main/java/com/ti/mobpo/AppViewModelProvider.kt @@ -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) \ No newline at end of file + (this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as PokeSearch) + diff --git a/app/src/main/java/com/ti/mobpo/PokeSearch.kt b/app/src/main/java/com/ti/mobpo/PokeSearch.kt index 3d45059..953cb2a 100644 --- a/app/src/main/java/com/ti/mobpo/PokeSearch.kt +++ b/app/src/main/java/com/ti/mobpo/PokeSearch.kt @@ -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) + } } diff --git a/app/src/main/java/com/ti/mobpo/data/Favourite.kt b/app/src/main/java/com/ti/mobpo/data/Favourite.kt index eec9166..c4c9cbf 100644 --- a/app/src/main/java/com/ti/mobpo/data/Favourite.kt +++ b/app/src/main/java/com/ti/mobpo/data/Favourite.kt @@ -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( diff --git a/app/src/main/java/com/ti/mobpo/data/FavouriteDao.kt b/app/src/main/java/com/ti/mobpo/data/FavouriteDao.kt index c409f3a..6669a1c 100644 --- a/app/src/main/java/com/ti/mobpo/data/FavouriteDao.kt +++ b/app/src/main/java/com/ti/mobpo/data/FavouriteDao.kt @@ -14,9 +14,8 @@ interface FavouriteDao { @Delete suspend fun delete(favourite: Favourite) - - @Query("SELECT id FROM favourites") - fun getAllFavoriteIds(): Flow> + @Query("SELECT * from favourites ORDER BY id ASC") + fun getAllItems(): Flow> @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 index 4083c1b..b701947 100644 --- a/app/src/main/java/com/ti/mobpo/data/FavouritesRepository.kt +++ b/app/src/main/java/com/ti/mobpo/data/FavouritesRepository.kt @@ -9,7 +9,7 @@ interface FavouritesRepository { /** * Retrieve all the items from the the given data source. */ - fun getAllFavoriteIds(): Flow> + fun getAllItems(): Flow> /** * Insert item in the data source diff --git a/app/src/main/java/com/ti/mobpo/data/OfflineFavouritesRepository.kt b/app/src/main/java/com/ti/mobpo/data/OfflineFavouritesRepository.kt index 6f4b000..12443cf 100644 --- a/app/src/main/java/com/ti/mobpo/data/OfflineFavouritesRepository.kt +++ b/app/src/main/java/com/ti/mobpo/data/OfflineFavouritesRepository.kt @@ -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> { - return favouriteDao.getAllFavoriteIds() + override fun getAllItems(): Flow> { + return favouriteDao.getAllItems() } override suspend fun insertItem(item: Favourite) { favouriteDao.insert(item) diff --git a/app/src/main/java/com/ti/mobpo/model/FavouriteUiState.kt b/app/src/main/java/com/ti/mobpo/model/FavouriteUiState.kt new file mode 100644 index 0000000..69f5cd1 --- /dev/null +++ b/app/src/main/java/com/ti/mobpo/model/FavouriteUiState.kt @@ -0,0 +1,5 @@ +package com.ti.mobpo.model + +import com.ti.mobpo.data.Favourite + +data class FavouriteUiState(val favourites: List = listOf()) \ No newline at end of file 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 e09a3c7..de389ca 100644 --- a/app/src/main/java/com/ti/mobpo/ui/Navigation.kt +++ b/app/src/main/java/com/ti/mobpo/ui/Navigation.kt @@ -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(factory = AppViewModelProvider.Factory) + val favoritesVM = viewModel(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)) } } \ No newline at end of file diff --git a/app/src/main/java/com/ti/mobpo/ui/pokesearch/PokeSearchUiState.kt b/app/src/main/java/com/ti/mobpo/ui/pokesearch/PokeSearchUiState.kt deleted file mode 100644 index 5012bf5..0000000 --- a/app/src/main/java/com/ti/mobpo/ui/pokesearch/PokeSearchUiState.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.ti.mobpo.ui.pokesearch - -data class PokeSearchUiState(val searchQuery: String = "") diff --git a/app/src/main/java/com/ti/mobpo/ui/screens/Favourites.kt b/app/src/main/java/com/ti/mobpo/ui/screens/Favourites.kt index 2fe3bd6..022b788 100644 --- a/app/src/main/java/com/ti/mobpo/ui/screens/Favourites.kt +++ b/app/src/main/java/com/ti/mobpo/ui/screens/Favourites.kt @@ -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") + } + } + } +} \ No newline at end of file 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 a2aa2a7..869b4f0 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,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) {} diff --git a/app/src/main/java/com/ti/mobpo/ui/util/FeatureManager.kt b/app/src/main/java/com/ti/mobpo/ui/util/FeatureManager.kt new file mode 100644 index 0000000..df3bf9e --- /dev/null +++ b/app/src/main/java/com/ti/mobpo/ui/util/FeatureManager.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ti/mobpo/ui/viewmodels/FavouritesViewModel.kt b/app/src/main/java/com/ti/mobpo/ui/viewmodels/FavouritesViewModel.kt new file mode 100644 index 0000000..09108a4 --- /dev/null +++ b/app/src/main/java/com/ti/mobpo/ui/viewmodels/FavouritesViewModel.kt @@ -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?>(null) + val pokemonDetails: StateFlow?> = _pokemonDetails.asStateFlow() + val accessCheckFailed: MutableState = 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 = + favouritesRepository.getAllItems().map { FavouriteUiState(it) } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = FavouriteUiState() + ) + favouritesList.collect { state -> + val detailsList = mutableListOf() + 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)) + } + } + } + } + } +} diff --git a/app/src/main/java/com/ti/mobpo/ui/pokesearch/PokeSearchViewModel.kt b/app/src/main/java/com/ti/mobpo/ui/viewmodels/PokeSearchViewModel.kt similarity index 54% rename from app/src/main/java/com/ti/mobpo/ui/pokesearch/PokeSearchViewModel.kt rename to app/src/main/java/com/ti/mobpo/ui/viewmodels/PokeSearchViewModel.kt index 5bdc5a6..f85849e 100644 --- a/app/src/main/java/com/ti/mobpo/ui/pokesearch/PokeSearchViewModel.kt +++ b/app/src/main/java/com/ti/mobpo/ui/viewmodels/PokeSearchViewModel.kt @@ -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?>(null) val pokemonDetails: StateFlow?> = _pokemonDetails.asStateFlow() + private val _initialPokemonList = MutableStateFlow?>(null) - fun search(query: String) { + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _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() - 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 } } }