Techbase Academy
Android · Lesson 61

The UI Layer: State Holders, State Production and Events

11 phasesThe Students Screen, Done ProperlyAndroid Studio
Phase 1 of 11
Lesson Introduction

The UI layer has one job: show application data on the screen and serve as the point of user interaction. Whenever data changes, the UI must update. Whenever the user acts, the UI must tell someone who can respond. In lesson 60 you separated the layers. This lesson goes deep inside the top one.

It covers the UI Layer, The UI layer - MAD Skills, Handling UI events, State holders and UI state and State production items of the Modern Android App Architecture programme (https://developer.android.com/courses/android-architecture), based on the official pages:

  • UI layer: https://developer.android.com/topic/architecture/ui-layer
  • UI events: https://developer.android.com/topic/architecture/ui-layer/events
  • State holders and UI state: https://developer.android.com/topic/architecture/ui-layer/stateholders
  • State production: https://developer.android.com/topic/architecture/ui-layer/state-production

By the end you will be able to:

  • Design UI state classes for loading, content and error.
  • Choose between a ViewModel and a plain state holder.
  • Produce state with StateFlow, stateIn, combine, produceState and mutableStateOf.
  • Handle UI events correctly, including one-off events modelled as state.
  • Preserve state across configuration changes and process death with SavedStateHandle.
  • Distinguish UI logic from business logic and place each properly.

Big idea: the UI layer is a pipeline: data in → state production → UI state → render → user events → back to the state holder. If you can name every stage, you can debug any screen.

✏️ 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
  • Architecture principles and layers (lesson 60).
  • ViewModel, StateFlow, collectAsStateWithLifecycle (lessons 25 and 33).
  • Flow operators (lesson 33).
  • State hoisting (lessons 19 and 37).
  • Navigation arguments (lessons 26 and 55).
✏️ 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: The Parts of the UI Layer
text
 Data layer
     │  Flow / suspend
     ▼
 State holder (ViewModel or plain class)
     │  produces
     ▼
 UI state  (immutable data class)
     │  collected by
     ▼
 UI elements (composables)
     │  user events
     └──────────► back to the state holder
Part Role
UI state An immutable snapshot of everything the screen needs
State holder Owns the state, exposes it, and handles events
UI elements Render state and report events

Designing UI state

A good UI state:

  • is immutable (vals in a data class),
  • contains only what the screen shows,
  • makes impossible states impossible.

Version A: a flat class with flags.

kotlin
data class FeeUiState(
 val loading: Boolean = true,
 val payments: List<Payment> = emptyList(),
 val error: String? = null
)

This allows loading = true and error != null at the same time, a combination that makes no sense.

Version B: a sealed hierarchy.

kotlin
sealed interface FeeUiState {
 data object Loading : FeeUiState
 data class Success(val payments: List<Payment>, val summary: FeeSummary) : FeeUiState
 data class Error(val message: String) : FeeUiState
}

Now exactly one case is possible at a time, and a when expression must handle all of them:

kotlin
@Composable
fun FeeScreen(state: FeeUiState, onRetry: () -> Unit) {
 when (state) {
 FeeUiState.Loading -> CircularProgressIndicator()
 is FeeUiState.Error -> ErrorMessage(state.message, onRetry)
 is FeeUiState.Success -> PaymentList(state.payments, state.summary)
 }
}

Which to choose? Use a sealed state when the cases are truly exclusive. Use a flat data class when the pieces vary independently (a screen showing a list and a refresh indicator and a snackbar). Many real screens use a flat class with a few sealed parts. The official guidance only asks that state be immutable and complete.

Properties of good state

Do Do not
Expose StateFlow<UiState> Expose MutableStateFlow
Use immutable List Use MutableList in state
Keep display-ready values when helpful ("₦150,000") Put raw Context or View references in state
Have a default initial state Leave the screen with nothing to render
✏️ Your Task
Practise what you just learned about Conceptual Understanding: The Parts of the UI Layer. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 4 of 11
Step-by-Step Build 1: ViewModel or Plain State Holder?

There are two kinds of state holder.

ViewModel Plain state holder class
Lives Screen level (survives configuration changes) Composable level, created with remember
Scope The whole screen A part of the UI (a form, a search bar, a scaffold)
Needs the data layer Yes (business logic, repositories) Usually not, UI logic only
Example FeeViewModel rememberSearchState(), ScaffoldState

The guidance: business logic (rules about fees, repository calls) belongs in a ViewModel; UI logic (how to show something, scroll positions, expansion states) belongs in a plain state holder or composable state.

A plain state holder

kotlin
@Stable
class SearchState(initialQuery: String = "") {
 var query by mutableStateOf(initialQuery)
 private set
 val isActive: Boolean get() = query.isNotBlank()
 fun onQueryChange(value: String) { query = value }
 fun clear() { query = "" }
 companion object {
 val Saver: Saver<SearchState, String> = Saver(
 save = { it.query },
 restore = { SearchState(it) }
 )
 }
}
@Composable
fun rememberSearchState(): SearchState =
 rememberSaveable(saver = SearchState.Saver) { SearchState() }

Usage:

kotlin
@Composable
fun StudentSearchBar(onSearch: (String) -> Unit) {
 val state = rememberSearchState()
 OutlinedTextField(
 value = state.query,
 onValueChange = {
 state.onQueryChange(it)
 onSearch(it)
 },
 trailingIcon = { if (state.isActive) IconButton(onClick = state::clear) { Icon(Icons.Default.Clear, "Clear") } }
 )
}

The holder is plain Kotlin with Compose state: no ViewModel is needed, because nothing here involves the data layer. Using rememberSaveable with a custom Saver keeps the query across rotation.

Rule of thumb: if the logic needs a repository or must outlive the composition, use a ViewModel. If it only manipulates what is shown, use a plain holder. They can work together: the ViewModel holds screen state and the plain holder wraps UI-only details.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 1: ViewModel or Plain State Holder?. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 5 of 11
Step-by-Step Build 2: Producing State

State production is how you turn data (flows, suspend calls, user input) into UI state. The official page describes it as a pipeline with inputs (events, data) and outputs (state).

Pattern 1: Update a MutableStateFlow in response to events

kotlin
class FeeViewModel(private val repository: PaymentsRepository) : ViewModel() {
 private val _uiState = MutableStateFlow<FeeUiState>(FeeUiState.Loading)
 val uiState: StateFlow<FeeUiState> = _uiState.asStateFlow()
 fun refresh() {
 viewModelScope.launch {
 _uiState.value = FeeUiState.Loading
 _uiState.value = try {
 val payments = repository.payments()
 FeeUiState.Success(payments, summarise(payments))
 } catch (e: IOException) {
 FeeUiState.Error("Could not load payments")
 }
 }
 }
}

Good for one-shot loads and events.

Pattern 2: Transform a data stream with stateIn

When the data layer exposes a Flow (for example from Room), convert it directly:

kotlin
class FeeViewModel(repository: PaymentsRepository) : ViewModel() {
 val uiState: StateFlow<FeeUiState> = repository.paymentsStream() // Flow<List<Payment>>
 .map<List<Payment>, FeeUiState> { FeeUiState.Success(it, summarise(it)) }
 .catch { emit(FeeUiState.Error("Could not load payments")) }
 .stateIn(
 scope = viewModelScope,
 started = SharingStarted.WhileSubscribed(5_000),
 initialValue = FeeUiState.Loading
 )
}

stateIn turns a cold flow into a hot StateFlow. WhileSubscribed(5_000) keeps the upstream running for 5 seconds after the last collector leaves. This survives a screen rotation without restarting the work, but stops when the user really leaves the screen. It is the recommended default in the official material.

Pattern 3: Combine several inputs

A screen with a search query and a filter uses combine:

kotlin
class StudentsViewModel(repository: StudentsRepository) : ViewModel() {
 private val query = MutableStateFlow("")
 private val onlyOwing = MutableStateFlow(false)
 val uiState: StateFlow<StudentsUiState> =
 combine(repository.studentsStream(), query, onlyOwing) { students, q, owing ->
 val shown = students
 .filter { it.name.contains(q, ignoreCase = true) }
 .filter { !owing || it.balance > 0 }
 StudentsUiState(query = q, onlyOwing = owing, students = shown)
 }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), StudentsUiState())
 fun onQueryChange(value: String) { query.value = value }
 fun onOnlyOwingChange(value: Boolean) { onlyOwing.value = value }
}

Every input change produces a new complete state, so the UI never sees half-updated data.

Pattern 4: Compose-only production with produceState

For small, UI-local sources, produceState converts a non-Compose source into Compose State:

kotlin
@Composable
fun rememberNow(): State<LocalTime> = produceState(initialValue = LocalTime.now()) {
 while (true) {
 delay(1_000)
 value = LocalTime.now()
 }
}

Another useful tool is snapshotFlow, which turns Compose state into a flow, as you saw in lesson 54.

Where do you collect?

kotlin
@Composable
fun FeeRoute(viewModel: FeeViewModel = viewModel()) {
 val state by viewModel.uiState.collectAsStateWithLifecycle()
 FeeScreen(state = state, onRetry = viewModel::refresh)
}

Always use collectAsStateWithLifecycle (from lifecycle-runtime-compose) so that collection stops when the app is in the background, saving work and battery.

Surviving process death with SavedStateHandle

A ViewModel survives configuration changes but not the system killing your process. Small, important pieces of state (a search query, a selected id) can be saved with SavedStateHandle:

kotlin
class StudentsViewModel(
 private val savedState: SavedStateHandle,
 repository: StudentsRepository
) : ViewModel() {
 val query: StateFlow<String> = savedState.getStateFlow("query", "")
 fun onQueryChange(value: String) { savedState["query"] = value }
}

Keep what you store small (strings, ids, flags). The big data is reloaded from the data layer.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 2: Producing State. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 6 of 11
Step-by-Step Build 3: Handling UI Events

A UI event is something that happens in or to the UI: a click, a text change, a swipe. The official page splits events by who should handle them:

Event Handled by Example
Needs business logic ViewModel "Pay" clicked: validate and record payment
Needs UI behaviour logic UI (composable or plain holder) Show a snackbar, navigate, scroll to top

Business-logic events: ViewModel functions

kotlin
fun onPayClicked(studentId: Int, amount: Int) {
 viewModelScope.launch {
 repository.recordPayment(studentId, amount)
 }
}

The UI calls viewModel.onPayClicked(...). The ViewModel changes state; the UI recomposes.

UI-behaviour events: handle in the UI

kotlin
@Composable
fun StudentRow(student: Student, onPay: () -> Unit, onOpen: () -> Unit) {
 // navigation and "open details" are UI behaviour; the ViewModel need not know
 Row(Modifier.clickable(onClick = onOpen)) { /* ... */ }
}

The one-off events problem

Some outcomes are "do it once": show a snackbar "Payment saved", navigate after saving. The old approach used SharedFlow or Channel events from the ViewModel. The current official recommendation is: model one-off events as state, and have the UI consume them.

kotlin
data class PayUiState(
 val saving: Boolean = false,
 val userMessage: String? = null, // "Payment saved", consumed once
 val savedAndFinished: Boolean = false // navigation trigger
)
class PayViewModel(private val repository: PaymentsRepository) : ViewModel() {
 private val _uiState = MutableStateFlow(PayUiState())
 val uiState: StateFlow<PayUiState> = _uiState.asStateFlow()
 fun pay(studentId: Int, amount: Int) {
 viewModelScope.launch {
 _uiState.update { it.copy(saving = true) }
 repository.recordPayment(studentId, amount)
 _uiState.update { it.copy(saving = false, userMessage = "Payment saved", savedAndFinished = true) }
 }
 }
 fun messageShown() = _uiState.update { it.copy(userMessage = null) }
}

The UI shows the message and then tells the ViewModel it was consumed:

kotlin
@Composable
fun PayRoute(viewModel: PayViewModel, snackbar: SnackbarHostState, onFinished: () -> Unit) {
 val state by viewModel.uiState.collectAsStateWithLifecycle()
 LaunchedEffect(state.userMessage) {
 state.userMessage?.let {
 snackbar.showSnackbar(it)
 viewModel.messageShown()
 }
 }
 LaunchedEffect(state.savedAndFinished) {
 if (state.savedAndFinished) onFinished()
 }
 PayScreen(state = state, onPay = viewModel::pay)
}

Why state instead of an event stream? Events sent while the UI is not listening (during rotation, for example) can be lost or delivered twice with channels. State is retained until consumed, so it is never lost, and it can be rendered repeatedly without side effects if you clear it after use.

A rule of thumb: the ViewModel says what happened (state). The UI decides how to show it (a snackbar, a dialog, a navigation call).

Text fields and state

A text field is an event source that must update state synchronously to avoid cursor jumps. For simple fields, keep the field in UI state (rememberSaveable) or, when business logic needs every keystroke (validation), keep it in the ViewModel as a StateFlow<String> and read it with collectAsStateWithLifecycle. If you use TextFieldState (the newer Compose text API) hold it in a plain state holder. Do not debounce the value that the field displays; debounce only the work that reacts to it.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 3: Handling UI Events. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 7 of 11
Step-by-Step Build 4: UI Logic vs Business Logic
Question Business logic UI logic
What does it need? Data and domain rules Screen elements and resources
Example "A student with a balance over 0 is owing" "Show the owing badge in red"
Home Domain/data layer (via ViewModel) Composable or plain state holder
Test JUnit Compose UI test

Mixing them is the cause of most "my ViewModel is 800 lines long" problems.

kotlin
// Business logic (domain)
fun Student.isOwing(): Boolean = balance > 0
// UI logic (composable)
@Composable
fun OwingBadge(student: Student) {
 if (student.isOwing()) {
 AssistChip(onClick = {}, label = { Text("Owing") }, colors = AssistChipDefaults.assistChipColors(
 containerColor = MaterialTheme.colorScheme.errorContainer))
 }
}
✏️ Your Task
Practise what you just learned about Step-by-Step Build 4: UI Logic vs Business Logic. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 8 of 11
Step-by-Step Build 5: Putting the Pipeline Together

Before the exercises, trace one user action through the whole pipeline, because being able to narrate the flow is the best debugging skill in this layer. Suppose a bursar taps Mark as paid on a student row in the Students screen.

  1. The UI element reports an event. The row's button calls a lambda passed from above, onMarkPaid(student.id). The row does not know what happens next, and it holds no state of its own about payment.
  2. The route composable forwards it. StudentsRoute receives viewModel::markPaid as the callback. This is a business-logic event, because it changes stored data, so it goes to the ViewModel rather than being handled in the UI.
  3. The state holder runs business logic. markPaid launches a coroutine in viewModelScope, asks the repository to record the payment, and then writes a message into its message flow.
  4. The data layer changes the source of truth. The repository updates the database. Because the screen collects a Flow from that database, the list re-emits on its own.
  5. State is produced. combine merges the new student list, the unchanged query and filter, and the new message into a fresh Success state. stateIn publishes it.
  6. The UI renders. collectAsStateWithLifecycle hands the new state to Compose. The row now shows a cleared balance, and a LaunchedEffect keyed on the message shows a snackbar and calls messageShown().

If any step misbehaves you can look at exactly one boundary: is the event reaching the ViewModel (add a log or a breakpoint in markPaid)? Is the repository changing the data (check the database with the Database Inspector from lesson 44)? Is the state being produced (inspect uiState.value in a test)? Is the UI reading the right state (check that you are collecting the same StateFlow and not a copy)? Debugging by boundary is far quicker than staring at a whole screen.

Two warnings about state production

First, never create a new StateFlow inside a getter. A property such as val uiState get() = flow.stateIn(...) builds a new pipeline each time it is read, so the UI would restart your work on every recomposition. Define the property once, as a value, as in the examples above.

Second, keep the initial value honest. stateIn needs an initialValue, and many learners pass an empty Success, which makes the screen flash "no students" before the data arrives. Use Loading (or a Success with a refreshing flag when you already have cached content), so that what the user sees matches reality.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 5: Putting the Pipeline Together. 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: Make impossible states impossible

This state allows loading = true with a non-empty error. Redesign it for a screen that shows a list of students, an error, or a spinner.

kotlin
data class StudentsUiState(
 val loading: Boolean = false,
 val students: List<Student> = emptyList(),
 val error: String? = null
)
kotlin
sealed interface StudentsUiState {
 data object Loading : StudentsUiState
 data class Success(val students: List<Student>) : StudentsUiState
 data class Error(val message: String) : StudentsUiState
}

Now only one case exists at a time and when forces every case to be handled. (If you also need a pull-to-refresh indicator while content is visible, keep a refreshing: Boolean inside Success.)

🎯 Your Challenge

Exercise 2: Pick the holder

For each, choose ViewModel or plain state holder: (a) the loaded list of students; (b) whether a filter panel is expanded; (c) the text in a search box that must survive rotation; (d) the result of saving a payment.

(a) ViewModel (data layer, screen state). (b) Plain state or rememberSaveable (pure UI). (c) Plain state holder with rememberSaveable, or SavedStateHandle if the ViewModel filters with it. (d) ViewModel, because it follows a business operation.

🎯 Your Challenge

Exercise 3: Replace the channel

Convert this one-off event approach into state:

kotlin
private val _events = Channel<String>()
val events = _events.receiveAsFlow()
fun save() { viewModelScope.launch { repo.save(); _events.send("Saved") } }
kotlin
data class SaveUiState(val message: String? = null)
private val _uiState = MutableStateFlow(SaveUiState())
val uiState = _uiState.asStateFlow()
fun save() {
 viewModelScope.launch {
 repo.save()
 _uiState.update { it.copy(message = "Saved") }
 }
}
fun messageShown() = _uiState.update { it.copy(message = null) }

The UI shows message in a LaunchedEffect(state.message) and calls messageShown() afterwards, so it is neither lost during rotation nor shown twice.

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

Mistake #1: Exposing MutableStateFlow. Fix: expose StateFlow with asStateFlow().

Mistake #2: Using collectAsState instead of collectAsStateWithLifecycle. Fix: the lifecycle version stops collection in the background.

Mistake #3: Flags that allow impossible states. Fix: a sealed interface where cases are exclusive.

Mistake #4: Doing heavy mapping in the composable. Fix: produce display-ready state in the state holder.

Mistake #5: Passing Context, NavController or SnackbarHostState into the ViewModel. Fix: keep UI objects in the UI; send state.

Mistake #6: One-off events through SharedFlow or Channel that get lost. Fix: model events as state and consume them.

Mistake #7: Starting stateIn with Eagerly for everything. Fix: use WhileSubscribed(5_000).

Mistake #8: Storing large data in SavedStateHandle. Fix: save ids; reload data.

Mistake #9: Creating a ViewModel for every tiny widget. Fix: use a plain state holder.

Mistake #10: Updating StateFlow with a mutable object in place. Fix: copy: update { it.copy(...) }.

✏️ 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 are the three parts of the UI layer?

UI state, state holders and UI elements.

Question 2. Why use WhileSubscribed(5_000)?

It keeps the upstream running through short interruptions like rotation but stops it when the user has really left, saving resources.

Question 3. When should a plain state holder be preferred to a ViewModel?

When the logic is UI-only and does not need the data layer or to outlive the composition's screen scope.

Question 4. How should a ViewModel tell the UI to show a snackbar?

By setting a state value (such as userMessage); the UI shows it and calls a function to clear it.

Question 5. What does SavedStateHandle protect against?

Loss of small pieces of state when the system kills the app process while it is in the background.

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
The Students Screen, Done Properly

Project goal: Build a Students screen that demonstrates every part of the UI layer.

Requirements:

  1. A StudentsRepository interface and a fake with a Flow<List<Student>> that emits an update every few seconds.
  2. A StudentsViewModel that combines the repository flow, a search query (backed by SavedStateHandle) and an "only owing" filter, and produces a StateFlow<StudentsUiState> with stateIn and WhileSubscribed(5_000).
  3. A sealed StudentsUiState with Loading, Success and Error.
  4. A plain rememberSearchState-style holder for the search field's UI behaviour (clear button).
  5. A one-off message ("Marked as paid") implemented as state and consumed with a snackbar.
  6. Collection with collectAsStateWithLifecycle.
  7. Tests: the ViewModel's filtering (with fake repository and runTest), and a UI test for Success and Error.

Stretch goal: add produceState to show a "last updated 12 s ago" label that ticks every second without involving the ViewModel.

The ViewModel (core of the answer):

kotlin
sealed interface StudentsUiState {
 data object Loading : StudentsUiState
 data class Success(
 val query: String,
 val onlyOwing: Boolean,
 val students: List<Student>,
 val message: String? = null
 ) : StudentsUiState
 data class Error(val message: String) : StudentsUiState
}
class StudentsViewModel(
 private val savedState: SavedStateHandle,
 private val repository: StudentsRepository
) : ViewModel() {
 private val query = savedState.getStateFlow("query", "")
 private val onlyOwing = MutableStateFlow(false)
 private val message = MutableStateFlow<String?>(null)
 val uiState: StateFlow<StudentsUiState> =
 combine(repository.studentsStream(), query, onlyOwing, message) { students, q, owing, msg ->
 val shown = students
 .filter { it.name.contains(q, ignoreCase = true) }
 .filter { !owing || it.balance > 0 }
 StudentsUiState.Success(q, owing, shown, msg) as StudentsUiState
 }
 .catch { emit(StudentsUiState.Error("Could not load students")) }
 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), StudentsUiState.Loading)
 fun onQueryChange(value: String) { savedState["query"] = value }
 fun onOnlyOwingChange(value: Boolean) { onlyOwing.value = value }
 fun markPaid(id: Int) {
 viewModelScope.launch {
 repository.markPaid(id)
 message.value = "Marked as paid"
 }
 }
 fun messageShown() { message.value = null }
}

A ViewModel test:

kotlin
class StudentsViewModelTest {
 @get:Rule val mainRule = MainDispatcherRule()
 @Test
 fun filter_only_owing_hides_cleared_students() = runTest {
 val repo = FakeStudentsRepository(
 listOf(Student(1, "Ada", balance = 0), Student(2, "Tunde", balance = 5_000))
 )
 val vm = StudentsViewModel(SavedStateHandle(), repo)
 val job = launch(UnconfinedTestDispatcher(testScheduler)) { vm.uiState.collect {} }
 vm.onOnlyOwingChange(true)
 val state = vm.uiState.value as StudentsUiState.Success
 assertEquals(listOf("Tunde"), state.students.map { it.name })
 job.cancel()
 }
}

The test collects in the background because stateIn(WhileSubscribed) only runs while something is subscribed, which is exactly why this default saves work in the real app.

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 61 project”.

3

On github.com click New repository, name it android-lesson-61-the-ui-layer-state-holders-state-productio, 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 61 complete! 🎉

You covered:

  • Lesson Introduction
  • Prerequisite Concepts
  • Conceptual Understanding: The Parts of the UI Layer
  • Step-by-Step Build 1: ViewModel or Plain State Holder?
  • Step-by-Step Build 2: Producing State
  • Step-by-Step Build 3: Handling UI Events
  • Step-by-Step Build 4: UI Logic vs Business Logic
  • Step-by-Step Build 5: Putting the Pipeline Together
  • 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