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