diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 858f189fa..45273b603 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -43,6 +43,7 @@ android { buildFeatures { dataBinding = true + compose = true } hilt { @@ -73,6 +74,14 @@ android { lint { abortOnError = false } + + kotlinOptions { + jvmTarget = "1.8" + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.4.0" + } } dependencies { @@ -109,6 +118,7 @@ dependencies { // image loading implementation(libs.glide) implementation(libs.glide.palette) + implementation(libs.glide.compose) // bundler implementation(libs.bundler) @@ -120,11 +130,6 @@ dependencies { implementation(libs.recyclerview) implementation(libs.baseAdapter) - // custom views - implementation(libs.rainbow) - implementation(libs.androidRibbon) - implementation(libs.progressView) - // unit test testImplementation(libs.junit) testImplementation(libs.turbine) @@ -136,4 +141,18 @@ dependencies { androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso) androidTestImplementation(libs.android.test.runner) + + // compose + val composeBom = platform(libs.androidx.compose.bom) + implementation(composeBom) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.foundation.layout) + implementation(libs.androidx.compose.material.iconsExtended) + implementation(libs.androidx.compose.material3) + debugImplementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.ui.util) + implementation(libs.androidx.compose.runtime) + implementation(libs.androidx.compose.runtime.livedata) + implementation(libs.androidx.constraintlayout.compose) } diff --git a/app/src/main/kotlin/com/skydoves/pokedex/binding/ViewBinding.kt b/app/src/main/kotlin/com/skydoves/pokedex/binding/ViewBinding.kt index 2cfed8433..25b71abfa 100644 --- a/app/src/main/kotlin/com/skydoves/pokedex/binding/ViewBinding.kt +++ b/app/src/main/kotlin/com/skydoves/pokedex/binding/ViewBinding.kt @@ -16,29 +16,14 @@ package com.skydoves.pokedex.binding -import android.graphics.Color -import android.graphics.Typeface -import android.view.Gravity import android.view.View -import android.view.WindowManager import android.widget.Toast -import androidx.activity.OnBackPressedDispatcherOwner -import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.AppCompatImageView import androidx.databinding.BindingAdapter import com.bumptech.glide.Glide import com.github.florent37.glidepalette.BitmapPalette import com.github.florent37.glidepalette.GlidePalette import com.google.android.material.card.MaterialCardView -import com.skydoves.androidribbon.RibbonRecyclerView -import com.skydoves.androidribbon.ribbonView -import com.skydoves.pokedex.core.model.PokemonInfo -import com.skydoves.pokedex.utils.PokemonTypeUtils -import com.skydoves.pokedex.utils.SpacesItemDecoration -import com.skydoves.progressview.ProgressView -import com.skydoves.rainbow.Rainbow -import com.skydoves.rainbow.RainbowOrientation -import com.skydoves.rainbow.color import com.skydoves.whatif.whatIfNotNullOrEmpty object ViewBinding { @@ -68,38 +53,6 @@ object ViewBinding { ).into(view) } - @JvmStatic - @BindingAdapter("paletteImage", "paletteView") - fun bindLoadImagePaletteView(view: AppCompatImageView, url: String, paletteView: View) { - val context = view.context - Glide.with(context) - .load(url) - .listener( - GlidePalette.with(url) - .use(BitmapPalette.Profile.MUTED_LIGHT) - .intoCallBack { palette -> - val light = palette?.lightVibrantSwatch?.rgb - val domain = palette?.dominantSwatch?.rgb - if (domain != null) { - if (light != null) { - Rainbow(paletteView).palette { - +color(domain) - +color(light) - }.background(orientation = RainbowOrientation.TOP_BOTTOM) - } else { - paletteView.setBackgroundColor(domain) - } - if (context is AppCompatActivity) { - context.window.apply { - addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) - statusBarColor = domain - } - } - } - }.crossfade(true) - ).into(view) - } - @JvmStatic @BindingAdapter("gone") fun bindGone(view: View, shouldBeGone: Boolean) { @@ -109,69 +62,4 @@ object ViewBinding { View.VISIBLE } } - - @JvmStatic - @BindingAdapter("onBackPressed") - fun bindOnBackPressed(view: View, onBackPress: Boolean) { - val context = view.context - if (onBackPress && context is OnBackPressedDispatcherOwner) { - view.setOnClickListener { - context.onBackPressedDispatcher.onBackPressed() - } - } - } - - @JvmStatic - @BindingAdapter("bindPokemonTypes") - fun bindPokemonTypes(recyclerView: RibbonRecyclerView, types: List?) { - types.whatIfNotNullOrEmpty { - recyclerView.clear() - for (type in it) { - with(recyclerView) { - addRibbon( - ribbonView(context) { - setText(type.type.name) - setTextColor(Color.WHITE) - setPaddingLeft(84f) - setPaddingRight(84f) - setPaddingTop(2f) - setPaddingBottom(10f) - setTextSize(16f) - setRibbonRadius(120f) - setTextStyle(Typeface.BOLD) - setRibbonBackgroundColorResource( - PokemonTypeUtils.getTypeColor(type.type.name) - ) - }.apply { - maxLines = 1 - gravity = Gravity.CENTER - } - ) - addItemDecoration(SpacesItemDecoration()) - } - } - } - } - - @JvmStatic - @BindingAdapter("progressView_labelText") - fun bindProgressViewLabelText(progressView: ProgressView, text: String?) { - progressView.labelText = text - } - - @JvmStatic - @BindingAdapter("progressView_progress") - fun bindProgressViewProgress(progressView: ProgressView, value: Int?) { - if (value != null) { - progressView.progress = value.toFloat() - } - } - - @JvmStatic - @BindingAdapter("progressView_max") - fun bindProgressViewMax(progressView: ProgressView, value: Int?) { - if (value != null) { - progressView.max = value.toFloat() - } - } } diff --git a/app/src/main/kotlin/com/skydoves/pokedex/ui/details/AnimatedProgressBar.kt b/app/src/main/kotlin/com/skydoves/pokedex/ui/details/AnimatedProgressBar.kt new file mode 100644 index 000000000..73b66c82c --- /dev/null +++ b/app/src/main/kotlin/com/skydoves/pokedex/ui/details/AnimatedProgressBar.kt @@ -0,0 +1,78 @@ +package com.skydoves.pokedex.ui.details + +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +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.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +internal fun AnimatedProgressBar( + progressValue: Int, + maximumValue: Int, + progressColor: Color, + modifier: Modifier, +) { + + var value by remember { mutableStateOf(0) } + LaunchedEffect(progressValue) { + value = progressValue + } + // state represent the percent of the progress value compared with maximum value + val percent by remember(value) { + derivedStateOf { + value.toFloat() / maximumValue.toFloat() + } + } + val animatedPercent by animateFloatAsState( + targetValue = percent, tween( + durationMillis = 1500, delayMillis = 0, easing = LinearOutSlowInEasing + ) + ) + val shape = RoundedCornerShape(50) + Box( + modifier = modifier + .height(18.dp) + .background( + color = Color.White, shape = shape + ) + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(animatedPercent) + .background( + color = progressColor, shape = shape + ) + ) + Text( + text = "$progressValue/$maximumValue", + fontSize = 12.sp, + color = Color.White, + modifier = Modifier + .fillMaxWidth(animatedPercent) + .padding(end = 5.dp) + .align(Alignment.CenterStart), + textAlign = TextAlign.End + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/skydoves/pokedex/ui/details/DetailActivity.kt b/app/src/main/kotlin/com/skydoves/pokedex/ui/details/DetailActivity.kt index 8e95df645..b27288aca 100644 --- a/app/src/main/kotlin/com/skydoves/pokedex/ui/details/DetailActivity.kt +++ b/app/src/main/kotlin/com/skydoves/pokedex/ui/details/DetailActivity.kt @@ -19,12 +19,14 @@ package com.skydoves.pokedex.ui.details import android.os.Bundle import androidx.activity.viewModels import androidx.annotation.VisibleForTesting -import com.skydoves.bindables.BindingActivity +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView import com.skydoves.bundler.bundleNonNull import com.skydoves.bundler.intentOf -import com.skydoves.pokedex.R import com.skydoves.pokedex.core.model.Pokemon -import com.skydoves.pokedex.databinding.ActivityDetailBinding import com.skydoves.transformationlayout.TransformationCompat import com.skydoves.transformationlayout.TransformationCompat.onTransformationEndContainerApplyParams import com.skydoves.transformationlayout.TransformationLayout @@ -32,14 +34,14 @@ import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @AndroidEntryPoint -class DetailActivity : BindingActivity(R.layout.activity_detail) { +class DetailActivity : AppCompatActivity() { @set:Inject internal lateinit var detailViewModelFactory: DetailViewModel.AssistedFactory @get:VisibleForTesting internal val viewModel: DetailViewModel by viewModels { - DetailViewModel.provideFactory(detailViewModelFactory, pokemon.name) + DetailViewModel.provideFactory(detailViewModelFactory, pokemon) } private val pokemon: Pokemon by bundleNonNull(EXTRA_POKEMON) @@ -47,8 +49,21 @@ class DetailActivity : BindingActivity(R.layout.activity_ override fun onCreate(savedInstanceState: Bundle?) { onTransformationEndContainerApplyParams(this) super.onCreate(savedInstanceState) - binding.pokemon = pokemon - binding.vm = viewModel + setContentView( + ComposeView(this).apply { + setContent { + MaterialTheme { + val uiState by viewModel.uiState.collectAsState() + DetailScreen( + uiState = uiState, + onBackIconPressed = { + finish() + } + ) + } + } + } + ) } companion object { diff --git a/app/src/main/kotlin/com/skydoves/pokedex/ui/details/DetailScreen.kt b/app/src/main/kotlin/com/skydoves/pokedex/ui/details/DetailScreen.kt new file mode 100644 index 000000000..d2d6fda6c --- /dev/null +++ b/app/src/main/kotlin/com/skydoves/pokedex/ui/details/DetailScreen.kt @@ -0,0 +1,369 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalGlideComposeApi::class) + +package com.skydoves.pokedex.ui.details + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +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.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +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.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.constraintlayout.compose.ChainStyle +import androidx.constraintlayout.compose.ConstraintLayout +import com.bumptech.glide.Glide +import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi +import com.bumptech.glide.integration.compose.GlideImage +import com.github.florent37.glidepalette.BitmapPalette +import com.github.florent37.glidepalette.GlidePalette +import com.skydoves.pokedex.R +import com.skydoves.pokedex.core.model.Pokemon +import com.skydoves.pokedex.core.model.PokemonInfo + +@Composable +fun DetailScreen( + uiState: DetailUiState, + onBackIconPressed: () -> Unit = {}, +) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + colorResource(id = R.color.background) + ) + ) { + val context = LocalContext.current + LaunchedEffect(uiState) { + if (uiState is DetailUiState.Failure) { + Toast.makeText( + context, + uiState.message, + Toast.LENGTH_SHORT + ).show() + } + } + if (uiState is DetailUiState.Loading) { + CircularProgressIndicator( + modifier = Modifier + .align(Alignment.Center) + .size(50.dp), + color = colorResource(R.color.colorAccent) + ) + } + if (uiState is DetailUiState.Success) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll( + rememberScrollState() + ), horizontalAlignment = Alignment.CenterHorizontally + ) { + Header(uiState, onBackIconPressed) + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(24.dp) + ) + Text( + text = uiState.pokemon.name, + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + color = Color.White + ) + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(12.dp) + ) + RibbonsRow( + types = uiState.pokemonInfo.types.map { it.type.name }, modifier = Modifier + ) + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(24.dp) + ) + ConstraintLayout( + modifier = Modifier.fillMaxWidth() + ) { + val (weight, height, weightTitle, heightTitle) = createRefs() + createHorizontalChain(weight, height, chainStyle = ChainStyle.Spread) + Text(text = uiState.pokemonInfo.getWeightString(), + fontSize = 21.sp, + fontWeight = FontWeight.Bold, + color = Color.White, + modifier = Modifier + .padding(10.dp) + .constrainAs(weight) { + start.linkTo(parent.start) + }) + Text(text = uiState.pokemonInfo.getHeightString(), + fontSize = 21.sp, + fontWeight = FontWeight.Bold, + color = Color.White, + modifier = Modifier + .padding(10.dp) + .constrainAs(height) { + end.linkTo(parent.end) + }) + Text(text = stringResource(id = R.string.weight), + fontSize = 14.sp, + fontWeight = FontWeight.Light, + color = Color.White, + modifier = Modifier.constrainAs(weightTitle) { + top.linkTo(weight.bottom) + start.linkTo(weight.start) + end.linkTo(weight.end) + }) + Text(text = stringResource(id = R.string.height), + fontSize = 14.sp, + fontWeight = FontWeight.Light, + color = Color.White, + modifier = Modifier.constrainAs(heightTitle) { + top.linkTo(height.bottom) + start.linkTo(height.start) + end.linkTo(height.end) + }) + } + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(id = R.string.base_stats), + fontSize = 21.sp, + fontWeight = FontWeight.Bold, + color = Color.White, + modifier = Modifier.padding(10.dp) + ) + AbilityRow( + stringResource(id = R.string.hp), + uiState.pokemonInfo.hp, + PokemonInfo.maxHp, + colorResource(id = R.color.colorPrimary) + ) + Spacer(modifier = Modifier.size(10.dp)) + AbilityRow( + stringResource(id = R.string.atk), + uiState.pokemonInfo.attack, + PokemonInfo.maxAttack, + colorResource(id = R.color.md_orange_100) + ) + Spacer(modifier = Modifier.size(10.dp)) + AbilityRow( + stringResource(id = R.string.def), + uiState.pokemonInfo.defense, + PokemonInfo.maxDefense, + colorResource(id = R.color.md_blue_100) + ) + Spacer(modifier = Modifier.size(10.dp)) + AbilityRow( + stringResource(id = R.string.spd), + uiState.pokemonInfo.speed, + PokemonInfo.maxSpeed, + colorResource(id = R.color.flying) + ) + Spacer(modifier = Modifier.size(10.dp)) + AbilityRow( + stringResource(id = R.string.exp), + uiState.pokemonInfo.exp, + PokemonInfo.maxExp, + colorResource(id = R.color.md_green_200) + ) + Spacer(modifier = Modifier.height(30.dp)) + } + } + } +} + +@Composable +private fun Header( + uiState: DetailUiState.Success, + onBackIconPressed: () -> Unit, +) { + + val brush = rememberPaletteBrush( + imageUrl = uiState.pokemon.getImageUrl() + ) + ConstraintLayout( + modifier = Modifier + .fillMaxWidth() + .height(260.dp) + .background( + brush = brush, shape = RoundedCornerShape( + bottomEndPercent = 20, bottomStartPercent = 20 + ) + ) + ) { + val (image, index, backButton, appName) = createRefs() + GlideImage(model = uiState.pokemon.getImageUrl(), + contentDescription = null, + modifier = Modifier + .size(190.dp) + .constrainAs(image) { + bottom.linkTo( + anchor = parent.bottom, margin = 20.dp + ) + start.linkTo(parent.start) + end.linkTo(parent.end) + }) + Text(text = uiState.pokemonInfo.getIdString(), + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = Color.White, + modifier = Modifier.constrainAs(index) { + end.linkTo( + anchor = parent.end, margin = 12.dp + ) + top.linkTo( + anchor = parent.top, margin = 12.dp + ) + }) + IconButton(onClick = onBackIconPressed, modifier = Modifier.constrainAs(backButton) { + top.linkTo(parent.top) + start.linkTo(parent.start) + }) { + Icon( + painterResource(R.drawable.ic_arrow), tint = Color.White, modifier = Modifier.padding( + start = 12.dp, end = 6.dp, top = 12.dp + ), contentDescription = null + ) + } + Text(text = stringResource(id = R.string.app_name), + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = Color.White, + modifier = Modifier.constrainAs(appName) { + start.linkTo( + anchor = backButton.end + ) + top.linkTo( + anchor = backButton.top, margin = 10.dp + ) + bottom.linkTo(backButton.bottom) + }) + } +} + +@Composable +private fun AbilityRow( + title: String, + progressValue: Int, + maximumValue: Int, + progressColor: Color, +) { + Row { + Spacer(modifier = Modifier.width(26.dp)) + Text( + text = title, color = Color.White, fontSize = 12.sp, modifier = Modifier.width(40.dp) + ) + AnimatedProgressBar( + modifier = Modifier.weight(1f), + progressValue = progressValue, + maximumValue = maximumValue, + progressColor = progressColor + ) + Spacer(modifier = Modifier.width(26.dp)) + } +} + +@Composable +fun rememberPaletteBrush( + imageUrl: String, +): Brush { + val context = LocalContext.current + var brush by remember { mutableStateOf(Brush.verticalGradient(List(2) { Color.Transparent })) } + DisposableEffect(imageUrl) { + Glide.with(context).load(imageUrl).listener( + GlidePalette.with(imageUrl).use(BitmapPalette.Profile.MUTED_LIGHT).intoCallBack { palette -> + val light = palette?.lightVibrantSwatch?.rgb + val domain = palette?.dominantSwatch?.rgb + if (domain != null) { + brush = if (light != null) { + Brush.verticalGradient(listOf(Color(domain), Color(light))) + } else { + Brush.verticalGradient(List(2) { Color(domain) }) + } + } + }.crossfade(true) + ).preload() + onDispose {} + } + return brush +} + +@Preview +@Composable +fun DetailScreenLoadingPreview() { + MaterialTheme { + DetailScreen(uiState = DetailUiState.Loading) + } +} + +@Preview +@Composable +fun DetailScreenSuccessPreview() { + MaterialTheme { + val pokemon = Pokemon( + 0, "charmander", "https://pokeapi.co/api/v2/pokemon/4/" + ) + val pokemonInfo = PokemonInfo( + id = 15, + name = "charmander", + height = 50, + weight = 10, + experience = 3, + types = listOf(PokemonInfo.TypeResponse(1, PokemonInfo.Type("ground"))) + ) + DetailScreen( + uiState = DetailUiState.Success( + pokemon = pokemon, pokemonInfo = pokemonInfo + ) + ) + } +} diff --git a/app/src/main/kotlin/com/skydoves/pokedex/ui/details/DetailViewModel.kt b/app/src/main/kotlin/com/skydoves/pokedex/ui/details/DetailViewModel.kt index 2045c02f0..d59f340e4 100644 --- a/app/src/main/kotlin/com/skydoves/pokedex/ui/details/DetailViewModel.kt +++ b/app/src/main/kotlin/com/skydoves/pokedex/ui/details/DetailViewModel.kt @@ -16,61 +16,75 @@ package com.skydoves.pokedex.ui.details -import androidx.databinding.Bindable import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import com.skydoves.bindables.BindingViewModel -import com.skydoves.bindables.asBindingProperty -import com.skydoves.bindables.bindingProperty import com.skydoves.core.data.repository.DetailRepository +import com.skydoves.pokedex.core.model.Pokemon import com.skydoves.pokedex.core.model.PokemonInfo import dagger.assisted.Assisted import dagger.assisted.AssistedInject -import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update import timber.log.Timber class DetailViewModel @AssistedInject constructor( detailRepository: DetailRepository, - @Assisted private val pokemonName: String -) : BindingViewModel() { + @Assisted private val pokemon: Pokemon +) : ViewModel() { - @get:Bindable - var isLoading: Boolean by bindingProperty(true) - private set - - @get:Bindable - var toastMessage: String? by bindingProperty(null) - private set - - private val pokemonInfoFlow: Flow = detailRepository.fetchPokemonInfo( - name = pokemonName, - onComplete = { isLoading = false }, - onError = { toastMessage = it } - ) - - @get:Bindable - val pokemonInfo: PokemonInfo? by pokemonInfoFlow.asBindingProperty(viewModelScope, null) + private val _uiState: MutableStateFlow = MutableStateFlow(DetailUiState.Loading) + val uiState: StateFlow = _uiState init { Timber.d("init DetailViewModel") + detailRepository.fetchPokemonInfo( + name = pokemon.name, + onComplete = {}, + onError = { message -> + _uiState.update { + DetailUiState.Failure(message!!) + } + } + ).onEach { pokemonInfo -> + _uiState.update { + DetailUiState.Success(pokemonInfo, pokemon) + } + }.launchIn(viewModelScope) } @dagger.assisted.AssistedFactory interface AssistedFactory { - fun create(pokemonName: String): DetailViewModel + fun create(pokemon: Pokemon): DetailViewModel } companion object { fun provideFactory( assistedFactory: AssistedFactory, - pokemonName: String + pokemon: Pokemon ): ViewModelProvider.Factory = object : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { - return assistedFactory.create(pokemonName) as T + return assistedFactory.create(pokemon) as T } } } } + +sealed interface DetailUiState { + + object Loading : DetailUiState + + data class Success( + val pokemonInfo: PokemonInfo, + val pokemon: Pokemon + ) : DetailUiState + + data class Failure( + val message: String + ) : DetailUiState +} diff --git a/app/src/main/kotlin/com/skydoves/pokedex/ui/details/RibbonsRow.kt b/app/src/main/kotlin/com/skydoves/pokedex/ui/details/RibbonsRow.kt new file mode 100644 index 000000000..5da3870e1 --- /dev/null +++ b/app/src/main/kotlin/com/skydoves/pokedex/ui/details/RibbonsRow.kt @@ -0,0 +1,81 @@ +/* + * Designed and developed by 2023 houssem85 (Houssemeddine Daoud) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.skydoves.pokedex.ui.details + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.skydoves.pokedex.utils.PokemonTypeUtils + +@Composable +internal fun RibbonsRow( + types: List, + modifier: Modifier = Modifier, +) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(20.dp), + modifier = modifier + ) { + val shape = RoundedCornerShape(50) + items(types) { + Box( + modifier = modifier + .size(140.dp, 26.dp) + .background( + color = colorResource(id = PokemonTypeUtils.getTypeColor(it)), + shape = shape + ) + ) { + Text( + text = it, + modifier = Modifier.align(Alignment.Center), + color = Color.White, + fontSize = 16.sp, + textAlign = TextAlign.Center + ) + } + } + } +} + +@Preview +@Composable +fun RibbonsRowPreview() { + MaterialTheme { + RibbonsRow( + listOf( + "ground", + "fighting" + ) + ) + } +} diff --git a/app/src/main/res/layout/activity_detail.xml b/app/src/main/res/layout/activity_detail.xml deleted file mode 100644 index 0134eda98..000000000 --- a/app/src/main/res/layout/activity_detail.xml +++ /dev/null @@ -1,361 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/core-model/src/main/kotlin/com/skydoves/pokedex/core/model/PokemonInfo.kt b/core-model/src/main/kotlin/com/skydoves/pokedex/core/model/PokemonInfo.kt index fdef86e65..e0e284562 100644 --- a/core-model/src/main/kotlin/com/skydoves/pokedex/core/model/PokemonInfo.kt +++ b/core-model/src/main/kotlin/com/skydoves/pokedex/core/model/PokemonInfo.kt @@ -18,7 +18,6 @@ package com.skydoves.pokedex.core.model import com.squareup.moshi.Json import com.squareup.moshi.JsonClass -import kotlin.random.Random @JsonClass(generateAdapter = true) data class PokemonInfo( @@ -29,31 +28,26 @@ data class PokemonInfo( @field:Json(name = "weight") val weight: Int, @field:Json(name = "base_experience") val experience: Int, @field:Json(name = "types") val types: List, - val hp: Int = Random.nextInt(maxHp), - val attack: Int = Random.nextInt(maxAttack), - val defense: Int = Random.nextInt(maxDefense), - val speed: Int = Random.nextInt(maxSpeed), - val exp: Int = Random.nextInt(maxExp) + val hp: Int = (50..maxHp).random(), + val attack: Int = (50..maxAttack).random(), + val defense: Int = (50..maxDefense).random(), + val speed: Int = (50..maxSpeed).random(), + val exp: Int = (200..maxExp).random(), ) { fun getIdString(): String = String.format("#%03d", id) fun getWeightString(): String = String.format("%.1f KG", weight.toFloat() / 10) fun getHeightString(): String = String.format("%.1f M", height.toFloat() / 10) - fun getHpString(): String = " $hp/$maxHp" - fun getAttackString(): String = " $attack/$maxAttack" - fun getDefenseString(): String = " $defense/$maxDefense" - fun getSpeedString(): String = " $speed/$maxSpeed" - fun getExpString(): String = " $exp/$maxExp" @JsonClass(generateAdapter = true) data class TypeResponse( @field:Json(name = "slot") val slot: Int, - @field:Json(name = "type") val type: Type + @field:Json(name = "type") val type: Type, ) @JsonClass(generateAdapter = true) data class Type( - @field:Json(name = "name") val name: String + @field:Json(name = "name") val name: String, ) companion object { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c33d0334a..702dac3d2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,13 +21,11 @@ coroutines = "1.6.4" whatIf = "1.1.2" glide = "4.13.2" glidePalette = "2.1.2" +glideCompose = "1.0.0-alpha.1" bundler = "1.0.4" transformationLayout = "1.1.1" recyclerView = "1.2.1" baseAdapter = "1.0.4" -androidRibbon = "1.0.4" -progressView = "1.1.3" -rainbow = "1.0.3" timber = "5.0.1" baselineProfiles = "1.2.0" macroBenchmark = "1.1.0" @@ -42,6 +40,11 @@ androidTestRunner = "1.3.0-beta01" espresso = "3.3.0" mockitoKotlin = "2.2.0" mockitoInline = "3.5.13" +androidxComposeBom = "2023.01.00" +androidxComposeCompiler = "1.4.1" +androidxComposeMaterial3 = "1.1.0-alpha06" +androidxComposeRuntimeTracing = "1.0.0-alpha01" +androidxConstraintlayoutCompose = "1.0.1" [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -87,19 +90,32 @@ coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", ve whatif = { module = "com.github.skydoves:whatif", version.ref = "whatIf" } glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" } glide-palette = { module = "com.github.florent37:glidepalette", version.ref = "glidePalette" } +glide-compose = { module = "com.github.bumptech.glide:compose", version.ref = "glideCompose" } bundler = { module = "com.github.skydoves:bundler", version.ref = "bundler" } transformationLayout = { module = "com.github.skydoves:transformationlayout", version.ref = "transformationLayout" } recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerView" } baseAdapter = { module = "com.github.skydoves:baserecyclerviewadapter", version.ref = "baseAdapter" } -rainbow = { module = "com.github.skydoves:rainbow", version.ref = "rainbow" } -androidRibbon = { module = "com.github.skydoves:androidribbon", version.ref = "androidRibbon" } -progressView = { module = "com.github.skydoves:progressview", version.ref = "progressView" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBom" } +androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } +androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout" } +androidx-compose-material-iconsExtended = { group = "androidx.compose.material", name = "material-icons-extended" } +androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "androidxComposeMaterial3" } +androidx-compose-material3-windowSizeClass = { group = "androidx.compose.material3", name = "material3-window-size-class", version.ref = "androidxComposeMaterial3" } +androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime" } +androidx-compose-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" } +androidx-compose-runtime-tracing = { group = "androidx.compose.runtime", name = "runtime-tracing", version.ref = "androidxComposeRuntimeTracing" } +androidx-compose-ui-test = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-compose-ui-testManifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-compose-ui-util = { group = "androidx.compose.ui", name = "ui-util" } +androidx-constraintlayout-compose = { group = "androidx.constraintlayout", name = "constraintlayout-compose", version.ref = "androidxConstraintlayoutCompose" } # unit test junit = { module = "junit:junit", version.ref = "junit" } -mockito-kotlin = { module = "com.nhaarman.mockitokotlin2:mockito-kotlin", version.ref = "mockitoKotlin" } -mockito-inline = { module = "org.mockito:mockito-inline", version.ref = "mockitoInline" } +mockito-kotlin = { module = "com.nhaarman.mockitokotlin2:mockito-kotlin", version.ref = "mockitoKotlin" } +mockito-inline = { module = "org.mockito:mockito-inline", version.ref = "mockitoInline" } turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } truth = { module = "com.google.truth:truth", version.ref = "truth" }