Your assignment: Temperature Unit Converter
Convert between Celsius and Fahrenheit with one input, a clear direction selector, Convert and Clear actions, and support for negative and decimal temperatures.
Beginner · 2–3 hours of building
Before you begin
- Connect Compose inputs to rememberSaveable state.
- Understand nullable parsing and basic function tests.
- Use Kotlin if expressions and decimal arithmetic.
By the end, you can…
- Model two mutually exclusive choices with one Boolean.
- Build accessible radio-style selection rows.
- Apply different formulas from the selected direction.
- Preserve intermediate text and reject nonfinite input.
- Verify known reference values and negative values with local tests.
Required features
- Provide Celsius to Fahrenheit and Fahrenheit to Celsius choices.
- Allow only one conversion direction to be selected.
- Accept positive, negative, zero, and decimal temperature text.
- Update both the input label and output unit when direction changes.
- Keep the typed number when direction changes and explain that it is reinterpreted in the new input unit.
- Display helpful messages for blank, malformed, nonfinite, or overflowing input.
- Restore the input and direction after rotation and round only the displayed output.
- Provide Convert and Clear buttons; changing input or direction hides the previous result, and Clear resets the field, direction, and result.
Set up the project and run the reference app
- Open: Extract the ZIP. In Android Studio choose Open, then select the project folder containing
settings.gradle.kts. Open that folder rather than just a Kotlin file. - Prepare: Use Android Studio’s bundled JDK 17 or 21 in Gradle settings. In SDK Manager install Android SDK Platform 36 and any Build Tools requested during sync.
- Sync: Allow Gradle to download the pinned dependencies. Initial setup requires internet. The app runs offline and needs no backend, login or API key.
- Run: In Device Manager create an emulator with API 23 or newer, or connect a device with USB debugging. Select the
apprun configuration and press Run. - Observe: Try the normal and boundary scenarios from Test the app. Record what changes after each action. Use Logcat to investigate a crash instead of changing several files at once.
- Practice: Copy the extracted folder to a working folder. Keep the build files, manifest, resources and
CommonUi.kt. Rebuild the application files following the steps below. Attempt each task before consulting its reference.
.\gradlew.bat :app:assembleDebug :app:testDebugUnitTestKeep the toolchain consistent
TOOLCHAIN.md for setup details.Understand the topics before coding
For each topic, connect the Kotlin concept to a visible behavior. Identify the value the screen reads and the event that can change that value.
One selection, one source of truth
fromCelsius is true for Celsius input and false for Fahrenheit input. Both selection rows read this one Boolean, so they cannot both be selected. Tapping the second row assigns false, changes the input label, and hides the previous result until Convert is tapped again. The formula and output unit follow the same direction value.
The inverse formula has a different order
Celsius to Fahrenheit multiplies by 9/5 before adding 32. Fahrenheit to Celsius subtracts 32 before multiplying by 5/9. For 32°F, subtracting first produces zero and therefore 0°C; dividing first would produce the wrong answer. Decimal constants also prevent integer truncation.
Editor state is broader than valid numbers
A user typing -40 first produces a minus sign on its own. Store that temporary string instead of rejecting the keystroke or converting it immediately to zero. Tapping Convert during incomplete input shows guidance; after entering the complete number, tapping Convert shows the valid conversion.
Semantics should match the selection behavior
The whole direction row is selectable and declares a radio-button role. Its child RadioButton has no separate click action, so one visible choice does not become two competing controls. The selected state and text together help assistive technology communicate the available direction.
Three different lifetimes
rememberSaveable restores small supported values after Activity recreation; a ViewModel retains screen state during configuration changes. Neither replaces permanent storage. The notes project uses Room for records that must remain after closing and reopening the app.Build the app step by step
Work in this order. The code panels are focused excerpts from the complete files below. They may begin or end inside a function: use the full source browser when copying a file, including its imports. After every step, run the app and check the stated result.
Step 1. Write a pure conversion function
Your task: Create TemperatureConverter.kt with Success(value, unit) and Error(message) result cases. Write convertTemperature(input, fromCelsius) and safely parse the trimmed input before applying a formula.
Open: app/src/main/java/com/mastercoding/miniprojects/converter/TemperatureConverter.kt
How it works: A blank input or the string NaN should return guidance rather than a numeric output. Keeping this rule in a pure function means the same behavior can be tested from the JVM and reused by another screen. The result includes its output unit so the number and unit are returned together.
}
fun convertTemperature(input: String, fromCelsius: Boolean): ConversionResult {
val value = input.trim().toDoubleOrNull()
if (value == null || !value.isFinite()) {
return ConversionResult.Error("Enter a finite temperature. Negative values are allowed.")
}
val converted = if (fromCelsius) value * (9.0 / 5.0) + 32.0 else (value - 32.0) * (5.0 / 9.0)
if (!converted.isFinite()) {
return ConversionResult.Error("This temperature is too large to convert.")
}
return ConversionResult.Success(converted, if (fromCelsius) "°F" else "°C")
}Stop and check
Step 2. Implement both directions using decimal arithmetic
Your task: Use value × (9.0 / 5.0) + 32.0 for Celsius input and (value − 32.0) × (5.0 / 9.0) for Fahrenheit input. Check the calculated result is finite before returning it with °F or °C.
Open: app/src/main/java/com/mastercoding/miniprojects/converter/TemperatureConverter.kt
How it works: With input 100 and fromCelsius true, the multiplication yields 180 and the final result is 212°F. With input 212 and false, subtracting 32 yields 180 and the result is 100°C. The .0 constants make both ratios floating-point divisions rather than integer divisions.
return ConversionResult.Error("Enter a finite temperature. Negative values are allowed.")
}
val converted = if (fromCelsius) value * (9.0 / 5.0) + 32.0 else (value - 32.0) * (5.0 / 9.0)
if (!converted.isFinite()) {
return ConversionResult.Error("This temperature is too large to convert.")
}
return ConversionResult.Success(converted, if (fromCelsius) "°F" else "°C")
}Stop and check
Step 3. Keep input and direction in saved state
Your task: Inside TemperatureConverter, declare saved input and fromCelsius values plus a saved showResult Boolean. Derive result by calling the conversion function with both current inputs; use showResult to control whether the output is visible.
Open: app/src/main/java/com/mastercoding/miniprojects/converter/MainActivity.kt
How it works: Typing changes input and hides the previous output. Convert sets showResult to true, revealing the calculation for the current input and direction. There is no separate numeric result state to synchronize. After rotation, restoring these primitive values lets the screen show the same successful conversion or validation message.
@Composable
fun TemperatureConverter() {
var input by rememberSaveable { mutableStateOf("0") }
var fromCelsius by rememberSaveable { mutableStateOf(true) }
var showResult by rememberSaveable { mutableStateOf(false) }
val result = convertTemperature(input, fromCelsius)
Text("Choose the input scale, then enter a temperature.")
DirectionOption("Celsius to Fahrenheit", fromCelsius) { fromCelsius = true; showResult = false }
DirectionOption("Fahrenheit to Celsius", !fromCelsius) { fromCelsius = false; showResult = false }
OutlinedTextField(
value = input,
onValueChange = { input = it; showResult = false },
label = { Text(if (fromCelsius) "Temperature (°C)" else "Temperature (°F)") },
supportingText = { Text("Use a dot for decimals. You can type a minus sign.") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = { showResult = true }) { Text("Convert") }
OutlinedButton(onClick = { input = ""; fromCelsius = true; showResult = false }) { Text("Clear") }
}
if (!showResult) Text("Tap Convert to calculate the current input.")
if (showResult) when (result) {
is ConversionResult.Error -> Text(result.message, color = MaterialTheme.colorScheme.error)Stop and check
Step 4. Build a single-action selection row
Your task: Implement DirectionOption using a selectable Row with Role.RadioButton, a RadioButton whose onClick is null, and the direction label. Call it twice with complementary selected values and callbacks that set the shared Boolean. Also set showResult to false when selecting a direction so an old result cannot be mistaken for the new conversion.
Open: app/src/main/java/com/mastercoding/miniprojects/converter/MainActivity.kt
How it works: Tapping anywhere in the Fahrenheit row sets fromCelsius to false once. The row owns the interaction, while the child circle only displays its state. Because the Celsius row reads fromCelsius and the Fahrenheit row reads its inverse, exactly one choice remains selected.
@Composable
private fun DirectionOption(label: String, selected: Boolean, onSelect: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().selectable(selected = selected, role = Role.RadioButton, onClick = onSelect),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
RadioButton(selected = selected, onClick = null)
Text(label)
}
}Stop and check
Step 5. Add Convert, Clear, and a labelled result
Your task: Add the text field with a label derived from fromCelsius and a keyboard that permits a minus sign. Convert sets showResult to true; Clear empties the field, restores Celsius input, and hides the output. Render Success with its unit and six significant digits, or Error as guidance.
Open: app/src/main/java/com/mastercoding/miniprojects/converter/MainActivity.kt
How it works: With input 0 Celsius, Convert shows 32 degrees Fahrenheit. Switching to Fahrenheit keeps 0 in the field but hides the old output; Convert then shows about -17.7778 degrees Celsius. Clear provides a predictable fresh form. Keeping the visibility flag separate from the derived number preserves a clear event-to-state flow.
value = input,
onValueChange = { input = it; showResult = false },
label = { Text(if (fromCelsius) "Temperature (°C)" else "Temperature (°F)") },
supportingText = { Text("Use a dot for decimals. You can type a minus sign.") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = { showResult = true }) { Text("Convert") }
OutlinedButton(onClick = { input = ""; fromCelsius = true; showResult = false }) { Text("Clear") }
}
if (!showResult) Text("Tap Convert to calculate the current input.")
if (showResult) when (result) {
is ConversionResult.Error -> Text(result.message, color = MaterialTheme.colorScheme.error)
is ConversionResult.Success -> Text(
"${String.format(Locale.US, "%.6g", if (result.value == 0.0) 0.0 else result.value)} ${result.unit}",
style = MaterialTheme.typography.headlineMedium
)
}
Text("Changing direction keeps the typed number and interprets it in the newly selected input scale.")
Text("The result is rounded to six significant digits. This exercise converts numeric values; it does not model physical limits.")
}
@Composable
private fun DirectionOption(label: String, selected: Boolean, onSelect: () -> Unit) {Stop and check
Step 6. Check reference values, invalid text, and recreation
Your task: Run TemperatureConverterTest and add a round-trip example within a numeric tolerance. On a device, convert -40 in both directions, test Clear, then convert 98.6 Fahrenheit and rotate.
Open: app/src/test/java/com/mastercoding/miniprojects/converter/TemperatureConverterTest.kt
How it works: The value -40 is equal on both scales, making it a useful negative-number check. Decimal assertions need a tolerance because floating-point arithmetic is approximate. The device rotation check covers state restoration, which the pure calculation tests intentionally do not exercise.
}
@Test fun negativeFortyIsTheSameOnBothScales() {
assertEquals(-40.0, (convertTemperature("-40", true) as ConversionResult.Success).value, 0.000001)
assertEquals(-40.0, (convertTemperature("-40", false) as ConversionResult.Success).value, 0.000001)
}
@Test fun decimalsAreNotTruncated() {
assertEquals(98.6, (convertTemperature("37", true) as ConversionResult.Success).value, 0.000001)
}
@Test fun rejectsBlankNonNumericNonFiniteAndOverflow() {
listOf("", "-", "abc", "NaN", "Infinity", "1.7e308").forEach {
assertTrue(convertTemperature(it, true) is ConversionResult.Error)
}
}
}Stop and check
Complete source files
Every text file in the ZIP is available here, including Kotlin, resources, Gradle scripts and the student README. The binary Gradle wrapper JAR is included in the ZIP. Source excerpts and downloads are generated from these same files.
| Start here | Responsibility |
|---|---|
| MainActivity.kt | The launcher Activity installs the Compose UI with setContent. |
| CommonUi.kt | Material 3 theme and reusable screen container with scrolling and system insets. |
| Application Kotlin files | State, rules, navigation or persistence introduced in the guide. |
| app/src/main/res | Images, text resources, and the launch theme. |
| app/build.gradle.kts | SDK settings and the exact libraries used by this app. |
| README.md / TOOLCHAIN.md | Student instructions and setup, usable without the website. |
Android launcher Activity, setContent entry point and screen composition. Start tracing the app here.
19 complete text files. Copy the selected file with the code panel button, or download the full project above.
package com.mastercoding.miniprojects.converter
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import java.util.Locale
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MiniTheme { MiniScreen("Temperature Converter") { TemperatureConverter() } }
}
}
}
@Composable
fun TemperatureConverter() {
var input by rememberSaveable { mutableStateOf("0") }
var fromCelsius by rememberSaveable { mutableStateOf(true) }
var showResult by rememberSaveable { mutableStateOf(false) }
val result = convertTemperature(input, fromCelsius)
Text("Choose the input scale, then enter a temperature.")
DirectionOption("Celsius to Fahrenheit", fromCelsius) { fromCelsius = true; showResult = false }
DirectionOption("Fahrenheit to Celsius", !fromCelsius) { fromCelsius = false; showResult = false }
OutlinedTextField(
value = input,
onValueChange = { input = it; showResult = false },
label = { Text(if (fromCelsius) "Temperature (°C)" else "Temperature (°F)") },
supportingText = { Text("Use a dot for decimals. You can type a minus sign.") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = { showResult = true }) { Text("Convert") }
OutlinedButton(onClick = { input = ""; fromCelsius = true; showResult = false }) { Text("Clear") }
}
if (!showResult) Text("Tap Convert to calculate the current input.")
if (showResult) when (result) {
is ConversionResult.Error -> Text(result.message, color = MaterialTheme.colorScheme.error)
is ConversionResult.Success -> Text(
"${String.format(Locale.US, "%.6g", if (result.value == 0.0) 0.0 else result.value)} ${result.unit}",
style = MaterialTheme.typography.headlineMedium
)
}
Text("Changing direction keeps the typed number and interprets it in the newly selected input scale.")
Text("The result is rounded to six significant digits. This exercise converts numeric values; it does not model physical limits.")
}
@Composable
private fun DirectionOption(label: String, selected: Boolean, onSelect: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().selectable(selected = selected, role = Role.RadioButton, onClick = onSelect),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
RadioButton(selected = selected, onClick = null)
Text(label)
}
}Test the app with concrete scenarios
Run these scenarios on your device or emulator. Write down the actual result beside the expected result. A successful Gradle build checks compilation; it does not demonstrate tapping, accessibility, keyboard behavior or rotation.
| Do this | Expected result |
|---|---|
| Convert 0°C and 100°C. Tap Convert for each calculation. | The displayed results are 32°F and 212°F, allowing trailing decimal zeros from formatting. |
| Select Fahrenheit input and enter 32. Tap Convert for each calculation. | The result is 0°C. |
| Enter -40 and test both directions. Tap Convert for each calculation. | Both directions return -40 with the correct output unit. |
| Convert 37°C. Tap Convert for each calculation. | The result is approximately 98.6°F; the decimal part is retained. |
| Enter 0, then switch from Celsius input to Fahrenheit input. Tap Convert for each calculation. | The field stays 0, its unit changes to Fahrenheit, and the old output hides. Convert then displays approximately -17.7778 degrees Celsius. |
| Clear the field, type only a minus sign, paste letters, and try NaN or Infinity. Tap Convert for each calculation. | The app shows a finite-temperature message without crashing or erasing the typed text. |
| Select Fahrenheit input, enter 98.6, tap Convert, and rotate. | Fahrenheit remains selected, the field remains 98.6, and the visible result is approximately 37 degrees Celsius. |
| Show a result, change the number, then tap Clear. | Editing hides the prior result. Clear empties the field, restores Celsius input, and displays Convert guidance. |
Your completion checklist
Check an item only after demonstrating it. Progress is saved in this browser.
Troubleshooting: understand the cause
Fahrenheit-to-Celsius always returns zero or Celsius-to-Fahrenheit has the wrong slope.
Cause: Ratios such as 5 / 9 use integer division.
Repair: Use 5.0 / 9.0 and 9.0 / 5.0 in the formulas.
The output number has the wrong unit after switching direction.
Cause: The label or result unit is hard-coded or controlled by a different state value.
Repair: Derive the formula, input label, and output unit from fromCelsius.
Students cannot type negative values using the keyboard.
Cause: Some numeric keyboard layouts do not expose a minus sign.
Repair: Choose an input keyboard that permits signed text and keep validation in the conversion function.
The app shows Infinity after accepting a huge finite number.
Cause: Input finiteness was checked but arithmetic overflow was not.
Repair: Check converted.isFinite before constructing the successful result.
Submit your app and explain your decisions
- Submit the Android Studio project without build caches,
.gradleor machine-specificlocal.properties. - Include two screenshots demonstrating normal behavior and an edge case.
- Add a short README recording the device/API version, actions, observed outcomes, and any known limitation.
- Answer the questions below using function and state names from your own implementation.
- Why does changing the direction update the output even though the input string has not changed?
- Why should the app preserve an incomplete minus sign while the user is typing?
- What additional state or data model would be useful if the converter supported five input units?
- Why does the app save a visibility flag while calculating the result number from the input?
| Criterion | Points | What to demonstrate |
|---|---|---|
| Core behavior | 40 | All required actions give the expected result. |
| Validation and lifecycle | 20 | Invalid inputs, empty states, rotation and the project's persistence rules work. |
| Interface and accessibility | 20 | Readable contrast, labeled inputs, meaningful image descriptions, scrollable content and usable touch targets. |
| Code and explanation | 20 | Small named functions, clear state ownership and a correct explanation of one event-to-result trace. |
Extra challenges after the core works
- Add Kelvin with explicit conversions and decide whether to validate absolute-zero limits.
- Add a Swap action that intentionally places the unrounded result into the input and reverses direction.
- Add another conversion category such as meters and feet using an enum instead of a Boolean.
Choose one extension, write its acceptance check first, then implement it without breaking the original scenarios.