From e230aa77d8849c8f117e161a637ccb31db2616de Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Wed, 10 Jun 2026 13:44:53 +0200 Subject: [PATCH] REDI-92: Views/XML renderer for the characters list (same MVI ViewModel) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../views/CharacterListAdapter.kt | 74 ++++++++++ .../views/CharacterListFragment.kt | 127 ++++++++++++++++++ .../views/CharacterStatusViews.kt | 24 ++++ .../src/main/res/drawable/bg_status_dot.xml | 6 + .../src/main/res/drawable/ic_arrow_back.xml | 11 ++ .../res/layout/fragment_character_list.xml | 71 ++++++++++ .../src/main/res/layout/item_character.xml | 54 ++++++++ .../src/main/res/values/strings.xml | 9 ++ 8 files changed, 376 insertions(+) create mode 100644 feature/characters/presentation-views/src/main/kotlin/com/example/architecture/feature/characters/presentation/views/CharacterListAdapter.kt create mode 100644 feature/characters/presentation-views/src/main/kotlin/com/example/architecture/feature/characters/presentation/views/CharacterListFragment.kt create mode 100644 feature/characters/presentation-views/src/main/kotlin/com/example/architecture/feature/characters/presentation/views/CharacterStatusViews.kt create mode 100644 feature/characters/presentation-views/src/main/res/drawable/bg_status_dot.xml create mode 100644 feature/characters/presentation-views/src/main/res/drawable/ic_arrow_back.xml create mode 100644 feature/characters/presentation-views/src/main/res/layout/fragment_character_list.xml create mode 100644 feature/characters/presentation-views/src/main/res/layout/item_character.xml create mode 100644 feature/characters/presentation-views/src/main/res/values/strings.xml diff --git a/feature/characters/presentation-views/src/main/kotlin/com/example/architecture/feature/characters/presentation/views/CharacterListAdapter.kt b/feature/characters/presentation-views/src/main/kotlin/com/example/architecture/feature/characters/presentation/views/CharacterListAdapter.kt new file mode 100644 index 0000000..6e116e9 --- /dev/null +++ b/feature/characters/presentation-views/src/main/kotlin/com/example/architecture/feature/characters/presentation/views/CharacterListAdapter.kt @@ -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(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() { + override fun areItemsTheSame(oldItem: CharacterUi, newItem: CharacterUi): Boolean = + oldItem.id == newItem.id + + override fun areContentsTheSame(oldItem: CharacterUi, newItem: CharacterUi): Boolean = + oldItem == newItem + } + } +} diff --git a/feature/characters/presentation-views/src/main/kotlin/com/example/architecture/feature/characters/presentation/views/CharacterListFragment.kt b/feature/characters/presentation-views/src/main/kotlin/com/example/architecture/feature/characters/presentation/views/CharacterListFragment.kt new file mode 100644 index 0000000..a7543a0 --- /dev/null +++ b/feature/characters/presentation-views/src/main/kotlin/com/example/architecture/feature/characters/presentation/views/CharacterListFragment.kt @@ -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 + } +} diff --git a/feature/characters/presentation-views/src/main/kotlin/com/example/architecture/feature/characters/presentation/views/CharacterStatusViews.kt b/feature/characters/presentation-views/src/main/kotlin/com/example/architecture/feature/characters/presentation/views/CharacterStatusViews.kt new file mode 100644 index 0000000..23d7f80 --- /dev/null +++ b/feature/characters/presentation-views/src/main/kotlin/com/example/architecture/feature/characters/presentation/views/CharacterStatusViews.kt @@ -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() +} diff --git a/feature/characters/presentation-views/src/main/res/drawable/bg_status_dot.xml b/feature/characters/presentation-views/src/main/res/drawable/bg_status_dot.xml new file mode 100644 index 0000000..8eb9180 --- /dev/null +++ b/feature/characters/presentation-views/src/main/res/drawable/bg_status_dot.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/feature/characters/presentation-views/src/main/res/drawable/ic_arrow_back.xml b/feature/characters/presentation-views/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 0000000..9cac029 --- /dev/null +++ b/feature/characters/presentation-views/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,11 @@ + + + diff --git a/feature/characters/presentation-views/src/main/res/layout/fragment_character_list.xml b/feature/characters/presentation-views/src/main/res/layout/fragment_character_list.xml new file mode 100644 index 0000000..57f148f --- /dev/null +++ b/feature/characters/presentation-views/src/main/res/layout/fragment_character_list.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + +