RizTech Academy logo
RizTech Academy
Jetpack Compose FundamentalsLesson 2 of 630 min

Composable functions

A composable is an ordinary Kotlin function with one annotation, and that annotation changes who calls it, when, and how often. This lesson builds the first real piece of Mandi Bhav, a PriceCard that shows one day's price for one commodity at one market, and on the way meets a formatting bug that would put the wrong number in front of every farmer in Maharashtra whose phone is set to Marathi.

What makes a function composable

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Text(text = "Namaste from $name", modifier = modifier)
}

That is the Greeting from your first app. The @Composable annotation tells the Compose compiler plugin (the org.jetbrains.kotlin.plugin.compose line in your build file) to rewrite the function so that Compose can track what it emits and run it again later. Four things follow from it:

Rule Why
A composable can only be called from another composable Compose passes hidden bookkeeping into every call. setContent { } is where the chain starts.
It returns Unit and emits UI Calling Text adds text to the screen being described; it does not hand you an object.
Its name is a noun in PascalCase: PriceCard, not showPriceCard It describes a thing on screen, so it reads like a type. Android Studio's lint flags lower-case names.
It must be fast and have no side effects Compose may run it many times, in any order, and skip it. Lesson 3 is about this.

The fourth rule is the one that bites. A composable that writes to a database, sends an analytics event or sorts ten thousand rows will do so every time it runs, and you do not control how often that is.

First, the data: a price is not a Double

Before drawing a price you need a type for one. A mandi publishes, for each commodity at each market each day, a minimum, a maximum and a modal price (the price most lots actually traded at), in rupees per quintal, where a quintal is 100 kg. Create a package model next to MainActivity.kt and add CommodityPrice.kt:

package com.riztechacademy.mandibhav.model

import java.time.LocalDate

/**
 * One day's price for one commodity at one market.
 *
 * Prices are whole rupees per quintal (100 kg), the unit APMC markets report in.
 * They are Long, never Double: a price is money, and money has to add up exactly.
 */
data class CommodityPrice(
    val commodity: String,
    val market: String,
    val date: LocalDate,
    val minPrice: Long,
    val maxPrice: Long,
    val modalPrice: Long,
)

Why Long and not Double? A Double stores binary fractions, and most decimal amounts have no exact binary form:

println(1.10 + 2.20)   // 3.3000000000000003

One stray digit looks harmless until a trader's app adds up a season of transactions and the total disagrees with the bank by a few paise, or a comparison total == expected is false for no visible reason. Money is stored as a whole number of the smallest unit you need. Mandi prices are reported in whole rupees per quintal, so whole rupees is that unit here; if a later feature needs paise (a per-kg price, say), it stores paise as a Long, not rupees as a Double. Long rather than Int costs nothing and removes any question of overflow when you multiply by quantities.

LocalDate is a date without a time or a time zone, which is exactly what "the price on 7 November" is. It needs API 26, which is your minSdk.

Some sample prices

Until the networking module, prices are hard-coded. Create a package data and SamplePrices.kt:

package com.riztechacademy.mandibhav.data

import com.riztechacademy.mandibhav.model.CommodityPrice
import java.time.LocalDate

/**
 * Hard-coded prices for building the screen. Realistic, not real: the app
 * fetches real prices in the networking module.
 */
object SamplePrices {
    private val day = LocalDate.of(2026, 11, 7)

    val lasalgaonOnion = CommodityPrice(
        commodity = "Onion",
        market = "Lasalgaon APMC",
        date = day,
        minPrice = 1_200,
        maxPrice = 2_851,
        modalPrice = 2_450,
    )

    val puneOnion = CommodityPrice(
        commodity = "Onion",
        market = "Pune (Gultekdi) APMC",
        date = day,
        minPrice = 1_000,
        maxPrice = 2_800,
        modalPrice = 2_200,
    )

    val nashikOnion = CommodityPrice(
        commodity = "Onion",
        market = "Nashik APMC",
        date = day,
        minPrice = 1_100,
        maxPrice = 2_700,
        modalPrice = 2_300,
    )

    val puneTomato = CommodityPrice(
        commodity = "Tomato",
        market = "Pune (Gultekdi) APMC",
        date = day,
        minPrice = 800,
        maxPrice = 2_000,
        modalPrice = 1_500,
    )

    val nashikTomato = CommodityPrice(
        commodity = "Tomato",
        market = "Nashik APMC",
        date = day,
        minPrice = 600,
        maxPrice = 1_800,
        modalPrice = 1_200,
    )

    val all = listOf(lasalgaonOnion, puneOnion, nashikOnion, puneTomato, nashikTomato)
}

The underscores in 2_450 are ignored by the compiler and make large numbers readable.

The trap: formatting ₹2,450

The card has to show ₹2,450, and in India large amounts group in twos after the first three digits: ₹2,45,000 is two lakh forty-five thousand. The obvious code is wrong in three different ways. These are real outputs, logged on the emulator and in a unit test:

Code Where it ran Result for 245000
String.format("₹%,d", n) Emulator, default locale en_US ₹245,000
String.format(Locale.GERMANY, "₹%,d", n) Emulator ₹245.000
String.format(Locale.forLanguageTag("mr-IN"), "₹%,d", n) Emulator ₹२४५,०००
NumberFormat.getNumberInstance(Locale.forLanguageTag("en-IN")) Emulator 2,45,000
The same NumberFormat call JVM unit test 245,000

String.format without a locale uses the phone's locale. With an English (US) phone you get Western grouping; a German setting turns the comma into a full stop; and a phone set to Marathi, which is common in rural Maharashtra and exactly Mandi Bhav's user, gets Devanagari digits. That might even be what a Marathi reader wants, but you did not choose it, and the same price now looks different on two phones in the same mandi.

java.text.NumberFormat with en-IN gets the grouping right on the phone, because Android's implementation understands two-digit groups. The desktop JVM that runs your unit tests does not, so the test and the phone disagree. That is worse than both being wrong: the test you wrote to protect the formatting cannot see the format users see.

The fix is a small function you own, which does the same thing on every phone and in every test. Create a package ui and Rupees.kt:

package com.riztechacademy.mandibhav.ui

/**
 * Formats whole rupees with Indian digit grouping: 2450 -> "₹2,450",
 * 245000 -> "₹2,45,000".
 *
 * Deliberately not String.format or NumberFormat: both depend on the
 * device locale, and java.text groups "245,000" on the JVM that runs
 * unit tests even when the phone shows "2,45,000".
 */
fun formatRupees(amount: Long): String {
    val digits = amount.toString().removePrefix("-")
    val grouped = if (digits.length <= 3) {
        digits
    } else {
        val lastThree = digits.takeLast(3)
        val rest = digits.dropLast(3)
        rest.reversed().chunked(2).joinToString(",").reversed() + "," + lastThree
    }
    val sign = if (amount < 0) "-" else ""
    return "$sign₹$grouped"
}

Take 245000: the last three digits are 000; the rest, 245, is reversed to 542, cut into twos (54, 2), joined with commas (54,2) and reversed back (2,45). Add ,000 and the rupee sign. The minus sign is handled by stripping it first and adding it back, which also copes with the one Long whose absolute value does not fit in a Long.

What it costs: you own the code and its tests, and it does not localise. Mandi Bhav shows rupees to Indian users, so a fixed Indian format is the requirement, not a shortcut. An app selling in several countries would use Android's android.icu.text formatters and test them on a device.

Testing it

Unit tests live in app/src/test/java/…, run on your computer's JVM, and need no emulator. Your starter project already has the JUnit dependency. Create app/src/test/java/com/riztechacademy/mandibhav/ui/RupeesTest.kt:

package com.riztechacademy.mandibhav.ui

import org.junit.Assert.assertEquals
import org.junit.Test
import java.util.Locale

class RupeesTest {

    @Test
    fun amountsBelowOneThousandHaveNoSeparator() {
        assertEquals("₹0", formatRupees(0))
        assertEquals("₹999", formatRupees(999))
    }

    @Test
    fun thousandsGroupLikeEverywhereElse() {
        assertEquals("₹1,000", formatRupees(1_000))
        assertEquals("₹2,450", formatRupees(2_450))
        assertEquals("₹99,999", formatRupees(99_999))
    }

    @Test
    fun lakhsAndCroresGroupInTwos() {
        assertEquals("₹1,00,000", formatRupees(1_00_000))
        assertEquals("₹2,45,000", formatRupees(2_45_000))
        assertEquals("₹1,23,45,678", formatRupees(1_23_45_678))
    }

    @Test
    fun negativeAmountsKeepTheSignInFront() {
        assertEquals("-₹2,450", formatRupees(-2_450))
        assertEquals("-₹92,23,37,20,36,85,47,75,808", formatRupees(Long.MIN_VALUE))
    }

    @Test
    fun deviceLocaleDoesNotChangeTheResult() {
        val original = Locale.getDefault()
        try {
            Locale.setDefault(Locale.forLanguageTag("mr-IN"))
            assertEquals("₹2,45,000", formatRupees(2_45_000))
            Locale.setDefault(Locale.GERMANY)
            assertEquals("₹2,45,000", formatRupees(2_45_000))
        } finally {
            Locale.setDefault(original)
        }
    }
}

The last test pins down the bug you avoided: it switches the default locale to Marathi and German and checks the output does not move. Run the tests from the project folder:

./gradlew testDebugUnitTest
> Task :app:compileDebugUnitTestKotlin
> Task :app:testDebugUnitTest

BUILD SUCCESSFUL in 1s

Your times will differ. If you swap the body for the NumberFormat version, the lakhs test fails with the exact disagreement from the table:

org.junit.ComparisonFailure: expected:<₹2[,]45,000> but was:<₹2[]45,000>

The PriceCard

Now the composable. In the ui package, create PriceCard.kt:

package com.riztechacademy.mandibhav.ui

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.riztechacademy.mandibhav.model.CommodityPrice
import java.time.format.DateTimeFormatter
import java.util.Locale

private val priceDateFormat = DateTimeFormatter.ofPattern("d MMM yyyy", Locale.ENGLISH)

@Composable
fun PriceCard(price: CommodityPrice, modifier: Modifier = Modifier) {
    Card(modifier = modifier) {
        Column(modifier = Modifier.padding(16.dp)) {
            Text(
                text = price.commodity,
                style = MaterialTheme.typography.titleLarge,
            )
            Text(
                text = "${price.market} · ${priceDateFormat.format(price.date)}",
                style = MaterialTheme.typography.bodyMedium,
                color = MaterialTheme.colorScheme.onSurfaceVariant,
            )
            Spacer(modifier = Modifier.height(12.dp))
            Text(
                text = "${formatRupees(price.modalPrice)} / quintal",
                style = MaterialTheme.typography.headlineSmall,
            )
            Text(
                text = "Range ${formatRupees(price.minPrice)} – ${formatRupees(price.maxPrice)}",
                style = MaterialTheme.typography.bodyMedium,
            )
        }
    }
}

Reading it top to bottom:

  • price: CommodityPrice — the card takes the data it shows and nothing else. It does not fetch it, so it can show any price you hand it.
  • modifier: Modifier = Modifier — every reusable composable takes a modifier, defaulting to the empty one, and applies it to its outermost element. That lets the caller decide size and position. Lesson 4 is about modifiers.
  • Card — a Material 3 surface with rounded corners and a tinted background.
  • Column with padding(16.dp) — stacks the texts with space inside the card's edge. dp is a density-independent pixel, the same size on a cheap phone and a flagship.
  • MaterialTheme.typography.titleLarge — a text style from the theme rather than a hard-coded size, so the whole app's text can change in one place in module 4.
  • priceDateFormat sits outside the function, created once. It also fixes Locale.ENGLISH for month names, for the same reason you own the rupee format. Why it lives outside the function is the subject of the next lesson.

To see it, replace the call in MainActivity for now:

setContent {
    MaterialTheme {
        PriceCard(price = SamplePrices.lasalgaonOnion)
    }
}

Run it. The card sits under the status bar, because nothing is handling edge-to-edge yet; lesson 6 puts it inside a Scaffold, which fixes that.

Check your work

Where a composable can be called from. Only from another composable; setContent starts the chain.

What PriceCard returns. Unit. It emits UI.

Why prices are Long. Double cannot represent most decimal amounts exactly, and money must add up exactly. Whole rupees per quintal is the unit mandis report in.

What String.format("%,d", 245000L) shows on a phone set to Marathi. Devanagari digits grouped Western-style: २४५,०००.

Why NumberFormat with en-IN is a trap even though the phone shows it correctly. The JVM that runs unit tests formats it as 245,000, so tests disagree with the device.

The output of formatRupees(12345678). ₹1,23,45,678.

Why PriceCard takes a modifier parameter. So the caller controls its size and placement.

Practice 2. formatRupees(10_00_00_000) is ₹10,00,00,000, ten crore.

Practice 5. ₹2,450 per 100 kg is ₹24.50 per kg, which is 2450 paise. Rupees per quintal and paise per kg are the same number, because both convert by a factor of 100.

Practice 6. One way, for non-negative amounts:

fun formatPaise(paise: Long): String {
    val rupees = formatRupees(paise / 100)
    val rest = (paise % 100).toString().padStart(2, '0')
    return "$rupees.$rest"
}

formatPaise(5) is ₹0.05; without padStart it would print ₹0.5, which is fifty paise. That is the test worth writing first.

Practice

  1. Type in CommodityPrice, SamplePrices, formatRupees and RupeesTest (type them; do not paste). Run ./gradlew testDebugUnitTest and get it green.
  2. Add a test for formatRupees(10_00_00_000) before running it. Write the expected value first, then check.
  3. Break it on purpose: change chunked(2) to chunked(3) and run the tests. Read which ones fail and what each message says. Put it back.
  4. Show PriceCard for SamplePrices.nashikTomato on the emulator.
  5. Work out, on paper, the modal price per kg for Lasalgaon onion in paise. Then notice something about the number.
  6. Harder: write formatPaise(paise: Long): String that returns ₹24.50 for 2450 and ₹1,23,456.05 for 12345605, reusing formatRupees for the part before the point. Write the tests first, including one for 5 paise. Then add a per-kg line to the card with it.

Official documentation

  • Thinking in Compose — The rules for composable functions: fast, idempotent and free of side effects.
  • DecimalFormat — Android's grouping and locale rules, which explain why the phone and the JVM disagree.
  • Card — The three card styles and their parameters.

Next: recomposition, and what actually re-runs when data changes.

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