REDI-92: Views/XML renderer for the characters list (same MVI ViewModel)
Add :feature:characters:presentation-views — a classic Fragment + ViewBinding + RecyclerView/DiffUtil renderer driving the SAME CharacterListViewModel as the Compose screen (obtained via Koin's by viewModel()), proving the presentation logic is truly UI-agnostic. State is observed with viewLifecycleOwner.repeatOnLifecycle(STARTED), one-time Events are collected, UiText is resolved via Context, and the binding is nulled in onDestroyView. Coil loads avatars into ImageView with a circle-crop transform; the module has no Compose dependency. Paging scroll listener guards the empty-list case (lastVisible >= 0), uses a safe layoutManager cast, and is removed in onDestroyView.
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
package com.example.architecture.feature.characters.presentation.views
|
||||
|
||||
import android.content.res.ColorStateList
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import coil3.load
|
||||
import coil3.request.crossfade
|
||||
import coil3.request.transformations
|
||||
import coil3.transform.CircleCropTransformation
|
||||
import com.example.architecture.feature.characters.presentation.model.CharacterUi
|
||||
import com.example.architecture.feature.characters.presentation.views.databinding.ItemCharacterBinding
|
||||
|
||||
/**
|
||||
* RecyclerView adapter over the SAME [CharacterUi] presentation model the Compose renderer uses.
|
||||
* [DiffUtil] computes minimal updates; Coil loads avatars straight into the `ImageView`.
|
||||
*/
|
||||
internal class CharacterListAdapter(
|
||||
private val onItemClick: (Int) -> Unit,
|
||||
) : ListAdapter<CharacterUi, CharacterListAdapter.CharacterViewHolder>(DIFF_CALLBACK) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CharacterViewHolder {
|
||||
val binding = ItemCharacterBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return CharacterViewHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: CharacterViewHolder, position: Int) {
|
||||
holder.bind(getItem(position))
|
||||
}
|
||||
|
||||
inner class CharacterViewHolder(
|
||||
private val binding: ItemCharacterBinding,
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
init {
|
||||
binding.root.setOnClickListener {
|
||||
val position = bindingAdapterPosition
|
||||
if (position != RecyclerView.NO_POSITION) {
|
||||
onItemClick(getItem(position).id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun bind(item: CharacterUi) {
|
||||
val context = binding.root.context
|
||||
binding.name.text = item.name
|
||||
binding.statusSpecies.text =
|
||||
context.getString(item.status.labelRes()) + " · " + item.species
|
||||
ViewCompat.setBackgroundTintList(
|
||||
binding.statusDot,
|
||||
ColorStateList.valueOf(item.status.indicatorColor()),
|
||||
)
|
||||
binding.avatar.contentDescription =
|
||||
context.getString(R.string.cd_character_avatar, item.name)
|
||||
binding.avatar.load(item.imageUrl) {
|
||||
crossfade(true)
|
||||
transformations(CircleCropTransformation())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val DIFF_CALLBACK = object : DiffUtil.ItemCallback<CharacterUi>() {
|
||||
override fun areItemsTheSame(oldItem: CharacterUi, newItem: CharacterUi): Boolean =
|
||||
oldItem.id == newItem.id
|
||||
|
||||
override fun areContentsTheSame(oldItem: CharacterUi, newItem: CharacterUi): Boolean =
|
||||
oldItem == newItem
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.example.architecture.feature.characters.presentation.views
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.example.architecture.core.presentation.asString
|
||||
import com.example.architecture.feature.characters.presentation.CharacterListAction
|
||||
import com.example.architecture.feature.characters.presentation.CharacterListEvent
|
||||
import com.example.architecture.feature.characters.presentation.CharacterListState
|
||||
import com.example.architecture.feature.characters.presentation.CharacterListViewModel
|
||||
import com.example.architecture.feature.characters.presentation.views.databinding.FragmentCharacterListBinding
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
|
||||
/**
|
||||
* Classic Views renderer for the characters list. It drives the **same** [CharacterListViewModel] as
|
||||
* the Compose screen — proving the presentation logic (State/Action/Event/UI-model) is truly
|
||||
* UI-agnostic. Koin's `by viewModel()` supplies the VM (and its `SavedStateHandle`).
|
||||
*
|
||||
* `:app` (the interop owner) wires [onCharacterClick] / [onNavigateBack]; the Fragment never touches
|
||||
* the Compose NavController, so this module stays decoupled from navigation.
|
||||
*/
|
||||
class CharacterListFragment : Fragment() {
|
||||
|
||||
var onCharacterClick: (Int) -> Unit = {}
|
||||
var onNavigateBack: () -> Unit = {}
|
||||
|
||||
private var _binding: FragmentCharacterListBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private val viewModel: CharacterListViewModel by viewModel()
|
||||
|
||||
private lateinit var listAdapter: CharacterListAdapter
|
||||
private var pagingListener: RecyclerView.OnScrollListener? = null
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?,
|
||||
): View {
|
||||
_binding = FragmentCharacterListBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
listAdapter = CharacterListAdapter(
|
||||
onItemClick = { id -> viewModel.onAction(CharacterListAction.OnCharacterClick(id)) },
|
||||
)
|
||||
val scrollListener = pagingScrollListener()
|
||||
pagingListener = scrollListener
|
||||
binding.recyclerView.apply {
|
||||
layoutManager = LinearLayoutManager(requireContext())
|
||||
adapter = listAdapter
|
||||
addOnScrollListener(scrollListener)
|
||||
}
|
||||
binding.toolbar.setNavigationOnClickListener { onNavigateBack() }
|
||||
binding.retryButton.setOnClickListener {
|
||||
viewModel.onAction(CharacterListAction.OnRetry)
|
||||
}
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
launch { viewModel.state.collect(::render) }
|
||||
launch { viewModel.events.collect(::handleEvent) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun render(state: CharacterListState) {
|
||||
listAdapter.submitList(state.characters)
|
||||
|
||||
val showFullScreenError = state.error != null && state.characters.isEmpty()
|
||||
binding.progressBar.isVisible = state.isLoading
|
||||
binding.nextPageProgress.isVisible = state.isLoadingNextPage
|
||||
binding.errorContainer.isVisible = showFullScreenError
|
||||
binding.recyclerView.isVisible = !state.isLoading && !showFullScreenError
|
||||
|
||||
if (showFullScreenError) {
|
||||
binding.errorMessage.text = state.error?.asString(requireContext())
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleEvent(event: CharacterListEvent) {
|
||||
when (event) {
|
||||
is CharacterListEvent.NavigateToDetail -> onCharacterClick(event.characterId)
|
||||
is CharacterListEvent.ShowSnackbar -> Snackbar.make(
|
||||
binding.root,
|
||||
event.message.asString(requireContext()),
|
||||
Snackbar.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun pagingScrollListener() = object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||
val layoutManager = recyclerView.layoutManager as? LinearLayoutManager ?: return
|
||||
val lastVisible = layoutManager.findLastVisibleItemPosition()
|
||||
// `lastVisible >= 0` skips the empty-list case (findLastVisibleItemPosition() == -1),
|
||||
// mirroring the Compose renderer's `total > 0` guard. The ViewModel still guards against
|
||||
// duplicate / end-reached / already-loading requests.
|
||||
if (lastVisible >= 0 && lastVisible >= layoutManager.itemCount - 1) {
|
||||
viewModel.onAction(CharacterListAction.OnLoadNextPage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
// Remove the scroll listener and detach the adapter before nulling the binding so neither
|
||||
// the RecyclerView nor this Fragment is leaked.
|
||||
pagingListener?.let { binding.recyclerView.removeOnScrollListener(it) }
|
||||
pagingListener = null
|
||||
binding.recyclerView.adapter = null
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.example.architecture.feature.characters.presentation.views
|
||||
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.StringRes
|
||||
import com.example.architecture.feature.characters.domain.model.CharacterStatus
|
||||
|
||||
/**
|
||||
* Views-renderer presentation helpers for [CharacterStatus]. These intentionally mirror the Compose
|
||||
* renderer's helpers but return platform types (a string-res id and an ARGB Int) — each renderer
|
||||
* owns its own resources, so the small label duplication across modules is expected.
|
||||
*/
|
||||
@StringRes
|
||||
internal fun CharacterStatus.labelRes(): Int = when (this) {
|
||||
CharacterStatus.ALIVE -> R.string.characters_views_status_alive
|
||||
CharacterStatus.DEAD -> R.string.characters_views_status_dead
|
||||
CharacterStatus.UNKNOWN -> R.string.characters_views_status_unknown
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
internal fun CharacterStatus.indicatorColor(): Int = when (this) {
|
||||
CharacterStatus.ALIVE -> 0xFF4CAF50.toInt()
|
||||
CharacterStatus.DEAD -> 0xFFE53935.toInt()
|
||||
CharacterStatus.UNKNOWN -> 0xFF9E9E9E.toInt()
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Solid white oval, tinted per status at bind time via setBackgroundTintList. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="@android:color/white" />
|
||||
</shape>
|
||||
@@ -0,0 +1,11 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorOnSurface"
|
||||
android:autoMirrored="true">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,71 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="?attr/colorSurface"
|
||||
android:orientation="vertical">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
app:navigationContentDescription="@string/cd_back"
|
||||
app:navigationIcon="@drawable/ic_arrow_back"
|
||||
app:title="@string/characters_views_title" />
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recyclerView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="12dp"
|
||||
android:scrollbars="vertical" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:visibility="gone" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/errorContainer"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:padding="24dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/errorMessage"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAlignment="center"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/retryButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/characters_views_retry" />
|
||||
</LinearLayout>
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/nextPageProgress"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|center_horizontal"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="4dp"
|
||||
android:paddingVertical="12dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/avatar"
|
||||
android:layout_width="64dp"
|
||||
android:layout_height="64dp"
|
||||
android:scaleType="centerCrop" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/name"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<View
|
||||
android:id="@+id/statusDot"
|
||||
android:layout_width="8dp"
|
||||
android:layout_height="8dp"
|
||||
android:background="@drawable/bg_status_dot" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/statusSpecies"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="6dp"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,9 @@
|
||||
<resources>
|
||||
<string name="characters_views_title">Characters (Views)</string>
|
||||
<string name="characters_views_retry">Retry</string>
|
||||
<string name="cd_back">Back</string>
|
||||
<string name="cd_character_avatar">Avatar of %1$s</string>
|
||||
<string name="characters_views_status_alive">Alive</string>
|
||||
<string name="characters_views_status_dead">Dead</string>
|
||||
<string name="characters_views_status_unknown">Unknown</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user