pokedex view

This commit is contained in:
Owen LeJeune
2022-09-16 09:59:43 -04:00
parent edd4559971
commit b1e2902581
18 changed files with 328 additions and 121 deletions

View File

@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetDropDown">
<targetSelectedWithDropDown>
<Target>
<type value="QUICK_BOOT_TARGET" />
<deviceKey>
<Key>
<type value="VIRTUAL_DEVICE_PATH" />
<value value="$USER_HOME$/.android/avd/Pixel_3a_API_33.avd" />
</Key>
</deviceKey>
</Target>
</targetSelectedWithDropDown>
<timeTargetWasSelectedWithDropDown value="2022-09-15T01:01:05.349795Z" />
</component>
</project>

View File

@@ -2,6 +2,8 @@ package com.owenlejeune.mydex.api.pokeapi.v2
import com.owenlejeune.mydex.api.pokeapi.v2.model.misc.PaginatedResponse
import com.owenlejeune.mydex.api.pokeapi.v2.model.pokemon.Pokemon
import com.owenlejeune.mydex.api.pokeapi.v2.model.pokemon.PokemonSpecies
import com.owenlejeune.mydex.api.pokeapi.v2.model.pokemon.PokemonType
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Path
@@ -15,4 +17,10 @@ interface PokemonApi {
@GET("pokemon/")
suspend fun getPaginatedPokemon(@Query("offset") offset: Int, @Query("limit") limit: Int): Response<PaginatedResponse>
@GET("pokemon-species/{id}")
suspend fun getPokemonSpecies(@Path("id") id: Int): Response<PokemonSpecies>
@GET("type/{id}")
suspend fun getPokemonType(@Path("id") id: Int): Response<PokemonType>
}

View File

@@ -3,6 +3,8 @@ package com.owenlejeune.mydex.api.pokeapi.v2
import android.util.Log
import com.owenlejeune.mydex.api.pokeapi.v2.model.misc.PaginatedResponse
import com.owenlejeune.mydex.api.pokeapi.v2.model.pokemon.Pokemon
import com.owenlejeune.mydex.api.pokeapi.v2.model.pokemon.PokemonSpecies
import com.owenlejeune.mydex.api.pokeapi.v2.model.pokemon.PokemonType
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import retrofit2.Response
@@ -10,7 +12,7 @@ import retrofit2.Response
class PokemonService: KoinComponent {
companion object {
private const val DEFAULT_LIMIT = 20
private const val DEFAULT_LIMIT = 60
val TAG = PokemonService::class.java.simpleName
}
@@ -23,7 +25,7 @@ class PokemonService: KoinComponent {
suspend fun getPaginatedPokemon(page: Int = 1): Response<PaginatedResponse> {
val limit = DEFAULT_LIMIT
val offset = DEFAULT_LIMIT * page
val offset = DEFAULT_LIMIT * (page-1)
Log.d(TAG, "Paginated: page=$page, limit=$limit, offset=$offset")
@@ -32,4 +34,12 @@ class PokemonService: KoinComponent {
}
}
suspend fun getPokemonSpecies(id: Int): Response<PokemonSpecies> {
return service.getPokemonSpecies(id)
}
suspend fun getPokemonType(id: Int): Response<PokemonType> {
return service.getPokemonType(id)
}
}

View File

@@ -4,6 +4,5 @@ import com.google.gson.annotations.SerializedName
class NameAndLanguage(
@SerializedName("name") val name: String,
@SerializedName("language.name") val language: String,
@SerializedName("language.url") val languageUrl: String
@SerializedName("language") val language: NameAndUrl
)

View File

@@ -5,11 +5,13 @@ import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.owenlejeune.mydex.api.pokeapi.v2.PokemonService
import com.owenlejeune.mydex.api.pokeapi.v2.model.misc.NameAndUrl
import com.owenlejeune.mydex.api.pokeapi.v2.model.misc.PaginatedResponse
import com.owenlejeune.mydex.api.pokeapi.v2.model.pokemon.Pokemon
import com.owenlejeune.mydex.utils.AppCache
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class PokemonPagingSource: PagingSource<Int, Pokemon>(), KoinComponent {
class PokemonPagingSource: PagingSource<Int, NameAndUrl>(), KoinComponent {
companion object {
private val TAG = PokemonPagingSource::class.java.simpleName
@@ -17,21 +19,22 @@ class PokemonPagingSource: PagingSource<Int, Pokemon>(), KoinComponent {
private val pokemonService: PokemonService by inject()
override fun getRefreshKey(state: PagingState<Int, Pokemon>): Int? {
override fun getRefreshKey(state: PagingState<Int, NameAndUrl>): Int? {
return state.anchorPosition
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Pokemon> {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, NameAndUrl> {
return try {
val nextPage = params.key ?: 1
val response = pokemonService.getPokemon(nextPage)
val response = pokemonService.getPaginatedPokemon(nextPage)
if (response.isSuccessful) {
val responseBody = response.body()!!
// Log.d(TAG, "${results.map { it.name }}")
val responseBody = response.body()
val results = responseBody?.results ?: emptyList()
Log.d(TAG, "${results.map { it.name }}")
LoadResult.Page(
data = listOf(responseBody),
data = results,
prevKey = if (nextPage == 1) null else nextPage - 1,
nextKey = if (responseBody == null) null else nextPage + 1
nextKey = if (results.isEmpty()) null else nextPage + 1
)
} else {
LoadResult.Invalid()
@@ -41,4 +44,23 @@ class PokemonPagingSource: PagingSource<Int, Pokemon>(), KoinComponent {
}
}
// fun t() {
// val nextPage = params.key ?: 1
// var pokemon = AppCache.cachedPokemon[nextPage]
// if (pokemon == null) {
// val response = pokemonService.getPokemon(nextPage)
// if (response.isSuccessful) {
// pokemon = response.body()!!
// } else {
// return LoadResult.Invalid()
// }
// }
// Log.d(TAG, pokemon.name)
// LoadResult.Page(
// data = listOf(pokemon),
// prevKey = if (nextPage == 1) null else nextPage - 1,
// nextKey = nextPage + 1
// )
// }
}

View File

@@ -12,7 +12,7 @@ import com.owenlejeune.mydex.api.pokeapi.v2.paging.PokemonPagingSource
import kotlinx.coroutines.flow.Flow
class PokemonViewModel: ViewModel() {
val pokemon: Flow<PagingData<Pokemon>> = Pager(PagingConfig(pageSize = Int.MAX_VALUE)) {
val pokemon: Flow<PagingData<NameAndUrl>> = Pager(PagingConfig(pageSize = Int.MAX_VALUE)) {
PokemonPagingSource()
}.flow.cachedIn(viewModelScope)
}

View File

@@ -19,15 +19,6 @@ fun <T: Any> LazyGridScope.lazyPagingItems(
}
}
fun <T: Any> LazyGridScope.listItems(
items: List<T>,
itemContent: @Composable (value: T) -> Unit
) {
items(items.size) { index ->
itemContent(items[index])
}
}
fun LazyGridScope.header(
content: @Composable LazyGridItemScope.() -> Unit
) {
@@ -37,32 +28,4 @@ fun LazyGridScope.header(
},
content = content
)
}
fun <T: Any> LazyListScope.listItems(
items: Collection<T>,
itemContent: @Composable (value: T) -> Unit
) {
items(items.size) { index ->
itemContent(items.elementAt(index))
}
}
fun <T: Any?> LazyListScope.listItems(
items: List<T?>,
key: (T?) -> Any,
itemContent: @Composable (value: T?) -> Unit
) {
items(items.size, key = { key(items[it]) }) { index ->
itemContent(items[index])
}
}
fun <T: Any> LazyListScope.lazyPagingItems(
lazyPagingItems: LazyPagingItems<T>,
itemContent: @Composable LazyItemScope.(value: T?) -> Unit
) {
items(lazyPagingItems.itemCount) { index ->
itemContent(lazyPagingItems[index])
}
}

View File

@@ -1,5 +1,5 @@
package com.owenlejeune.mydex.extensions
fun String.charAtFromEnd(index: Int): Char {
return get(length-1-index)
fun String.getIdFromUrl(): Int {
return split("/").find { it.toIntOrNull() != null }?.toInt() ?: -1
}

View File

@@ -7,9 +7,11 @@ import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.kieronquinn.monetcompat.app.MonetCompatActivity
import com.owenlejeune.mydex.preferences.AppPreferences
import com.owenlejeune.mydex.ui.navigation.DataNavItem
@@ -17,6 +19,8 @@ import com.owenlejeune.mydex.ui.navigation.MainNavItem
import com.owenlejeune.mydex.ui.theme.MYDexTheme
import com.owenlejeune.mydex.ui.views.AppScaffold
import com.owenlejeune.mydex.ui.views.PokedexView
import com.owenlejeune.mydex.ui.views.PokemonDetailView
import com.owenlejeune.mydex.utils.AppCache
import org.koin.java.KoinJavaComponent
class MainActivity : MonetCompatActivity() {
@@ -49,6 +53,17 @@ class MainActivity : MonetCompatActivity() {
composable(DataNavItem.Pokedex.route) {
PokedexView(appNavController = appNavController)
}
composable(
MainNavItem.PokemonDetailView.route.plus("/{id}"),
arguments = listOf(
navArgument("id") { type = NavType.IntType }
)
) {
val id = it.arguments?.getInt("id")
id?.let {
PokemonDetailView(pokemonId = it)
}
}
}
}
}

View File

@@ -30,6 +30,7 @@ import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.modifier.modifierLocalConsumer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
@@ -191,4 +192,24 @@ fun MenuItemButton(
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PokemonTypeLabel(
type: String
) {
Card(
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(containerColor = Color(0x38FFFFFF))
) {
Text(
text = type,
modifier = Modifier
.padding(vertical = 3.dp, horizontal = 8.dp),
textAlign = TextAlign.Center,
fontSize = 12.sp,
color = Color.White
)
}
}

View File

@@ -22,7 +22,7 @@ sealed class DataNavItem(
val title = resourceUtils.getString(titleRes)
object Pokedex: DataNavItem("pokedex_route", PokeTeal, R.string.pokedex_nav_title)
object Pokedex: DataNavItem("pokedex_route", PokeGreen, R.string.pokedex_nav_title)
object Moves: DataNavItem("moves_route", PokeRed, R.string.moves_nav_title)
object Abilities: DataNavItem("abilities_route", PokeLightBlue, R.string.abilities_nav_title)
object Items: DataNavItem("items_route", PokeYellow, R.string.items_nav_title)

View File

@@ -7,5 +7,6 @@ sealed class MainNavItem(val route: String) {
}
object MainView: MainNavItem("main_route")
object PokemonDetailView: MainNavItem("pokemon_route")
}

View File

@@ -3,15 +3,17 @@ package com.owenlejeune.mydex.ui.theme
import androidx.compose.ui.graphics.Color
val PokeBlack = Color(0xFF303943)
val PokeGrey = Color(0xFF434C57)
val PokeWhite = Color(0xFF898A8B)
val PokeBlue = Color(0xFF429BED)
val PokeBrown = Color(0xFFB1736C)
val PokeLightBlue = Color(0xFF58ABF6)
val PokeLightBrown = Color(0xFFCA8179)
val PokeLightPurple = Color(0xFF9F5BBA)
val PokeLightRed = Color(0xFFF7786B)
val PokeLightTeal = Color(0xFF2CDAB1)
val PokeLightGreen = Color(0xFF2CDAB1)
val PokeLightYellow = Color(0xFFFFCE4B)
val PokePurple = Color(0xFF7C538C)
val PokeRed = Color(0xFFFA6555)
val PokeTeal = Color(0xFF4FC1A6)
val PokeGreen = Color(0xFF4FC1A6)
val PokeYellow = Color(0xFFF6C747)

View File

@@ -0,0 +1,13 @@
package com.owenlejeune.mydex.ui.views
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import com.owenlejeune.mydex.utils.AppCache
@Composable
fun PokemonDetailView(
pokemonId: Int
) {
val pokemon = AppCache.cachedSpecies[pokemonId]
Text(text = pokemon.name)
}

View File

@@ -4,23 +4,21 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
@@ -30,11 +28,16 @@ import com.owenlejeune.mydex.R
import com.owenlejeune.mydex.api.pokeapi.v2.PokemonService
import com.owenlejeune.mydex.api.pokeapi.v2.model.misc.NameAndUrl
import com.owenlejeune.mydex.api.pokeapi.v2.model.pokemon.Pokemon
import com.owenlejeune.mydex.api.pokeapi.v2.model.pokemon.PokemonSpecies
import com.owenlejeune.mydex.api.pokeapi.v2.model.pokemon.PokemonType
import com.owenlejeune.mydex.api.pokeapi.v2.viewmodel.PokemonViewModel
import com.owenlejeune.mydex.extensions.charAtFromEnd
import com.owenlejeune.mydex.extensions.getIdFromUrl
import com.owenlejeune.mydex.extensions.header
import com.owenlejeune.mydex.extensions.lazyPagingItems
import kotlinx.coroutines.launch
import com.owenlejeune.mydex.ui.components.PokemonTypeLabel
import com.owenlejeune.mydex.ui.navigation.MainNavItem
import com.owenlejeune.mydex.utils.AppCache
import com.owenlejeune.mydex.utils.ColorUtils
import org.koin.java.KoinJavaComponent.get
@Composable
@@ -57,6 +60,39 @@ fun PokedexView(
colorFilter = ColorFilter.tint(tint)
)
val lazyPokemon = PokemonViewModel().pokemon.collectAsLazyPagingItems()
LazyVerticalGrid(
columns = GridCells.Fixed(2),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.padding(start = 36.dp, end = 36.dp)
.fillMaxSize()
) {
header {
Column {
Spacer(modifier = Modifier.height(160.dp))
Text(
text = stringResource(id = R.string.pokedex_nav_title),
style = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 30.sp,
color = MaterialTheme.colorScheme.onBackground
),
modifier = Modifier.padding(bottom = 50.dp)
)
}
}
lazyPagingItems(lazyPokemon) { item ->
item?.let {
PokedexCard(appNavController = appNavController, nameAndUrl = item)
}
}
}
SmallTopAppBar(
modifier = Modifier
.statusBarsPadding()
@@ -72,54 +108,45 @@ fun PokedexView(
containerColor = Color.Transparent.copy(alpha = 0.4f)
)
)
val scrollState = rememberScrollState()
val lazyPokemon = PokemonViewModel().pokemon.collectAsLazyPagingItems()
LazyVerticalGrid(
columns = GridCells.Fixed(2),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.padding(start = 36.dp, end = 36.dp, top = 160.dp)
.fillMaxSize()
) {
header {
Text(
text = stringResource(id = R.string.main_screen_title),
style = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 30.sp,
color = MaterialTheme.colorScheme.onBackground
),
modifier = Modifier.padding(bottom = 50.dp)
)
}
lazyPagingItems(lazyPokemon) { item ->
item?.let {
PokedexCard(pokemon = item)
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PokedexCard(
pokemon: Pokemon
appNavController: NavController,
nameAndUrl: NameAndUrl,
service: PokemonService = get(PokemonService::class.java)
) {
val pokemonId = nameAndUrl.url.getIdFromUrl()
val pokemon = remember { mutableStateOf<Pokemon?>(AppCache.cachedPokemon[pokemonId]) }
LaunchedEffect(key1 = pokemon.value) {
fetchPokemon(nameAndUrl, service, pokemon)
}
val species = remember { mutableStateOf<PokemonSpecies?>(AppCache.cachedSpecies[pokemonId]) }
LaunchedEffect(key1 = pokemon.value) {
fetchPokemonSpecies(pokemon, species, service)
}
val defBg = ColorUtils.pokeColorToComposeColor(color = "")
val bgColor = remember { mutableStateOf(defBg) }
Card(
modifier = Modifier
.height(80.dp)
.height(160.dp)
.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer)
colors = CardDefaults.cardColors(containerColor = bgColor.value),
onClick = {
pokemon.value?.let {
appNavController.navigate("${MainNavItem.PokemonDetailView.route}/${it.id}")
}
}
) {
Box(
modifier = Modifier.fillMaxSize()
modifier = Modifier
.fillMaxSize()
) {
AsyncImage(
model = R.drawable.pokeball_s,
@@ -131,13 +158,115 @@ fun PokedexCard(
colorFilter = ColorFilter.tint(Color.White.copy(alpha = 0.15f))
)
Text(
Box(
modifier = Modifier
.align(Alignment.CenterStart)
.padding(start = 16.dp),
text = pokemon.name,
style = MaterialTheme.typography.bodyLarge.copy(color = Color.White)
)
.fillMaxSize()
.padding(all = 12.dp)
) {
species.value?.let { species ->
bgColor.value = ColorUtils.pokeColorToComposeColor(color = species.color.name)
val locale = Locale.current.language
val name = species.names.find { it.language.name == locale }?.name ?: species.name
val dexNumber = pokemon.value!!.id.toString().padStart(3, '0')
Text(
modifier = Modifier
.align(Alignment.TopStart),
text = name,
style = MaterialTheme.typography.bodyLarge.copy(color = Color.White, fontWeight = FontWeight.Bold)
)
Column(
modifier = Modifier
.align(Alignment.TopStart)
.padding(top = 35.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
pokemon.value?.types?.forEach { type ->
val id = type.type.url.getIdFromUrl()
val pokemonType = remember { mutableStateOf<PokemonType?>(AppCache.cachedTypes[id]) }
LaunchedEffect(key1 = pokemonType.value) {
fetchPokemonType(id, pokemonType, service)
}
pokemonType.value?.let { t ->
val typeName = t.names.find { it.language.name == locale }?.name ?: ""//type.type.name
PokemonTypeLabel(type = typeName)
}
}
}
Text(
modifier = Modifier
.align(Alignment.TopEnd),
text = "#${dexNumber}",
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold),
color = Color.Unspecified.copy(alpha = 0.3f)
)
AsyncImage(
model = pokemon.value?.sprites?.frontDefault,
contentDescription = name,
modifier = Modifier
.align(Alignment.BottomEnd)
.size(70.dp),
contentScale = ContentScale.FillBounds
)
}
}
}
}
}
private suspend fun fetchPokemon(
nameAndUrl: NameAndUrl,
service: PokemonService,
pokemon: MutableState<Pokemon?>
) {
if (pokemon.value == null) {
val id = nameAndUrl.url.getIdFromUrl()
service.getPokemon(id).apply {
if (isSuccessful) {
body()?.let {
pokemon.value = it
AppCache.cachedPokemon.put(it.id, it)
}
}
}
}
}
private suspend fun fetchPokemonSpecies(
pokemon: MutableState<Pokemon?>,
species: MutableState<PokemonSpecies?>,
service: PokemonService
) {
if (species.value == null && pokemon.value != null) {
service.getPokemonSpecies(pokemon.value!!.id).apply {
if (isSuccessful) {
body()?.let {
species.value = it
AppCache.cachedSpecies.put(it.id, it)
}
}
}
}
}
private suspend fun fetchPokemonType(
id: Int,
pokemonType: MutableState<PokemonType?>,
service: PokemonService
) {
if (pokemonType.value == null) {
service.getPokemonType(id).apply {
if (isSuccessful) {
body()?.let {
pokemonType.value = it
AppCache.cachedTypes.put(it.id, it)
}
}
}
}
}

View File

@@ -0,0 +1,14 @@
package com.owenlejeune.mydex.utils
import android.util.SparseArray
import com.owenlejeune.mydex.api.pokeapi.v2.model.pokemon.Pokemon
import com.owenlejeune.mydex.api.pokeapi.v2.model.pokemon.PokemonSpecies
import com.owenlejeune.mydex.api.pokeapi.v2.model.pokemon.PokemonType
object AppCache {
var cachedSpecies = SparseArray<PokemonSpecies>()
var cachedPokemon = SparseArray<Pokemon>()
var cachedTypes = SparseArray<PokemonType>()
}

View File

@@ -0,0 +1,27 @@
package com.owenlejeune.mydex.utils
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import com.owenlejeune.mydex.ui.theme.*
object ColorUtils {
@Composable
fun pokeColorToComposeColor(color: String): Color {
return when (color) {
"green" -> PokeGreen
"red" -> PokeRed
"blue" -> PokeBlue
"black" -> PokeBlack
"brown" -> PokeBrown
"purple" -> PokePurple
"pink" -> PokeLightBrown
"grey" -> PokeGrey
"white" -> PokeWhite
"yellow" -> PokeYellow
else -> MaterialTheme.colorScheme.secondaryContainer
}
}
}

View File

@@ -6,7 +6,7 @@
<string name="main_search_placeholder">Search Pokémon, Move, Ability, etc.</string>
<!-- nav titles -->
<string name="pokedex_nav_title">Pokedex</string>
<string name="pokedex_nav_title">Pokédex</string>
<string name="moves_nav_title">Moves</string>
<string name="abilities_nav_title">Abilities</string>
<string name="items_nav_title">Items</string>