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.Columnwithpadding(16.dp)— stacks the texts with space inside the card's edge.dpis 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.priceDateFormatsits outside the function, created once. It also fixesLocale.ENGLISHfor 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
- Type in
CommodityPrice,SamplePrices,formatRupeesandRupeesTest(type them; do not paste). Run./gradlew testDebugUnitTestand get it green. - Add a test for
formatRupees(10_00_00_000)before running it. Write the expected value first, then check. - Break it on purpose: change
chunked(2)tochunked(3)and run the tests. Read which ones fail and what each message says. Put it back. - Show
PriceCardforSamplePrices.nashikTomatoon the emulator. - Work out, on paper, the modal price per kg for Lasalgaon onion in paise. Then notice something about the number.
- Harder: write
formatPaise(paise: Long): Stringthat returns₹24.50for 2450 and₹1,23,456.05for 12345605, reusingformatRupeesfor 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