Wishlist Screen
Where the heart appears
The heart is on every product card (Home rows, product grids, search, similar products) and in the toolbar of the detail screen. The API sends in_wishlist with every product, so the heart is filled correctly the moment a list appears.
One toggle, three places
Every screen calls the same repo.toggleWishlist(id), which hits wishlist/toggle.php and returns the new in_wishlist. Each ViewModel then updates only its own data:
// 1) Home / product list – flip the flag in the list we already have
fun toggleWishlist(productId: Int) {
viewModelScope.launch {
when (val r = repo.toggleWishlist(productId)) {
is Result.Success -> {
_state.value = _state.value.copy(items = _state.value.items.withWishlist(productId, r.data.inWishlist))
_messages.emit(r.message) // "Added to wishlist."
}
is Result.Error -> _messages.emit(r.message)
}
}
}
// 2) Product detail – update the detail object and the toolbar heart
_state.value = UiState.Success(cur.copy(inWishlist = r.data.inWishlist))
// 3) Wishlist screen – a heart tap always means "remove" (see WishlistFragment.kt)package com.codingeasily.karteasy.ui.product
import com.codingeasily.karteasy.data.model.Product
/** After a heart tap, return the same list with that product's in_wishlist flipped. */
fun List<Product>.withWishlist(productId: Int, inWishlist: Boolean): List<Product> =
map { if (it.id == productId) it.copy(inWishlist = inWishlist) else it }Because ProductAdapter is a ListAdapter, giving it a list where one product's inWishlist changed redraws just that one card.
The wishlist screen
Step 1: layout and fragment
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:padding="8dp"
tools:listitem="@layout/item_product" />
<include
android:id="@+id/state"
layout="@layout/layout_state" />
</FrameLayout>package com.codingeasily.karteasy.ui.wishlist
import android.app.Application
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.GridLayoutManager
import com.codingeasily.karteasy.R
import com.codingeasily.karteasy.data.api.Result
import com.codingeasily.karteasy.data.api.UiState
import com.codingeasily.karteasy.data.model.Product
import com.codingeasily.karteasy.databinding.FragmentWishlistBinding
import com.codingeasily.karteasy.ui.product.ProductAdapter
import com.codingeasily.karteasy.ui.product.ProductDetailFragment
import com.codingeasily.karteasy.util.collectWhenStarted
import com.codingeasily.karteasy.util.done
import com.codingeasily.karteasy.util.empty
import com.codingeasily.karteasy.util.error
import com.codingeasily.karteasy.util.kartApp
import com.codingeasily.karteasy.util.loading
import com.codingeasily.karteasy.util.snack
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class WishlistViewModel(app: Application) : AndroidViewModel(app) {
private val repo = app.kartApp.repository
private val _state = MutableStateFlow<UiState<List<Product>>>(UiState.Loading)
val state: StateFlow<UiState<List<Product>>> = _state
private val _messages = MutableSharedFlow<String>()
val messages: SharedFlow<String> = _messages
fun load() {
viewModelScope.launch {
if (_state.value !is UiState.Success) _state.value = UiState.Loading
_state.value = when (val r = repo.wishlist()) {
is Result.Success -> UiState.Success(r.data)
is Result.Error -> UiState.Error(r.message)
}
}
}
/** In the wishlist a heart tap always means "remove". */
fun remove(p: Product) {
val cur = (_state.value as? UiState.Success)?.data ?: return
_state.value = UiState.Success(cur.filter { it.id != p.id }) // optimistic: hide at once
viewModelScope.launch {
val r = repo.toggleWishlist(p.id)
if (r is Result.Error) { _messages.emit(r.message); load() } else _messages.emit((r as Result.Success).message)
}
}
fun moveToCart(p: Product) {
viewModelScope.launch {
when (val r = repo.addToCart(p.id)) {
is Result.Success -> { repo.toggleWishlist(p.id); _messages.emit("Moved to cart."); load() }
is Result.Error -> _messages.emit(r.message)
}
}
}
}
class WishlistFragment : Fragment() {
private var _binding: FragmentWishlistBinding? = null
private val binding get() = _binding!!
private val vm: WishlistViewModel by viewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentWishlistBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val adapter = ProductAdapter(
onClick = { findNavController().navigate(R.id.productDetailFragment, bundleOf(ProductDetailFragment.ARG_PRODUCT_ID to it.id)) },
onWishlist = { vm.remove(it) },
onAddToCart = { vm.moveToCart(it) }
)
binding.list.layoutManager = GridLayoutManager(requireContext(), resources.getInteger(R.integer.product_columns))
binding.list.adapter = adapter
collectWhenStarted(vm.state) { state ->
when (state) {
is UiState.Loading -> binding.state.loading()
is UiState.Error -> binding.state.error(state.message) { vm.load() }
is UiState.Success -> {
adapter.submitList(state.data)
if (state.data.isEmpty()) binding.state.empty(R.drawable.ic_heart_outline, R.string.wishlist_empty, R.string.start_shopping) {
findNavController().navigate(R.id.homeFragment)
} else binding.state.done()
}
}
}
collectWhenStarted(vm.messages) { snack(it) }
}
override fun onResume() {
super.onResume()
vm.load()
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}Optimistic updates
When the user removes an item, we hide it immediately and call the API in the background. That feels instant. If the call fails, we show the error and reload, so the item comes back.
Wait for the server
- tap ♥
- spinner 300–800 ms
- item disappears
Optimistic (KartEasy wishlist)
- tap ♥
- item disappears at once
- API runs in background; on error → reload
Move to cart and save for later
From the wishlist, + adds the product to the cart and removes it from the wishlist. From the cart, Save for later does the opposite. The second toggle handles the case where the product was already in the wishlist.
// CartViewModel.kt – "Save for later" moves an item from the cart to the wishlist
fun moveToWishlist(item: CartItem) {
viewModelScope.launch {
val r = repo.toggleWishlist(item.productId)
if (r is Result.Success && !r.data.inWishlist) repo.toggleWishlist(item.productId) // was already there
update { repo.removeFromCart(item.productId) }
_messages.emit("Moved to wishlist.")
}
}Test it
- 1Tap ♡ on Home → Snackbar "Added to wishlist." and the heart fills
- 2Open Wishlist tab → the product is there
- 3Open the same product → the toolbar heart is filled
- 4In Wishlist tap + → "Moved to cart.", the cart badge increases, the item leaves the wishlist
- 5In Cart tap Save for later → item moves back to Wishlist
- 6Remove the last item → empty state with Start shopping
