Skip to content

Commit

Permalink
Refactor to MVVM
Browse files Browse the repository at this point in the history
  • Loading branch information
NinoDLC committed Dec 1, 2021
1 parent 76b7925 commit 39c56a7
Show file tree
Hide file tree
Showing 17 changed files with 1,577 additions and 291 deletions.
2 changes: 2 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ dependencies {
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.4.0-alpha03"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.4.0-alpha03"

implementation "androidx.fragment:fragment-ktx:1.3.6"

// Hilt
implementation("com.google.dagger:hilt-android:$hilt_version")
kapt("com.google.dagger:hilt-android-compiler:$hilt_version")
Expand Down
56 changes: 44 additions & 12 deletions app/src/main/java/fr/delcey/mvctomvvm/data/PokemonRepository.kt
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
package fr.delcey.mvctomvvm.data

import android.os.Handler
import android.os.Looper
import androidx.core.os.HandlerCompat
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import fr.delcey.mvctomvvm.data.pokemon.PokemonResponse
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.TreeSet
import java.util.concurrent.Executors
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class PokemonRepository @Inject constructor() {

private val executor = Executors.newFixedThreadPool(4)
private val mainThreadHandler: Handler = HandlerCompat.createAsync(Looper.getMainLooper())

private val pokemonDatasource: PokemonDatasource

init {
Expand All @@ -31,20 +41,42 @@ class PokemonRepository @Inject constructor() {
pokemonDatasource = retrofit.create(PokemonDatasource::class.java)
}

fun getPokemons(): List<PokemonResponse> {
val pokemonResponses = mutableListOf<PokemonResponse>()

for (i in 1..3) {
// TODO That's sad, we have to wait for ALL the pokemons to be queried sequentially to display them
// We should display them as soon as we get them !
// Or even better, query them in parallel !
// And why not both ?!
val response = pokemonDatasource.getPokemonById(i.toString()).execute().body()
if (response != null) {
pokemonResponses.add(response)
fun getPokemonsLiveData(): LiveData<List<PokemonResponse>> {
val pokemonsMutableLiveData = MutableLiveData<List<PokemonResponse>>()
val pokemonResponses = TreeSet<PokemonResponse> { o1, o2 ->
compareValues(o1?.id, o2?.id)
}

for (pokemonId in 1..30) {
queryPokemonById(pokemonId.toString()) { pokemonResponse ->
pokemonResponses.add(pokemonResponse)
pokemonsMutableLiveData.value = pokemonResponses.sortedBy { it.id }
}
}

return pokemonResponses
return pokemonsMutableLiveData
}

fun getPokemonByIdLiveData(pokemonId: String): LiveData<PokemonResponse> {
val pokemonMutableLiveData = MutableLiveData<PokemonResponse>()

queryPokemonById(pokemonId) { pokemonResponse ->
pokemonMutableLiveData.value = pokemonResponse
}

return pokemonMutableLiveData
}

private fun queryPokemonById(
pokemonId: String,
onQueried: (PokemonResponse) -> Unit
) {
executor.execute {
pokemonDatasource.getPokemonById(pokemonId).execute().body()?.let {
mainThreadHandler.post {
onQueried(it)
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,4 @@ data class PokemonResponse(
@field:SerializedName("order")
val order: Int? = null
) : Parcelable {
// TODO Deep inside our POJOS lies the business rules of our project... Not easily manageable (would you find it without the TODO ?)
// the "business rule" here is how to display the name and the number of the Pokemon

// TODO Meaningful testing : testing these functions is easy but it doesn't really check what function should be used on which screen
fun getFormattedNumber() = "#$id"

fun getCapitalizedName() = name?.capitalize()

// TODO Meaningful coding : "getDetailedName" should be used on the DetailActivity, right ? Nope it's used in the ListAdapter !
fun getDetailedName() = "${getFormattedNumber()} ${getCapitalizedName()}"
}
34 changes: 0 additions & 34 deletions app/src/main/java/fr/delcey/mvctomvvm/data/pokemon/Type.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package fr.delcey.mvctomvvm.data.pokemon

import android.graphics.Color
import android.os.Parcelable
import androidx.annotation.ColorInt
import com.google.gson.annotations.SerializedName
import kotlinx.android.parcel.Parcelize

Expand All @@ -15,36 +13,4 @@ data class Type(
@field:SerializedName("url")
val url: String? = null
) : Parcelable {

// TODO Deep inside our POJOS lies the business rules of our project... Not easily manageable : would you find it without the TODO ?
// (the "business rule" here is how to map the name of the type to the corresponding color)

// TODO Meaningful testing : testing this function is easy (no its not) but it doesn't really check that we use this color in every screen

// TODO Testing against Android : testing this function is impossible because it uses the Android Color class
// (and unit tests don't know anything about Android)

// TODO Decoupling : this is about data parsing wtf why do I use the Android Color class ?
@ColorInt
fun getColorInt(): Int = when (name) {
"normal" -> Color.parseColor("#A8A77A")
"fire" -> Color.parseColor("#EE8130")
"water" -> Color.parseColor("#6390F0")
"electric" -> Color.parseColor("#F7D02C")
"grass" -> Color.parseColor("#7AC74C")
"ice" -> Color.parseColor("#96D9D6")
"fighting" -> Color.parseColor("#C22E28")
"poison" -> Color.parseColor("#A33EA1")
"ground" -> Color.parseColor("#E2BF65")
"flying" -> Color.parseColor("#A98FF3")
"psychic" -> Color.parseColor("#F95587")
"bug" -> Color.parseColor("#A6B91A")
"rock" -> Color.parseColor("#B6A136")
"ghost" -> Color.parseColor("#735797")
"dragon" -> Color.parseColor("#6F35FC")
"dark" -> Color.parseColor("#705746")
"steel" -> Color.parseColor("#B7B7CE")
"fairy" -> Color.parseColor("#D685AD")
else -> Color.parseColor("#777777")
}
}
38 changes: 38 additions & 0 deletions app/src/main/java/fr/delcey/mvctomvvm/ui/PokemonUtils.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package fr.delcey.mvctomvvm.ui

import androidx.annotation.ColorRes
import fr.delcey.mvctomvvm.R
import fr.delcey.mvctomvvm.data.pokemon.PokemonResponse
import fr.delcey.mvctomvvm.data.pokemon.TypesItem
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class PokemonUtils @Inject constructor() {
fun getType(types: List<TypesItem?>?, typeNumber: Int): String? = types?.firstOrNull {
it?.slot == typeNumber
}?.type?.name

@ColorRes
fun getTypeColorRes(type: String?): Int? = when (type) {
"normal" -> R.color.type_normal
"fire" -> R.color.type_fire
"water" -> R.color.type_water
"electric" -> R.color.type_electric
"grass" -> R.color.type_grass
"ice" -> R.color.type_ice
"fighting" -> R.color.type_fighting
"poison" -> R.color.type_poison
"ground" -> R.color.type_ground
"flying" -> R.color.type_flying
"psychic" -> R.color.type_psychic
"bug" -> R.color.type_bug
"rock" -> R.color.type_rock
"ghost" -> R.color.type_ghost
"dragon" -> R.color.type_dragon
"dark" -> R.color.type_dark
"steel" -> R.color.type_steel
"fairy" -> R.color.type_fairy
else -> null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,73 +6,61 @@ import android.content.res.ColorStateList
import android.os.Bundle
import android.widget.ImageView
import android.widget.TextView
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import com.bumptech.glide.Glide
import com.google.android.material.chip.Chip
import dagger.hilt.android.AndroidEntryPoint
import fr.delcey.mvctomvvm.R
import fr.delcey.mvctomvvm.data.pokemon.PokemonResponse

@AndroidEntryPoint
class PokemonDetailActivity : AppCompatActivity() {

companion object {
private const val EXTRA_POKEMON_RESPONSE = "EXTRA_POKEMON_RESPONSE"
private const val EXTRA_POKEMON_ID = "EXTRA_POKEMON_ID"

// TODO What if the PokemonResponse is too heavy (larger than 1Mo / 500 Ko, the maximum for a Bundle / Intent) ?
// Spoiler alert : it is too heavy. It would crash the app if you try to put 5 PokemonResponses on a Bundle
// Try it on the Repository, change 1..3 to 1..5 and click on a Pokemon in the list !
fun navigate(context: Context, pokemonResponse: PokemonResponse) = Intent(context, PokemonDetailActivity::class.java).apply {
putExtra(EXTRA_POKEMON_RESPONSE, pokemonResponse)
fun navigate(context: Context, pokemonId: String) = Intent(context, PokemonDetailActivity::class.java).apply {
putExtra(EXTRA_POKEMON_ID, pokemonId)
}
}

private val viewModel by viewModels<PokemonDetailViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

setContentView(R.layout.detail_activity)

// TODO What if the PokemonResponse is dynamic (data can be updated because of a repository change) ?

// TODO What if the PokemonResponse is mutable (data can be updated because of a user interaction on this screen),
// how to reflect this change on the rest of the application ?
// (yes, setResult() can be used but that's troublesome and what we call "Spaghetti code !")
val pokemonResponse = intent.getParcelableExtra<PokemonResponse>(EXTRA_POKEMON_RESPONSE) ?: return
val pokemonId = intent.getStringExtra(EXTRA_POKEMON_ID) ?: return

val pokemonImageView = findViewById<ImageView>(R.id.pokemon_detail_iv)
val pokemonNumberTextView = findViewById<TextView>(R.id.pokemon_detail_tv_number)
val pokemonNameTextView = findViewById<TextView>(R.id.pokemon_detail_tv_name)
val pokemonType1Chip = findViewById<Chip>(R.id.pokemon_detail_chip_type1)
val pokemonType2Chip = findViewById<Chip>(R.id.pokemon_detail_chip_type2)

Glide.with(pokemonImageView)
.load(pokemonResponse.sprites?.frontDefault)
.fitCenter()
.into(pokemonImageView)
viewModel.getPokemonDetailViewStateLiveData().observe(this) { pokemonDetailViewState ->
Glide.with(pokemonImageView)
.load(pokemonDetailViewState.imageUrl)
.fitCenter()
.into(pokemonImageView)

pokemonNumberTextView.text = pokemonResponse.getFormattedNumber()
pokemonNameTextView.text = pokemonResponse.getCapitalizedName()
pokemonNumberTextView.text = pokemonDetailViewState.number
pokemonNameTextView.text = pokemonDetailViewState.name

// TODO Intelligence on the Activity : can't be tested
val firstType = pokemonResponse.types?.firstOrNull {
it?.slot == 1
}?.type
pokemonType1Chip.isVisible = firstType != null
if (firstType != null) {
pokemonType1Chip.text = firstType.name
pokemonType1Chip.chipBackgroundColor = ColorStateList.valueOf(firstType.getColorInt())
}
pokemonType1Chip.text = pokemonDetailViewState.type1Name
pokemonType1Chip.chipBackgroundColor = pokemonDetailViewState.type1Color?.let {
ColorStateList.valueOf(ContextCompat.getColor(this, it))
}

// TODO Should be refactored but it doesn't makes much sense, the refactored function would be, for example :
// updatePokemonChip(pokemonResponse, 2, pokemonType2Chip)
val secondType = pokemonResponse.types?.firstOrNull {
it?.slot == 2
}?.type
pokemonType2Chip.isVisible = secondType != null
if (secondType != null) {
pokemonType2Chip.text = secondType.name
pokemonType2Chip.chipBackgroundColor = ColorStateList.valueOf(secondType.getColorInt())
pokemonType2Chip.isVisible = pokemonDetailViewState.isType2Visible
pokemonType2Chip.text = pokemonDetailViewState.type2Name
pokemonType2Chip.chipBackgroundColor = pokemonDetailViewState.type2Color?.let {
ColorStateList.valueOf(ContextCompat.getColor(this, it))
}
}

viewModel.setPokemonId(pokemonId)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package fr.delcey.mvctomvvm.ui.detail

import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import fr.delcey.mvctomvvm.data.PokemonRepository
import fr.delcey.mvctomvvm.ui.PokemonUtils
import javax.inject.Inject

@HiltViewModel
class PokemonDetailViewModel @Inject constructor(
pokemonRepository: PokemonRepository,
pokemonUtils: PokemonUtils
) : ViewModel() {

private val pokemonIdMutableLiveData = MutableLiveData<String>()
private val pokemonResponseLiveData = Transformations.switchMap(pokemonIdMutableLiveData) { id ->
pokemonRepository.getPokemonByIdLiveData(id)
}

private val pokemonDetailViewStateMediatorLiveData = MediatorLiveData<PokemonDetailViewState>()

init {
pokemonDetailViewStateMediatorLiveData.addSource(pokemonResponseLiveData) { pokemonResponse ->
val number = pokemonResponse.id?.toString() ?: return@addSource

val type1: String = pokemonUtils.getType(pokemonResponse.types, 1)?: return@addSource
val type2: String? = pokemonUtils.getType(pokemonResponse.types, 2)
val type2Color: Int? = pokemonUtils.getTypeColorRes(type2)

pokemonDetailViewStateMediatorLiveData.value = PokemonDetailViewState(
number = "#$number",
name = pokemonResponse.name?.capitalize() ?: return@addSource,
imageUrl = pokemonResponse.sprites?.frontDefault ?: return@addSource,
type1Name = type1,
type1Color = pokemonUtils.getTypeColorRes(type1) ?: return@addSource,
type2Name = type2,
type2Color = type2Color,
isType2Visible = type2 != null && type2Color != null
)
}
}

fun getPokemonDetailViewStateLiveData(): LiveData<PokemonDetailViewState> = pokemonDetailViewStateMediatorLiveData

fun setPokemonId(pokemonId: String) {
pokemonIdMutableLiveData.value = pokemonId
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package fr.delcey.mvctomvvm.ui.detail

import androidx.annotation.ColorRes

data class PokemonDetailViewState(
val number: String,
val name: String,
val imageUrl: String,
val type1Name: String,
@ColorRes
val type1Color: Int,
val type2Name: String?,
@ColorRes
val type2Color: Int?,
val isType2Visible: Boolean
)
Loading

0 comments on commit 39c56a7

Please sign in to comment.