Techbase Academy
Android · Lesson 19

State in Compose: The Tip Calculator and Art Space

11 phasesArt Space: Your Own GalleryAndroid Studio
Phase 1 of 11
Lesson Introduction

Welcome to one of the most important lessons in the whole course. Today you learn the single idea that makes every Compose app work: state.

Picture a shop assistant with a notepad. Every customer who walks in sees whatever is written on the notepad's front page: today's price of garri. When the price changes, the assistant crosses out the old number and writes the new one, and from then on every customer sees the new number. In Compose, the notepad value is state, and the act of showing it again is recomposition. Once this picture is firmly in your head, text fields, switches, counters, shopping carts and even whole games become variations on the same theme.

By the end of this lesson you will be able to:

  • Explain what state is and how a change in state triggers recomposition.
  • Use remember and mutableStateOf, and explain why rememberSaveable survives screen rotation.
  • Build a text input with OutlinedTextField and control the on-screen keyboard with KeyboardOptions.
  • Add a Switch and connect it to state.
  • Apply state hoisting to make stateless, reusable, testable composables.
  • Format money with NumberFormat.getCurrencyInstance().
  • Build a complete Tip Calculator app, including a custom tip percentage.
  • Apply everything to the Art Space project: an original gallery app with images, buttons and state.

How this fits the bigger picture: This lesson covers the official Unit 2, Pathway 3 items of Android Basics with Compose: Understanding state in Compose, Intro to tip calculator, Intro to state in Compose, Calculate a custom tip, and the Project: Create an Art Space app. We follow the official order but write our own code, explain the "why" in more depth, and set up the code so that lesson 20 can test it.

✏️ Your Task
Practise what you just learned about Lesson Introduction. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 2 of 11
Prerequisite Concepts

1. Composable functions (lessons 15 and 16)

A composable function is a Kotlin function marked @Composable that describes a piece of UI. It describes the screen; it does not draw pixels directly. Rows, Columns, Text and Image are all composables.

2. Buttons and lambdas (lesson 17)

Button(onClick = { ... }) { Text("Roll") } takes a lambda, a block of code stored to run later. You saw in lesson 12 that lambdas can be passed to functions. State hoisting is entirely about passing lambdas up and values down.

3. val and var (lesson 7)

val cannot be reassigned; var can. State values are declared with var because they change.

4. Default arguments and named parameters (lesson 8)

You will meet functions like calculateTip(amount, tipPercent = 15.0). A default means the caller may leave an argument out.

5. Nullable types and toDoubleOrNull() (lesson 10)

A text field gives you a String, but the user might type nothing or type 1.2.3. "12.5".toDoubleOrNull() gives 12.5, while "abc".toDoubleOrNull() gives null. The Elvis operator ?: supplies a fallback: value.toDoubleOrNull() ?: 0.0.

✏️ Your Task
Practise what you just learned about Prerequisite Concepts. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 3 of 11
Conceptual Understanding: State and Recomposition

What is state?

State is any value that can change over time and that the screen depends on. A counter's number, the text typed into a field, whether a switch is on, the index of the picture being shown: all of these are state.

Not everything is state. The words "Calculate Tip" on a label never change, so they are just fixed data.

Why a plain variable does not work

Beginners often try this:

kotlin
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@Composable
fun BrokenCounter() {
 var count = 0 // plain variable, NOT state
 Button(onClick = { count++ }) {
 Text("Clicked $count times")
 }
}

Tap the button and nothing changes on screen. There are two separate reasons:

  1. Compose has no idea that count changed, so it does not redraw the button.
  2. Even if it did redraw, the function would run again from the top and var count = 0 would reset the number to zero.

The Compose cycle

Compose apps follow a three-step loop. Learn it by heart:

text
        +-----------------------------+
        |   1. EVENT                  |
        |   The user taps, types,     |
        |   slides a switch...        |
        +--------------+--------------+
                       |
                       v
        +-----------------------------+
        |   2. UPDATE STATE           |
        |   Your code changes a       |
        |   state value.              |
        +--------------+--------------+
                       |
                       v
        +-----------------------------+
        |   3. DISPLAY                |
        |   Compose re-runs ONLY the  |
        |   composables that read     |
        |   that state (recomposition)|
        +--------------+--------------+
                       |
                       +--------> back to 1 when the user acts again

Key vocabulary

Term Meaning Analogy
State A value that can change and affects the UI. The price written on the shop notepad.
Composition The first time Compose builds the UI from your composables. Opening the shop and writing everything out.
Recomposition Compose running composables again because state they read has changed. Crossing out the old price and rewriting it.
MutableState An observable box that holds a value and tells Compose when it changes. A notepad that rings a bell when edited.
remember Keeps a value alive across recompositions. The notepad does not get thrown away every time you glance at it.
rememberSaveable Like remember, but also survives rotation and some other changes. A notepad saved in a drawer overnight.

Compose is smart about recomposition: it only re-runs the pieces that actually read the changed state, not the whole screen. Because recomposition can happen often and in any order, composable functions must be fast and free of side effects such as saving files or changing unrelated variables. Lesson 37 returns to this in depth.

The three ingredients of state in code

kotlin
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
var count by remember { mutableStateOf(0) }

Read it from the inside out:

  • mutableStateOf(0) creates the observable box with the starting value 0.
  • remember { ... } stores that box so the next recomposition finds the same box instead of making a new one.
  • by is a property delegate. It lets you write count and count++ instead of count.value and count.value++. It needs the two imports getValue and setValue, which Android Studio offers when you press Alt + Enter on a red by.

Think about it: Why must remember wrap mutableStateOf? Because without it, every recomposition would run mutableStateOf(0) again and create a brand-new box holding 0, wiping the user's progress.

✏️ Your Task
Practise what you just learned about Conceptual Understanding: State and Recomposition. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 4 of 11
Experiment: A Counter That Works

Create a new Empty Activity project called State Lab, package com.techbase.statelab. Replace MainActivity.kt:

MainActivity.kt
package com.techbase.statelab
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
class MainActivity : ComponentActivity() {
 override fun onCreate(savedInstanceState: Bundle?) {
 super.onCreate(savedInstanceState)
 setContent {
 MaterialTheme {
 Surface(modifier = Modifier.fillMaxSize()) {
 Column(modifier = Modifier.padding(24.dp)) {
 RememberCounter()
 SavedCounter()
 }
 }
 }
 }
 }
}
@Composable
fun RememberCounter() {
 var count by remember { mutableIntStateOf(0) }
 Log.d("StateLab", "RememberCounter composed, count=$count")
 Button(onClick = { count++ }) {
 Text("remember: $count")
 }
}
@Composable
fun SavedCounter() {
 var count by rememberSaveable { mutableIntStateOf(0) }
 Log.d("StateLab", "SavedCounter composed, count=$count")
 Button(onClick = { count++ }) {
 Text("rememberSaveable: $count")
 }
}

mutableIntStateOf is a version of mutableStateOf made for Int, which avoids boxing numbers. Either works; use mutableStateOf(0) if you prefer.

Run it and watch

  1. Tap each button three times. Both show 3.
  2. Open Logcat (lesson 18) and filter with tag:StateLab. Every tap prints a fresh composed line. That is recomposition happening in front of you. Notice that tapping the first button does not necessarily re-log the second one, because only the composable that read the changed value recomposes.
  3. Rotate the emulator (the rotate icons beside the emulator, or Ctrl + Left Arrow). The remember button goes back to 0. The rememberSaveable button keeps 3.

If auto-rotate is off on your emulator, switch it on in the quick settings or the emulator side bar.

Why the difference?

Rotating the phone is a configuration change. By default Android destroys and recreates the activity, and remember lives inside the activity's composition, so it is thrown away. rememberSaveable additionally writes the value into a small bundle that Android hands back to the new activity. Lesson 23 explains the activity lifecycle behind this. For now:

Tool Survives recomposition Survives rotation Survives process death*
plain var No No No
remember Yes No No
rememberSaveable Yes Yes Usually yes, for small values

*When Android silently kills your app in the background to free memory and later restores it. Only small, simple values (numbers, text, booleans) belong in rememberSaveable.

Rule of thumb: for anything the user typed or chose, use rememberSaveable. Nobody wants to lose a half-typed bill amount because they turned their phone sideways.

✏️ Your Task
Practise what you just learned about Experiment: A Counter That Works. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 5 of 11
Tip Calculator, Step 1: Project, Strings and the Logic

You will now build the official Tip Time app in your own way. The finished screen has a bill amount field, a tip percentage field, a "Round up tip?" switch, and the calculated tip underneath.

text
+----------------------------------+
|  Calculate Tip                   |
|                                  |
|  [ Bill Amount        1000    ]  |
|  [ Tip Percentage       15    ]  |
|  Round up tip?            (o-)   |
|                                  |
|  Tip Amount: $150.00             |
+----------------------------------+

Create the project

  1. File > New > New Project > Empty Activity.
  2. Name: Tip Time. Package: com.techbase.tiptime.
  3. Wait for Gradle to sync.

Put the words in strings.xml

Hard-coding text inside composables makes translation difficult. Android keeps user-visible words in res/values/strings.xml. Open it and make it look like this (keep the app_name that already exists):

strings.xml
<resources>
 <string name="app_name">Tip Time</string>
 <string name="calculate_tip">Calculate Tip</string>
 <string name="bill_amount">Bill Amount</string>
 <string name="how_was_the_service">Tip Percentage</string>
 <string name="round_up_tip">Round up tip?</string>
 <string name="tip_amount">Tip Amount: %s</string>
</resources>

%s is a placeholder. Later stringResource(R.string.tip_amount, "$150.00") fills it in. Placeholders let translators move the amount to wherever their language needs it.

Write the calculation first

A good habit: write the logic as a plain Kotlin function with no Compose in it. Plain functions are easy to reason about and, in lesson 20, easy to test. Create a new Kotlin file TipCalculator.kt in the com.techbase.tiptime package:

TipCalculator.kt
package com.techbase.tiptime
import java.text.NumberFormat
import kotlin.math.ceil
/**
 * Returns the tip as a number.
 * @param amount the bill before tip
 * @param tipPercent the tip as a percentage, for example 15.0 means 15 percent
 * @param roundUp when true, the tip is rounded UP to the next whole unit
 */
fun calculateTip(
 amount: Double,
 tipPercent: Double = 15.0,
 roundUp: Boolean = false
): Double {
 val tip = tipPercent / 100 * amount
 return if (roundUp) ceil(tip) else tip
}
/** Turns a number into money text using the phone's own currency settings. */
fun formatCurrency(value: Double): String {
 return NumberFormat.getCurrencyInstance().format(value)
}

Let us walk through it:

  • The default tipPercent = 15.0 means calculateTip(1000.0) works and gives 150.0.
  • ceil(tip) from kotlin.math rounds up to the next whole number. A tip of 12.5 becomes 13.0.
  • NumberFormat.getCurrencyInstance() produces money text using the phone's region settings: the currency symbol, the decimal separator and the grouping. A phone set to the US shows $150.00; a phone set to Nigeria typically shows ₦150.00. The exact symbol depends on the device's locale data, so the output on your emulator may differ from the examples here.

Some quick mental checks, which you will turn into real tests in lesson 20:

Call Result
calculateTip(1000.0) 150.0
calculateTip(1000.0, 20.0) 200.0
calculateTip(50.0, 15.0, roundUp = true) ceil(7.5) which is 8.0
calculateTip(0.0) 0.0
✏️ Your Task
Practise what you just learned about Tip Calculator, Step 1: Project, Strings and the Logic. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 6 of 11
Tip Calculator, Step 2: A Stateful Screen

First we build the screen the simplest way: all state lives inside one composable. Then in step 3 we improve it.

Open MainActivity.kt and replace everything:

MainActivity.kt
package com.techbase.tiptime
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
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.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
class MainActivity : ComponentActivity() {
 override fun onCreate(savedInstanceState: Bundle?) {
 super.onCreate(savedInstanceState)
 setContent {
 MaterialTheme {
 Surface(modifier = Modifier.fillMaxSize()) {
 TipTimeLayout()
 }
 }
 }
 }
}
@Composable
fun TipTimeLayout() {
 var amountInput by rememberSaveable { mutableStateOf("") }
 val amount = amountInput.toDoubleOrNull() ?: 0.0
 val tip = formatCurrency(calculateTip(amount))
 Column(
 modifier = Modifier
 .statusBarsPadding()
 .padding(horizontal = 40.dp)
 .verticalScroll(rememberScrollState())
 .safeContentPaddingFallback(),
 horizontalAlignment = Alignment.CenterHorizontally,
 verticalArrangement = Arrangement.Center
 ) {
 Text(
 text = stringResource(R.string.calculate_tip),
 modifier = Modifier
 .padding(bottom = 16.dp, top = 40.dp)
 .align(alignment = Alignment.Start),
 style = MaterialTheme.typography.headlineSmall
 )
 OutlinedTextField(
 value = amountInput,
 onValueChange = { amountInput = it },
 label = { Text(stringResource(R.string.bill_amount)) },
 singleLine = true,
 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
 modifier = Modifier
 .padding(bottom = 32.dp)
 .fillMaxWidth()
 )
 Text(
 text = stringResource(R.string.tip_amount, tip),
 style = MaterialTheme.typography.displaySmall
 )
 Spacer(modifier = Modifier.height(150.dp))
 }
}
private fun Modifier.safeContentPaddingFallback(): Modifier = this

The last helper is a harmless pass-through that keeps the chain readable; you may delete both it and its call. The important parts follow.

Run it

Start the app, tap the field, and type 1000. The tip below should change as you type to $150.00 (or your region's equivalent).

How it works, line by line

  1. var amountInput by rememberSaveable { mutableStateOf("") } is the notepad value. It starts as an empty string, and it survives rotation.
  2. OutlinedTextField(value = amountInput, onValueChange = { amountInput = it }, ...). This is the cycle in miniature: - Display: the field shows whatever value is. - Event: when the user types, the field does not change itself. It calls onValueChange with the text it would like to show. - Update: your lambda stores that text in amountInput. - Display again: the change triggers recomposition and the field is redrawn with the new value.

A Compose TextField is a controlled component: it is only ever as up-to-date as the state you give it. If you forget the assignment amountInput = it, typing appears to do nothing. 3. amountInput.toDoubleOrNull() ?: 0.0 turns the text into a number safely. Empty text gives 0.0, so the screen shows a zero tip instead of crashing. 4. tip is computed during composition from amount. Because amount depends on amountInput, which is state, the tip recalculates automatically each time. You never write "update the tip label" code. You describe how the tip is derived from state and Compose does the rest. 5. KeyboardOptions(keyboardType = KeyboardType.Number) asks the phone to open the numeric keyboard. It is a request, not a guarantee, and it does not stop someone pasting letters, which is why we use toDoubleOrNull(). 6. singleLine = true keeps the field to one line. 7. Modifier.statusBarsPadding() stops content hiding under the status bar when the app draws edge to edge, and verticalScroll lets the layout scroll when the keyboard shrinks the available space.

Keyboard options in more detail

KeyboardOptions has several useful settings:

Setting Example Effect
keyboardType KeyboardType.Number, Text, Email, Phone, Decimal Chooses the keyboard layout. Decimal suits money because it offers a decimal point.
imeAction ImeAction.Next, ImeAction.Done, ImeAction.Search Changes the action key at the bottom-right of the keyboard.
capitalization KeyboardCapitalization.Words Capitalises automatically.

Use KeyboardType.Decimal or KeyboardType.Number for amounts. We will use Number for consistency with the official app, and you may prefer Decimal in your own work.

Tip: If you see Unresolved reference: KeyboardOptions, the import is androidx.compose.foundation.text.KeyboardOptions. It is in the foundation package, not material3. The same goes for KeyboardType, which lives in androidx.compose.ui.text.input.

✏️ Your Task
Practise what you just learned about Tip Calculator, Step 2: A Stateful Screen. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 7 of 11
Tip Calculator, Step 3: State Hoisting, Custom Tip and the Switch

Our screen works, but everything is crammed into one function. To add a second field and a switch cleanly, we apply the most important Compose design pattern.

What is state hoisting?

State hoisting means moving state up out of a composable, to its caller, so that the composable itself becomes stateless. A stateless composable follows one rule:

State flows down, events flow up.

text
   TipTimeLayout   (OWNS the state)
        |   ^
 value  |   |  onValueChange(newText)
 (down) v   |  (up)
   EditNumberField   (STATELESS: just shows the value
                      and reports what the user did)

Why bother?

Benefit Explanation
Reusable One EditNumberField serves both the bill and the tip percentage.
Testable A stateless composable can be tested by simply handing it values (lesson 20).
Single source of truth Exactly one place owns each value, so two parts of the screen cannot disagree.
Previewable You can preview it in Android Studio with any fake value.

The usual pattern: a composable that needs state takes two parameters, value: T and onValueChange: (T) -> Unit.

Add strings for the new field labels

Everything you need is already in strings.xml: bill_amount, how_was_the_service and round_up_tip. Good planning pays off.

The complete final screen

Replace MainActivity.kt one last time. This is the whole app:

MainActivity.kt
package com.techbase.tiptime
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
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.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
class MainActivity : ComponentActivity() {
 override fun onCreate(savedInstanceState: Bundle?) {
 super.onCreate(savedInstanceState)
 setContent {
 MaterialTheme {
 Surface(modifier = Modifier.fillMaxSize()) {
 TipTimeLayout()
 }
 }
 }
 }
}
@Composable
fun TipTimeLayout() {
 var amountInput by rememberSaveable { mutableStateOf("") }
 var tipInput by rememberSaveable { mutableStateOf("") }
 var roundUp by rememberSaveable { mutableStateOf(false) }
 val amount = amountInput.toDoubleOrNull() ?: 0.0
 val tipPercent = tipInput.toDoubleOrNull() ?: 0.0
 val tip = formatCurrency(calculateTip(amount, tipPercent, roundUp))
 Column(
 modifier = Modifier
 .statusBarsPadding()
 .padding(horizontal = 40.dp)
 .verticalScroll(rememberScrollState()),
 horizontalAlignment = Alignment.CenterHorizontally,
 verticalArrangement = Arrangement.Center
 ) {
 Text(
 text = stringResource(R.string.calculate_tip),
 modifier = Modifier
 .padding(bottom = 16.dp, top = 40.dp)
 .align(alignment = Alignment.Start),
 style = MaterialTheme.typography.headlineSmall
 )
 EditNumberField(
 label = R.string.bill_amount,
 value = amountInput,
 onValueChange = { amountInput = it },
 keyboardOptions = KeyboardOptions(
 keyboardType = KeyboardType.Number,
 imeAction = ImeAction.Next
 ),
 modifier = Modifier
 .padding(bottom = 32.dp)
 .fillMaxWidth()
 )
 EditNumberField(
 label = R.string.how_was_the_service,
 value = tipInput,
 onValueChange = { tipInput = it },
 keyboardOptions = KeyboardOptions(
 keyboardType = KeyboardType.Number,
 imeAction = ImeAction.Done
 ),
 modifier = Modifier
 .padding(bottom = 32.dp)
 .fillMaxWidth()
 )
 RoundTheTipRow(
 roundUp = roundUp,
 onRoundUpChanged = { roundUp = it },
 modifier = Modifier.padding(bottom = 32.dp)
 )
 Text(
 text = stringResource(R.string.tip_amount, tip),
 style = MaterialTheme.typography.displaySmall
 )
 Spacer(modifier = Modifier.height(150.dp))
 }
}
/** Stateless: shows [value] and reports typing through [onValueChange]. */
@Composable
fun EditNumberField(
 @StringRes label: Int,
 value: String,
 onValueChange: (String) -> Unit,
 keyboardOptions: KeyboardOptions,
 modifier: Modifier = Modifier
) {
 OutlinedTextField(
 value = value,
 onValueChange = onValueChange,
 label = { Text(stringResource(label)) },
 singleLine = true,
 keyboardOptions = keyboardOptions,
 modifier = modifier
 )
}
/** Stateless: a label on the left and a Switch on the right. */
@Composable
fun RoundTheTipRow(
 roundUp: Boolean,
 onRoundUpChanged: (Boolean) -> Unit,
 modifier: Modifier = Modifier
) {
 Row(
 modifier = modifier
 .fillMaxWidth()
 .size(48.dp),
 verticalAlignment = Alignment.CenterVertically
 ) {
 Text(text = stringResource(R.string.round_up_tip))
 Switch(
 checked = roundUp,
 onCheckedChange = onRoundUpChanged,
 modifier = Modifier
 .fillMaxWidth()
 .wrapContentWidth(Alignment.End)
 )
 }
}
@Preview(showBackground = true)
@Composable
fun TipTimePreview() {
 MaterialTheme {
 TipTimeLayout()
 }
}

Walking through the new pieces

  • Three pieces of state live in TipTimeLayout: the bill text, the tip percentage text and the switch position. The two number fields keep their values as String because that is what a text field edits; we convert to numbers only when calculating.
  • EditNumberField has no state of its own. It takes value and onValueChange and hands both straight to the field. We use it twice with different labels.
  • @StringRes label: Int is an annotation that tells Android Studio "this Int must be the id of a string resource". If you accidentally pass R.drawable.something, Android Studio flags the error. You will use @DrawableRes in the same way later in this lesson and in lesson 21.
  • imeAction: the bill field shows a Next key that moves to the tip field, and the tip field shows Done, which closes the keyboard. Next and Done work by default for focus movement in simple forms; for custom behaviour you would add keyboardActions, which is beyond this lesson.
  • Switch(checked = roundUp, onCheckedChange = onRoundUpChanged): the same pattern as the text field. The switch does not flip itself; it reports the wanted new value and your state decides.
  • RoundTheTipRow sets the row height to 48.dp, a standard minimum touch target size, so the switch is easy to tap. Accessibility is covered in lessons 46 to 48.

Expected behaviour

Bill Tip % Round up Tip shown (US-style locale)
1000 15 off $150.00
1000 20 off $200.00
50 15 on $8.00
50 15 off $7.50
(empty) 15 off $0.00

Type 50, 15, then turn the switch on and off and watch $8.00 change to $7.50. Rotate the phone: all three values survive because they are rememberSaveable.

Why hoisting where we did?

We could have hoisted state all the way up into a ViewModel. For a small screen like this, holding state in the top-level composable is the right size. In lesson 24 you will move state into a ViewModel when it grows or must survive more than the screen does.

✏️ Your Task
Practise what you just learned about Tip Calculator, Step 3: State Hoisting, Custom Tip and the Switch. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 8 of 11
Art Space: Your Own Gallery App

The final item of this pathway is the Project: Create an Art Space app. The official project asks you to build a gallery that shows one artwork at a time with its title, artist and year, and Previous and Next buttons. We build our own original version here, step by step. In the @build project at the end of the lesson you will extend it with your own artworks and extra features.

Design first

text
+-----------------------------+
|                             |
|   +---------------------+   |
|   |                     |   |
|   |     ARTWORK         |   |   <- picture inside a framed, shadowed Surface
|   |     (image)         |   |
|   |                     |   |
|   +---------------------+   |
|                             |
|   Title of the artwork      |   <- text block: title
|   Artist name (2024)        |   <-   artist and year
|                             |
|   [ Previous ]   [ Next ]   |   <- two buttons
+-----------------------------+

The only thing that changes is which artwork is shown. So the app needs exactly one piece of state: the index of the current artwork.

Step 1: Add your own pictures

Use pictures you have the right to use: your own photographs, your own drawings, or images you have permission for. Three is enough.

  1. Resource file names must be lowercase letters, digits and underscores, for example art_one.png, art_two.png, art_three.jpg.
  2. Copy the files and paste them into the res/drawable folder in Android Studio's Project view (switch the view from Android), or use View > Tool Windows > Resource Manager and import them there.
  3. Keep each file small. A phone photograph can be 5 MB or more; shrink it to roughly 1000 pixels wide before adding it, because big images use lots of memory and make an app crash on modest phones.

Add strings in strings.xml:

strings.xml
<resources>
 <string name="app_name">Art Space</string>
 <string name="art_one_title">Market Morning</string>
 <string name="art_one_artist">Your Name</string>
 <string name="art_one_desc">A busy market stall at sunrise</string>
 <string name="art_two_title">Rooftops of Ibadan</string>
 <string name="art_two_artist">Your Name</string>
 <string name="art_two_desc">Rusty tin roofs under a hazy sky</string>
 <string name="art_three_title">Evening Danfo</string>
 <string name="art_three_artist">Your Name</string>
 <string name="art_three_desc">A yellow and black bus at dusk</string>
 <string name="previous">Previous</string>
 <string name="next">Next</string>
</resources>

Notice the extra _desc strings. Every image needs a content description, the text a screen reader speaks to blind users. It is not decoration; it is a requirement of good apps.

Step 2: Model the data

A data class (lesson 11) holds the details of one artwork. The annotations @DrawableRes and @StringRes make Studio check we pass the right kinds of resource id:

Artwork.kt
package com.techbase.artspace
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
data class Artwork(
 @DrawableRes val image: Int,
 @StringRes val title: Int,
 @StringRes val artist: Int,
 val year: Int,
 @StringRes val description: Int
)
val artworks = listOf(
 Artwork(R.drawable.art_one, R.string.art_one_title, R.string.art_one_artist, 2024, R.string.art_one_desc),
 Artwork(R.drawable.art_two, R.string.art_two_title, R.string.art_two_artist, 2023, R.string.art_two_desc),
 Artwork(R.drawable.art_three, R.string.art_three_title, R.string.art_three_artist, 2025, R.string.art_three_desc)
)

Create a new project called Art Space with package com.techbase.artspace for this.

Step 3: The screen and its state

MainActivity.kt
package com.techbase.artspace
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
class MainActivity : ComponentActivity() {
 override fun onCreate(savedInstanceState: Bundle?) {
 super.onCreate(savedInstanceState)
 setContent {
 MaterialTheme {
 Surface(modifier = Modifier.fillMaxSize()) {
 ArtSpaceApp()
 }
 }
 }
 }
}
@Composable
fun ArtSpaceApp(modifier: Modifier = Modifier) {
 var index by rememberSaveable { mutableIntStateOf(0) }
 val art = artworks[index]
 Column(
 modifier = modifier
 .safeDrawingPadding()
 .padding(16.dp),
 horizontalAlignment = Alignment.CenterHorizontally,
 verticalArrangement = Arrangement.SpaceBetween
 ) {
 ArtworkFrame(art = art, modifier = Modifier.weight(1f))
 Spacer(modifier = Modifier.height(16.dp))
 ArtworkCaption(art = art)
 Spacer(modifier = Modifier.height(16.dp))
 Row(
 modifier = Modifier.fillMaxWidth(),
 horizontalArrangement = Arrangement.SpaceEvenly
 ) {
 Button(onClick = { index = (index - 1 + artworks.size) % artworks.size }) {
 Text(stringResource(R.string.previous))
 }
 Button(onClick = { index = (index + 1) % artworks.size }) {
 Text(stringResource(R.string.next))
 }
 }
 }
}
@Composable
fun ArtworkFrame(art: Artwork, modifier: Modifier = Modifier) {
 Surface(
 modifier = modifier,
 shadowElevation = 8.dp,
 tonalElevation = 2.dp
 ) {
 Image(
 painter = painterResource(art.image),
 contentDescription = stringResource(art.description),
 contentScale = ContentScale.Fit,
 modifier = Modifier
 .fillMaxSize()
 .padding(24.dp)
 )
 }
}
@Composable
fun ArtworkCaption(art: Artwork, modifier: Modifier = Modifier) {
 Column(
 modifier = modifier.fillMaxWidth(),
 horizontalAlignment = Alignment.Start
 ) {
 Text(
 text = stringResource(art.title),
 style = MaterialTheme.typography.headlineSmall
 )
 Text(
 text = "${stringResource(art.artist)} (${art.year})",
 style = MaterialTheme.typography.bodyLarge
 )
 }
}

How the gallery works

  • One piece of state, index, decides everything. artworks[index] is the current artwork, and the two stateless composables ArtworkFrame and ArtworkCaption simply draw whatever artwork they are given. That is state hoisting again: they own nothing.
  • Wrap-around arithmetic. (index + 1) % artworks.size goes 0, 1, 2, 0, 1, .... The % (remainder) operator makes the number loop back to zero after the last picture. For Previous we add artworks.size first, because in Kotlin -1 % 3 is -1, which is not a valid index; (0 - 1 + 3) % 3 is 2, the last picture.
  • Modifier.weight(1f) makes the picture frame take all the leftover space, pushing the caption and buttons to the bottom.
  • ContentScale.Fit shrinks the picture to fit without cropping. Crop would fill the frame but cut off the edges.
  • safeDrawingPadding() keeps the content clear of the status bar, navigation bar and cut-outs when the app draws edge to edge.
  • painterResource(art.image) loads a drawable by its id. The contentDescription comes from a string resource, never a hard-coded one.

Things to test by hand

  1. Tap Next past the last picture. It should loop to the first.
  2. Tap Previous on the first. It should go to the last.
  3. Rotate the phone. The same picture stays (thanks to rememberSaveable).
✏️ Your Task
Practise what you just learned about Art Space: Your Own Gallery App. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 9 of 11
Guided Practice Exercises
🎯 Your Challenge

Exercise 1: Tip percent as a slider-less shortcut

In the Tip Time app, add a small row of three buttons labelled 10%, 15% and 20% that set the tip percentage field. Do this without adding any new state.

Hint: the buttons should call the same onValueChange logic that the text field uses, i.e. set tipInput.

The tip percentage text is already state (tipInput), so the buttons only need to change it. Add this stateless composable and call it inside TipTimeLayout below the tip field:

kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
fun TipPresetRow(
 onPresetSelected: (String) -> Unit,
 modifier: Modifier = Modifier
) {
 Row(
 modifier = modifier.fillMaxWidth(),
 horizontalArrangement = Arrangement.SpaceEvenly
 ) {
 listOf("10", "15", "20").forEach { percent ->
 OutlinedButton(onClick = { onPresetSelected(percent) }) {
 Text("$percent%")
 }
 }
 }
}

In TipTimeLayout, after the second EditNumberField:

kotlin
TipPresetRow(
 onPresetSelected = { tipInput = it },
 modifier = Modifier.padding(bottom = 32.dp)
)

The row is stateless. It reports which preset was tapped, and the owner updates tipInput, so the text field redraws with the new value automatically.

🎯 Your Challenge

Exercise 2: Split the bill

Add a third number field, Number of people, and show a second line Each person pays: ... that divides the bill plus tip by the number of people. If the field is empty or zero, treat it as 1. Put the arithmetic in a plain function so it can be tested later.

kotlin
// in TipCalculator.kt
fun perPerson(amount: Double, tip: Double, people: Int): Double {
 val count = if (people < 1) 1 else people
 return (amount + tip) / count
}

New state and output inside TipTimeLayout (add the string resources number_of_people and each_pays with %s in strings.xml):

kotlin
var peopleInput by rememberSaveable { mutableStateOf("") }
val people = peopleInput.toIntOrNull() ?: 1
val tipValue = calculateTip(amount, tipPercent, roundUp)
val each = formatCurrency(perPerson(amount, tipValue, people))
EditNumberField(
 label = R.string.number_of_people,
 value = peopleInput,
 onValueChange = { peopleInput = it },
 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done),
 modifier = Modifier.padding(bottom = 32.dp).fillMaxWidth()
)
Text(stringResource(R.string.each_pays, each), style = MaterialTheme.typography.titleLarge)

For bill 1000, tip 15 percent and 4 people: tip is 150, total is 1150, so each pays 287.5. Because EditNumberField is stateless and reusable, adding a third field took only a few lines.

🎯 Your Challenge

Exercise 3: Favourite artworks

In Art Space, add a Favourite Switch under the caption. Each artwork must remember its own favourite setting while you move around the gallery. Hint: one Boolean is not enough. You need state per artwork.

Keep a set of favourite indexes in state. Using a Set<Int> is simple, and saving it with rememberSaveable works if you store it as a list of ints:

kotlin
import androidx.compose.material3.Switch
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
// inside ArtSpaceApp, next to `index`
var favourites by rememberSaveable { mutableStateOf(listOf<Int>()) }
val isFavourite = index in favourites
// below ArtworkCaption(art = art)
Row(verticalAlignment = Alignment.CenterVertically) {
 Text("Favourite")
 Switch(
 checked = isFavourite,
 onCheckedChange = { wanted ->
 favourites = if (wanted) favourites + index else favourites - index
 }
 )
}

favourites + index and favourites - index create new lists (Kotlin's List is read-only), and replacing the whole list is what notifies Compose of the change. rememberSaveable can store a list of Int because it is a type Android's Bundle understands. Add Row and Alignment imports if Studio asks.

✏️ Your Task

In the State Lab project, make a third composable, ResetCounter, with a button that increments count and a second button labelled Reset that sets it back to 0. Add a Log.d and watch Logcat to see which composables recompose when you tap each button.

Phase 10 of 11
Common Beginner Mistakes (and How to Fix Them)

Mistake #1: A plain variable instead of state. The wrong code: var count = 0 inside a composable. The result: the UI never updates and the value resets on any recomposition. The fix: var count by remember { mutableStateOf(0) }.

Mistake #2: mutableStateOf without remember. The wrong code: var count by mutableStateOf(0). The problem: a fresh box is created on every recomposition, so the value always snaps back to 0. The fix: wrap it in remember (or rememberSaveable).

Mistake #3: Red by keyword. The error: Property delegate must have a 'getValue(...)' method. The fix: add import androidx.compose.runtime.getValue and import androidx.compose.runtime.setValue. Press Alt + Enter on the red text.

Mistake #4: A text field that will not change. The wrong code: OutlinedTextField(value = "", onValueChange = {}, ...). The problem: the field shows exactly the value you pass, and you never update it. The fix: store the new text in state: onValueChange = { text = it }.

Mistake #5: Using remember for user input and losing it on rotation. The symptom: typed text vanishes when the screen turns. The fix: rememberSaveable.

Mistake #6: Converting text to a number with toDouble(). The wrong code: amountInput.toDouble(). The problem: an empty string throws a NumberFormatException and the app crashes (read the stack trace using lesson 18). The fix: toDoubleOrNull() ?: 0.0.

Mistake #7: Hard-coded strings. The wrong code: Text("Calculate Tip"). The fix: put the words in strings.xml and use stringResource(R.string.calculate_tip) so the app can be translated and the words live in one place.

Mistake #8: Hiding state inside reusable composables. The problem: if EditNumberField owned its own remember, the parent could not read the text, and two fields could not be coordinated. The fix: hoist state to the caller and pass value plus onValueChange.

Mistake #9: Forgetting contentDescription. The problem: screen readers say nothing useful about your images. The fix: always give meaningful descriptions for pictures that carry meaning (use null only for purely decorative images).

Mistake #10: Index arithmetic that goes out of range. The wrong code: index = index + 1 in Next. The problem: past the last picture the app crashes with an index-out-of-bounds exception. The fix: use % artworks.size, or stop at the end.

✏️ Your Task
Practise what you just learned about Common Beginner Mistakes (and How to Fix Them). Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 11 of 11
Knowledge Check

Question 1. What is state in a Compose app, and what happens when it changes?

State is any value that can change over time and that the UI depends on. When a MutableState value changes, Compose schedules recomposition: it re-runs the composables that read that value so the screen shows the new value.

Question 2. Why is var count = 0 inside a composable not a good way to keep a counter?

Compose cannot observe a plain variable, so it will not redraw when it changes, and the composable function re-runs from the top and resets it to 0. State needs mutableStateOf (so Compose observes it) wrapped in remember (so it survives recomposition).

Question 3. What is the difference between remember and rememberSaveable?

remember keeps a value across recompositions but loses it when the activity is recreated, for example on rotation. rememberSaveable also stores the value in saved instance state, so it survives configuration changes and usually process death too, as long as the value is a type that can be saved to a Bundle.

Question 4. Describe state hoisting and the two parameters a hoisted composable normally has.

State hoisting moves state out of a composable into its caller, making the composable stateless. It receives the current value and an onValueChange: (T) -> Unit callback. State flows down; events flow up. Benefits are reuse, easier testing and a single source of truth.

Question 5. In Art Space, why does the code use (index + 1) % artworks.size rather than index + 1?

The remainder operator wraps the index back to 0 after the last artwork, so the user can keep pressing Next without the app trying to read a non-existent item, which would crash with an index-out-of-bounds exception.

Go deeper: official resources

✏️ Your Task
Practise what you just learned about Knowledge Check. Open Android Studio, type the examples by hand, change a value, and observe what happens.
🏗️ Build It — Mini Project
Art Space: Your Own Gallery

Project goal: Build a polished personal gallery app that you can put in your portfolio. Use your own artworks (photographs or drawings) and extend the lesson's code.

Requirements:

  1. Create a new project Art Space (package com.techbase.artspace) with at least four of your own pictures in res/drawable, each shrunk to a sensible size.
  2. Show one artwork at a time with its title, artist and year, in a framed, shadowed picture area.
  3. Provide Previous and Next buttons that wrap around at both ends.
  4. Show a progress line such as 2 of 4 derived from the index (do not make it separate state).
  5. Add a Favourite switch that remembers a favourite for each artwork separately.
  6. All words come from strings.xml, and every image has a content description.
  7. State survives rotation.
  8. Keep the composables stateless wherever you can, with state only in ArtSpaceApp.

Stretch goal: Add a button Show only favourites that skips non-favourite artworks, and handle the case of zero favourites with a friendly message.

Add a fourth artwork to Artwork.kt (with art_four and its four strings) and update the screen. The key changes follow.

Artwork.kt
package com.techbase.artspace
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
data class Artwork(
 @DrawableRes val image: Int,
 @StringRes val title: Int,
 @StringRes val artist: Int,
 val year: Int,
 @StringRes val description: Int
)
val artworks = listOf(
 Artwork(R.drawable.art_one, R.string.art_one_title, R.string.art_one_artist, 2024, R.string.art_one_desc),
 Artwork(R.drawable.art_two, R.string.art_two_title, R.string.art_two_artist, 2023, R.string.art_two_desc),
 Artwork(R.drawable.art_three, R.string.art_three_title, R.string.art_three_artist, 2025, R.string.art_three_desc),
 Artwork(R.drawable.art_four, R.string.art_four_title, R.string.art_four_artist, 2022, R.string.art_four_desc)
)
MainActivity.kt
package com.techbase.artspace
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
class MainActivity : ComponentActivity() {
 override fun onCreate(savedInstanceState: Bundle?) {
 super.onCreate(savedInstanceState)
 setContent {
 MaterialTheme {
 Surface(modifier = Modifier.fillMaxSize()) { ArtSpaceApp() }
 }
 }
 }
}
@Composable
fun ArtSpaceApp(modifier: Modifier = Modifier) {
 var index by rememberSaveable { mutableIntStateOf(0) }
 var favourites by rememberSaveable { mutableStateOf(listOf<Int>()) }
 val art = artworks[index]
 Column(
 modifier = modifier.safeDrawingPadding().padding(16.dp),
 horizontalAlignment = Alignment.CenterHorizontally
 ) {
 ArtworkFrame(art = art, modifier = Modifier.weight(1f))
 Spacer(modifier = Modifier.height(12.dp))
 ArtworkCaption(art = art)
 Text(
 text = "${index + 1} of ${artworks.size}",
 style = MaterialTheme.typography.labelLarge,
 modifier = Modifier.fillMaxWidth()
 )
 FavouriteRow(
 isFavourite = index in favourites,
 onChange = { wanted ->
 favourites = if (wanted) favourites + index else favourites - index
 }
 )
 Spacer(modifier = Modifier.height(12.dp))
 Row(
 modifier = Modifier.fillMaxWidth(),
 horizontalArrangement = Arrangement.SpaceEvenly
 ) {
 Button(onClick = { index = (index - 1 + artworks.size) % artworks.size }) {
 Text(stringResource(R.string.previous))
 }
 Button(onClick = { index = (index + 1) % artworks.size }) {
 Text(stringResource(R.string.next))
 }
 }
 }
}
@Composable
fun FavouriteRow(
 isFavourite: Boolean,
 onChange: (Boolean) -> Unit,
 modifier: Modifier = Modifier
) {
 Row(
 modifier = modifier.fillMaxWidth(),
 verticalAlignment = Alignment.CenterVertically,
 horizontalArrangement = Arrangement.SpaceBetween
 ) {
 Text("Favourite")
 Switch(checked = isFavourite, onCheckedChange = onChange)
 }
}
@Composable
fun ArtworkFrame(art: Artwork, modifier: Modifier = Modifier) {
 Surface(modifier = modifier, shadowElevation = 8.dp, tonalElevation = 2.dp) {
 Image(
 painter = painterResource(art.image),
 contentDescription = stringResource(art.description),
 contentScale = ContentScale.Fit,
 modifier = Modifier.fillMaxSize().padding(24.dp)
 )
 }
}
@Composable
fun ArtworkCaption(art: Artwork, modifier: Modifier = Modifier) {
 Column(modifier = modifier.fillMaxWidth()) {
 Text(text = stringResource(art.title), style = MaterialTheme.typography.headlineSmall)
 Text(
 text = "${stringResource(art.artist)} (${art.year})",
 style = MaterialTheme.typography.bodyLarge
 )
 }
}

What you should see: a framed picture with its caption, a line such as 2 of 4, a Favourite switch that keeps a separate setting per picture, and Previous and Next buttons that loop around. Rotating the phone keeps the same picture and the same favourites.

Design notes:

  • The progress line ${index + 1} of ${artworks.size} is derived from index. It is not stored, so it can never disagree with the picture.
  • FavouriteRow, ArtworkFrame and ArtworkCaption are stateless. Only ArtSpaceApp owns state.
  • For the stretch goal, compute val visible = artworks.indices.filter { it in favourites } and step through that list instead, showing a message when it is empty. Remember to clamp index when the favourites list shrinks.
  • The hard-coded "Favourite" and " of " words are fine in a first draft; moving them to strings.xml with placeholders (for example %1$d of %2$d) is the polished, translatable version.
1

Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.

2

Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 19 project”.

3

On github.com click New repository, name it android-lesson-19-state-in-compose-the-tip-calculator-and-ar, set it to Public, leave Add a README unticked, and click Create repository.

4

Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.

5

Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉

Lesson 19 complete! 🎉

You covered:

  • Lesson Introduction
  • Prerequisite Concepts
  • Conceptual Understanding: State and Recomposition
  • Experiment: A Counter That Works
  • Tip Calculator, Step 1: Project, Strings and the Logic
  • Tip Calculator, Step 2: A Stateful Screen
  • Tip Calculator, Step 3: State Hoisting, Custom Tip and the Switch
  • Art Space: Your Own Gallery App
  • Guided Practice Exercises
  • Common Beginner Mistakes (and How to Fix Them)
  • Knowledge Check
All 72 lessons in Google Android Studio ·Techbase Academy ·© 2025–2026 Techbase Consultant Services, Ibadan
Esc
↑ ↓ to moveEnter to openEsc to close