The unit converter is a small app with a clear job: turn 5 kilometres into miles, or 100 degrees Celsius into Fahrenheit. It is useful for learning because it forces you to think about data design. How do you store dozens of units without writing a huge if chain? The answer is a table of data, and it is a lesson you will reuse in every later project: when you feel yourself copying code, describe the differences as data instead.
Concepts in this project
- Data-driven design: each unit is a small object with two functions,
toBaseandfromBase. To convert A to B, change A to the base unit, then change the base unit to B. With N units you write N pairs of formulas, not N times N. - Dropdown menus:
ExposedDropdownMenuBoxor aDropdownMenuanchored to a button, with items that call a lambda when chosen. - Validation: the input is text, so it may be empty or invalid. The code must show a clear message and never crash.
- Pure functions: the conversion has no Android code, so you can test it with no phone.
Create the project
- New Empty Activity project
Converter, packagecom.example.converter. No extra libraries are needed. - Add
Units.ktandConverterScreen.kt, and replaceMainActivity.kt.
Converter/app/src/main/java/com/example/converter/
MainActivity.kt shows ConverterScreen
Units.kt unit definitions and the convert function
ConverterScreen.kt input, two pickers, result
The code
`app/src/main/java/com/example/converter/Units.kt`
package com.example.converter
/** Every unit knows how to turn itself into a base unit and back. */
data class UnitDef(val name: String, val toBase: (Double) -> Double, val fromBase: (Double) -> Double)
data class Category(val name: String, val units: List<UnitDef>)
private fun linear(name: String, factor: Double) = UnitDef(name, { it * factor }, { it / factor })
val categories = listOf(
Category(
"Length (base: metre)",
listOf(linear("Metre", 1.0), linear("Kilometre", 1000.0), linear("Centimetre", 0.01),
linear("Mile", 1609.344), linear("Foot", 0.3048), linear("Inch", 0.0254))
),
Category(
"Weight (base: kilogram)",
listOf(linear("Kilogram", 1.0), linear("Gram", 0.001), linear("Pound", 0.45359237), linear("Ounce", 0.028349523125))
),
Category(
"Temperature (base: Celsius)",
listOf(
UnitDef("Celsius", { it }, { it }),
UnitDef("Fahrenheit", { (it - 32) * 5 / 9 }, { it * 9 / 5 + 32 }),
UnitDef("Kelvin", { it - 273.15 }, { it + 273.15 })
)
)
)
fun convert(value: Double, from: UnitDef, to: UnitDef): Double = to.fromBase(from.toBase(value))
Look at temperature. Celsius is the base unit, so toBase for Fahrenheit is (f - 32) * 5 / 9 and fromBase is c * 9 / 5 + 32. Length and weight units just multiply by a factor. Because every unit follows the same shape, the screen needs no special cases at all.
`app/src/main/java/com/example/converter/ConverterScreen.kt`
package com.example.converter
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import java.util.Locale
@Composable
fun ConverterScreen() {
var categoryIndex by remember { mutableIntStateOf(0) }
var fromIndex by remember { mutableIntStateOf(0) }
var toIndex by remember { mutableIntStateOf(1) }
var input by remember { mutableStateOf("1") }
val category = categories[categoryIndex]
val from = category.units[fromIndex]
val to = category.units[toIndex]
val number = input.toDoubleOrNull()
// derivedStateOf would also work here; a plain value is fine because it is cheap.
val output = number?.let { convert(it, from, to) }
Column(
Modifier.fillMaxSize().systemBarsPadding().padding(20.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Text("Unit Converter", style = MaterialTheme.typography.headlineMedium)
Picker(label = "Category", options = categories.map { it.name }, selected = categoryIndex) {
categoryIndex = it; fromIndex = 0; toIndex = 1 // reset units when the category changes
}
OutlinedTextField(
value = input,
onValueChange = { input = it },
label = { Text("Value") },
isError = number == null && input.isNotEmpty(),
supportingText = { if (number == null && input.isNotEmpty()) Text("Enter a number") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.fillMaxWidth()
)
Picker("From", category.units.map { it.name }, fromIndex) { fromIndex = it }
Picker("To", category.units.map { it.name }, toIndex) { toIndex = it }
OutlinedButton(onClick = { val t = fromIndex; fromIndex = toIndex; toIndex = t }) { Text("Swap units") }
Card(Modifier.fillMaxWidth()) {
Text(
text = if (output == null) "-" else "${format(number!!)} ${from.name} = ${format(output)} ${to.name}",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp)
)
}
}
}
@Composable
private fun Picker(label: String, options: List<String>, selected: Int, onSelect: (Int) -> Unit) {
var expanded by remember { mutableStateOf(false) }
Row(verticalAlignment = Alignment.CenterVertically) {
Text("$label:", modifier = Modifier.width(90.dp))
Box {
OutlinedButton(onClick = { expanded = true }) { Text(options[selected]) }
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEachIndexed { index, name ->
DropdownMenuItem(text = { Text(name) }, onClick = { onSelect(index); expanded = false })
}
}
}
}
}
private fun format(v: Double): String = String.format(Locale.US, "%.4f", v).trimEnd('0').trimEnd('.')
`app/src/main/java/com/example/converter/MainActivity.kt`
package com.example.converter
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent { MaterialTheme { Surface { ConverterScreen() } } }
}
}
How it works
- The category chips choose a list of units. When the category changes, both pickers are reset to sensible units from the new list.
Pickeris a reusable composable: it shows the current unit, opens a menu when tapped and reports the choice throughonPick. Its ownexpandedstate is local, because nobody else cares whether a menu is open.- The result is recomputed from the typed text. If the text is not a number, no result is shown instead of an error.
Expected output
Choose Length, type 5, from Kilometres to Miles: the result reads about 3.107. Choose Temperature, type 100, from Celsius to Fahrenheit: 212. Type 0 from Celsius to Fahrenheit: 32. Type 72 from Fahrenheit to Celsius: about 22.22. Swapping the two units gives the reverse conversion.
Your task
- Build the app and check the four example conversions.
- Add a swap button between the two pickers that exchanges the units.
- Add a new category, Speed, with metres per second, kilometres per hour and miles per hour.
- Round the output to four decimal places with
"%.4f".format(value).
How to check it worked
- Every example above matches.
- Typing letters or leaving the box empty never crashes.
- Adding Speed needed changes to the data only, not the screen.