RizTech Academy logo
RizTech Academy
Lists and NavigationLesson 4 of 530 min

Passing data, and what not to pass

The detail screen's key holds one string, the price's id, and the screen fetches everything else itself. That can look like a detour: the prices screen already had the whole CommodityPrice in its hand when the farmer tapped it. Why not pass that? This lesson tries it, reads the compiler's objection, makes it work anyway, and measures what it costs. Then it covers the case passing an id creates, an id that no longer points at anything, and puts the rules under test.

Where the data comes from

The rule Mandi Bhav follows: a navigation key says which thing to show; the screen gets the thing from the source of the data. The detail screen's ViewModel is handed priceId and asks PriceSource for the price and its week. The prices screen's ViewModel asks the same source for today's list. Neither screen hands data to the other.

PriceSource is deliberately plain: an in-memory class over the sample data, created where it is needed. Module 7's "Repositories, and a single source of truth" replaces it with a repository that every screen shares, backed in later modules by a database and the network. The screens will not notice, because they already ask the right question, by id. That is most of the reason to work this way now.

The trap: pass the whole price

Change the key to carry the price itself, in a scratch copy:

/** One price in detail: the trap, the whole object. */
@Serializable
data class PriceDetail(val price: CommodityPrice) : NavKey

The build stops before it starts. A key must be @Serializable, so everything inside it must be too; mark CommodityPrice @Serializable and the compiler reaches its date:

e: CommodityPrice.kt:19:15 Serializer was not found for type 'LocalDate'. To use a context serializer as the fallback, explicitly annotate type or property with '@Contextual'.

LocalDate is a Java class, and kotlinx.serialization does not know how to write one. That error is the first warning. A determined developer writes a serializer for it, storing the date as text:

object LocalDateAsText : KSerializer<LocalDate> {
    override val descriptor = PrimitiveSerialDescriptor("LocalDate", PrimitiveKind.STRING)
    override fun serialize(encoder: Encoder, value: LocalDate) = encoder.encodeString(value.toString())
    override fun deserialize(decoder: Decoder): LocalDate = LocalDate.parse(decoder.decodeString())
}

annotates date with @Serializable(with = LocalDateAsText::class), does the same for DayPrice, and, since the detail screen needs the week as well, passes that too:

@Serializable
data class PriceDetail(val price: CommodityPrice, val week: List<DayPrice>) : NavKey

The prices screen builds the key from the source, the detail entry calls PriceDetailScreen(detail = priceWeek(key.price, key.week), …) directly, and the ViewModel is no longer needed. It compiles, it runs, and the detail screen looks identical. Here is what changed underneath.

It made the saved state bigger

Module 5 measured the saved-state Bundle hitting TransactionTooLargeException and quoted Android's advice to keep it under 50 KB. A navigation back stack is saved in the same Bundle. To measure it, the scratch copy logs its size when the activity saves:

override fun onSaveInstanceState(outState: Bundle) {
    super.onSaveInstanceState(outState)
    val parcel = android.os.Parcel.obtain()
    outState.writeToParcel(parcel, 0)
    android.util.Log.d("Nav", "saved state: ${parcel.dataSize()} bytes")
    parcel.recycle()
}

in MainActivity. Pressing Home writes the Bundle, so the log shows its size each time. With the id key and with the whole-object key, after the same steps:

Back stack when Home was pressed Id in the key Price and week in the key
Prices list only 3,680 bytes 3,680 bytes
+ Lasalgaon onion's detail screen 4,712 bytes 6,720 bytes
+ the Markets tab open on Nashik APMC and its onion's detail (lesson 5) 7,380 bytes 11,388 bytes

Each detail screen with the id costs about 1 KB of saved state, most of it the framework's own bookkeeping for the entry. With the price and its week it costs about 3 KB, and that grows with what you pass, not with what the screen is for. Nothing here is near 50 KB. That is exactly how this ships: the test data is small, the app works, and the day someone passes a month of history, or a list of prices to a comparison screen, the saved state is suddenly hundreds of KB and the crash from module 5 arrives on Home, not on the screen that caused it.

It showed old numbers as if they were new

Size is the problem you can measure today. The worse one is staleness. Kavita opens Lasalgaon onion at 10 am: ₹2,450. She takes a call, the app goes to the background, and Android kills it, as module 5 showed it does on an ordinary afternoon. At 4 pm the market posts its closing price, ₹2,525. She comes back to Mandi Bhav:

  • With the whole price in the key, the restored back stack contains the 10 am CommodityPrice, and the screen shows ₹2,450 with nothing to say it is six hours old. The data was frozen into the navigation state when she tapped.
  • With the id in the key, the restored back stack contains "lasalgaon-onion-2026-11-07", a new ViewModel asks the source, and the screen shows whatever the source knows now.

The sample data never changes, so you cannot watch this happen in the app until the networking module brings real prices. You can prove the id half of it today, with a test that hands the ViewModel a source whose price has been corrected:

@Test
fun readsTodaysValueFromTheSourceNotFromWhoeverOpenedIt() {
    // The 4 pm update: the source now has a corrected price under the
    // same id. A screen that was handed the id shows the new value.
    val corrected = SamplePrices.lasalgaonOnion.copy(modalPrice = 2_525)
    val source = PriceSource(prices = listOf(corrected))

    val viewModel = PriceDetailViewModel(corrected.id, source)

    assertEquals(2_525L, viewModel.detail!!.price.modalPrice)
}

This is also why PriceSource is a constructor parameter with a default: the app uses the default, and a test passes the data the case needs.

The rule, as a table

Put in a navigation key Do not
The id of the thing to show The thing itself
A market name, a commodity, a date A list of prices, a history
A small choice the screen needs to start ("open on the Alerts tab") Anything the screen can load or work out
Strings, numbers, enums, small @Serializable classes of those Lambdas, ViewModels, Context, a NavBackStack

It is module 2's rule about intent extras and module 5's rule about saved state, applied a third time: save what identifies the user's place; reload what the data source knows.

The id that points at nothing

Passing an id creates a case that passing the object did not: by the time the screen asks, the id may name nothing. A back stack restored after the app was killed yesterday, a price withdrawn by the market, a deep link from an old notification in the notifications module. The source returns null:

/** One price by its id, or null if there is no such price (any more). */
fun price(id: String): CommodityPrice? = prices.firstOrNull { it.id == id }

the ViewModel passes the null on as detail, and the screen says so instead of crashing:

if (detail == null) {
    // The id came from a saved back stack, and the price it named is
    // gone. Say so, rather than crash or show an empty screen.
    Text(
        text = "This price is no longer available.",
        style = MaterialTheme.typography.bodyLarge,
        modifier = Modifier
            .padding(innerPadding)
            .padding(16.dp),
    )
    return@Scaffold
}

The app bar still has its up arrow, so the farmer is not stranded. The second preview in PriceDetailScreen.kt, "Price no longer available", shows this state, which is the only way you will see it until real data can disappear. The tempting alternative, source.price(id)!!, crashes the whole app on a screen the user had not even looked at yet.

Testing the data side

Two test files cover what this lesson relies on. app/src/test/java/com/riztechacademy/mandibhav/ui/PriceDetailViewModelTest.kt:

package com.riztechacademy.mandibhav.ui

import com.riztechacademy.mandibhav.data.PriceSource
import com.riztechacademy.mandibhav.data.SamplePrices
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test

class PriceDetailViewModelTest {

    @Test
    fun loadsThePriceAndItsWeekFromTheId() {
        val id = SamplePrices.vashiWheat.id
        val viewModel = PriceDetailViewModel(id)

        val detail = viewModel.detail!!
        assertEquals(SamplePrices.vashiWheat, detail.price)
        assertEquals(7, detail.week.size)
        assertEquals(0L, detail.changeSinceYesterday)
    }

    @Test
    fun readsTodaysValueFromTheSourceNotFromWhoeverOpenedIt() {
        // The 4 pm update: the source now has a corrected price under the
        // same id. A screen that was handed the id shows the new value.
        val corrected = SamplePrices.lasalgaonOnion.copy(modalPrice = 2_525)
        val source = PriceSource(prices = listOf(corrected))

        val viewModel = PriceDetailViewModel(corrected.id, source)

        assertEquals(2_525L, viewModel.detail!!.price.modalPrice)
    }

    @Test
    fun anIdThatNoLongerExistsGivesNoDetail() {
        // What a back stack restored after process death can hold, once the
        // data has changed underneath it.
        val viewModel = PriceDetailViewModel("lasalgaon-onion-2026-10-01")
        assertNull(viewModel.detail)
    }
}

No MainDispatcherRule this time: this ViewModel launches no coroutines. And app/src/test/java/com/riztechacademy/mandibhav/data/PriceSourceTest.kt checks the source itself: a price found by id, an unknown id giving null and an empty week, the week being seven days oldest first ending on the price's own date and modal price, every sample price having such a week, and pricesAt returning only that market's prices. The week tests are worth having for a dull reason: the history is typed by hand, and a test that every week ends on the price's own modal price is what catches a typo in sixty-three hand-typed numbers. ./gradlew testDebugUnitTest runs 31 tests.

Check your work

What a navigation key should hold. Enough to identify what to show, and no more: here, an id.

Why the whole-object key did not compile at first. A key must be serialisable, and kotlinx.serialization has no serializer for LocalDate.

What the measurement showed. About 1 KB of saved state per detail screen with an id, about 3 KB with the price and its week, growing with whatever you add.

Why the size did not crash anything here, and why that is not reassuring. Sample data is small; real data grows, and the crash happens at the next Home press.

What Kavita would have seen at 4 pm with the object in the key. The 10 am price, restored from saved state, shown as current.

What the screen does with an id that names nothing. Says the price is no longer available, with the up arrow still there.

Practice 3. It fails with NullPointerException from the !!, in the test runner rather than on a farmer's phone, which is the place you want it. Most of what a test catches is like this: a rule that is obvious until someone else edits the code.

Practice

  1. Add PriceDetailViewModelTest and PriceSourceTest and run all 31 tests.
  2. Reproduce the LocalDate compile error in a scratch copy by putting CommodityPrice in the key. Read the message, and decide what @Contextual would ask you to do.
  3. Break it on purpose: change PriceDetailViewModel to use source.price(priceId)!! and run the tests. Predict which test fails and how.
  4. Add the onSaveInstanceState size log to a scratch copy, open two detail screens, press Home, and read the size. Then put the week in the key and compare.
  5. Harder: the detail screen will one day need "the same commodity at other markets today". Decide what goes in the key, what the ViewModel asks the source for, and add the function to PriceSource with a test. Then decide what the screen should show if the farmer's own market has no price today.

Official documentation

Next: a navigation bar, a rail for wide screens, and a back stack for each tab.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.

About the internship