Native Android Development Tutorial (Kotlin and Jetpack Compose) - Part 1

In the React Native and Flutter tutorials we built a Notes app with cross-platform frameworks. In this tutorial we build the same app natively for Android, with Kotlin and Jetpack Compose, Google’s modern toolkit for native UI. The goal is to compare the two approaches: what do you gain, and what do you pay, when you target one platform with its own tools?

The tutorial follows the same three parts as the previous one:

  • Part 1 (this file): fundamentals and a local Notes app
  • Part 2: connecting the app to a database (Appwrite)
  • Part 3: authentication, per-user data and publishing

Versions used in this tutorial. If something breaks, come back to this table first.

Tool Version
Android Studio Latest stable (it bundles the JDK and the emulator tools)
Android Gradle Plugin (AGP) 9.4
Gradle 9.x (the Studio wizard picks it)
Kotlin 2.4
Compose BOM 2026.09.00 (Material 3 1.4)
Navigation 3 1.2
Lifecycle (ViewModel) 2.11
Android API compileSdk 37, targetSdk 36, minSdk 26

You need the previous tutorial’s app specification only: a home screen, a notes screen with create / edit / delete, then a database, then user accounts.

Visual guide. Use this map to locate the current part before starting the examples.

Four stages: local notes in Part 1, remote data in Part 2, user accounts and release in Part 3. Part 1 is highlighted. Four stages: local notes in Part 1, remote data in Part 2, user accounts and release in Part 3. Part 1 is highlighted.

The highlighted steps are developed in this part.

Table of Contents

  1. Introduction to Native Android with Kotlin and Compose
  2. Development Environment Setup
  3. Project Structure and Fundamentals
  4. UI Components and Styling
  5. Building the Notes App
  6. Compose vs React Native vs Flutter Comparison

Parts 2 and 3 continue with: 6. Database Integration with Appwrite, 7. Authentication, 8. Filtering User Notes, 9. Empty State, 10. Building and Publishing.


1. Introduction to Native Android with Kotlin and Compose

Expected result β€” native UI preview. Target for this part: a local list with two cards and an Add note button.

notes β€” 1. Introduction to Native Android with Kotlin and Compose notes β€” 1. Introduction to Native Android with Kotlin and Compose

Concepts in this section:

  • What “native” means: the platform’s own language, SDK, tools and UI toolkit
  • Kotlin: the language Google recommends for Android
  • Jetpack Compose: a declarative UI toolkit, built on the same idea as React and Flutter
  • What native costs and what it brings, compared to cross-platform

Visual guide. Follow each row from the source language to the visible interface.

React Native: TypeScript + React β€” Components describe the UI. β†’ React Native runtime β€” Hermes + JSI; Fabric manages native views. β†’ Native views β€” Android and iOS platform UI.; Flutter: Dart + widgets β€” build() describes the widget tree. β†’ Flutter engine β€” Layout and painting of the interface. β†’ Drawn UI β€” Android / iOS Also web and desktop; Native Android: Kotlin + Compose β€” Composable functions describe the UI. β†’ Compose runtime β€” State drives composition, layout and drawing. β†’ Android UI β€” Runs within the Android application. React Native: TypeScript + React β€” Components describe the UI. β†’ React Native runtime β€” Hermes + JSI; Fabric manages native views. β†’ Native views β€” Android and iOS platform UI.; Flutter: Dart + widgets β€” build() describes the widget tree. β†’ Flutter engine β€” Layout and painting of the interface. β†’ Drawn UI β€” Android / iOS Also web and desktop; Native Android: Kotlin + Compose β€” Composable functions describe the UI. β†’ Compose runtime β€” State drives composition, layout and drawing. β†’ Android UI β€” Runs within the Android application.

The three approaches share declarative UI ideas, but use different rendering paths.

Native Android Overview

Expected result β€” native UI preview. Compose renders the Notes App screen using native Android UI components.

home β€” Native Android Overview home β€” Native Android Overview

Native Android development means using the tools provided by Google for the platform:

  • Language: Kotlin (Java is still supported, but new APIs and documentation are Kotlin-first)
  • IDE: Android Studio (based on IntelliJ)
  • Build system: Gradle with the Android Gradle Plugin
  • UI toolkit: Jetpack Compose (the older XML layouts and View system still exist but are no longer the starting point)
  • Libraries: Jetpack (AndroidX): ViewModel, Navigation, DataStore, Room…

Key Characteristics of Jetpack Compose:

  • UI is described with functions annotated with @Composable (similar to React function components, or to Flutter widgets)
  • Declarative: the UI is a function of the state. When the state changes, Compose calls the affected functions again (recomposition)
  • No XML, no findViewById: UI and logic are written in Kotlin
  • Direct access to every Android API, on the day it is released
  • Live Previews in Android Studio (@Preview), plus Live Edit on a running device

Architecture: A Compose app runs on the Android Runtime (ART). Compose draws the UI itself on a canvas (like Flutter), but it is part of the platform libraries and uses Android’s text, accessibility, input and system services. Material 3 provides the ready-made components, with dynamic color (the app takes its colors from the user’s wallpaper on Android 12+).

Comparison Between Cross-Platform and Native

Expected result β€” native UI preview. Compare the same Notes use case across frameworks; this capture is the Kotlin / Compose implementation.

notes β€” Comparison Between Cross-Platform and Native notes β€” Comparison Between Cross-Platform and Native

Aspect React Native Flutter Kotlin + Compose (native)
Target platforms Android, iOS, web Android, iOS, web, desktop Android (phones, tablets, Wear OS, TV, cars)
Language TypeScript Dart Kotlin
UI Native views (Fabric) Own engine (Impeller) Compose (part of the platform libraries)
New platform features Wait for a library or write a native module Wait for a plugin or write platform code Available immediately
Code sharing with iOS ~100% ~100% None (unless you add Kotlin Multiplatform)
Tooling Expo, VS Code Flutter SDK, VS Code / Studio Android Studio (the reference IDE)
Learning curve Easy if you know React New language and widgets Kotlin + Android concepts (lifecycle, Gradle)
Typical choice when… One team, two stores, web skills One team, custom UI everywhere Best Android quality, deep OS integration

2. Development Environment Setup

Reference checkpoint. Reference checklist: finish setup, run the project, then compare your result with the Hello screen in section 3.

environment β€” 2. Development Environment Setup environment β€” 2. Development Environment Setup

Concepts in this section:

  • Android Studio bundles everything: JDK, SDK manager, emulator, Gradle integration
  • Android Virtual Device (AVD): the emulator
  • A project is generated from a template; we use Empty Activity (Compose)

Visual guide. Follow the toolchain from setup to a visible result on the device.

Setup: Prepare β€” Android Studio SDK + bundled JDK β†’ Create β€” Empty Activity Gradle sync β†’ Run and verify β€” AVD or USB device Edit a label; run again Setup: Prepare β€” Android Studio SDK + bundled JDK β†’ Create β€” Empty Activity Gradle sync β†’ Run and verify β€” AVD or USB device Edit a label; run again

Checkpoint: launch the starter app, edit a label, and verify the change.

Setting Up Android Studio

Expected result β€” native UI preview. After the section 3 entry point is added, Run should show Hello, Jetpack Compose! on the emulator.

hello β€” Setting Up Android Studio hello β€” Setting Up Android Studio

Steps:

  1. Install Android Studio from https://developer.android.com/studio and run the setup wizard (it installs the Android SDK).

  2. Create an emulator: Tools β†’ Device Manager β†’ Create Device, choose a recent Pixel phone and a recent system image (download it when asked). You can also use a real phone with Developer options β†’ USB debugging.

  3. Create a new project: New Project β†’ Phone and Tablet β†’ Empty Activity (the one with the Compose logo), then:

    Field Value
    Name Notes
    Package name com.example.notes
    Save location any folder
    Minimum SDK API 26 (Android 8.0)
    Build configuration language Kotlin DSL (build.gradle.kts)
  4. Wait for the first Gradle sync (it downloads dependencies; the first time takes a few minutes).

  5. Run the app: choose your emulator in the toolbar and press Run β–Ά. You should see a “Hello Android!” screen.

Tip: the wizard may generate files slightly different from the ones shown below (Studio is updated every few weeks). Keep what it generated and add the lines that are specific to this tutorial. Keep the launcher icon attributes the wizard put in AndroidManifest.xml.

3. Project Structure and Fundamentals

Expected result β€” native UI preview. The activity fills the screen and centers Hello, Jetpack Compose!

hello β€” 3. Project Structure and Fundamentals hello β€” 3. Project Structure and Fundamentals

Concepts in this section:

  • Anatomy of an Android project: modules, Gradle files, AndroidManifest.xml, resources
  • The version catalog (libs.versions.toml): one place for all library versions
  • An Activity is the entry point; setContent { } switches to Compose
  • A composable function is the basic building block (the equivalent of a component / widget)

Visual guide. Trace the entry path before reading the generated files.

Startup: Manifest β€” AndroidManifest.xml Declares MainActivity. β†’ Activity β€” onCreate() setContent { … } β†’ Composition β€” NotesTheme NotesApp / screen; Supporting files: Build setup β€” Gradle + version catalog β†’ App module β€” Dependencies and Android settings β†’ Resources β€” res/ icons and startup theme Startup: Manifest β€” AndroidManifest.xml Declares MainActivity. β†’ Activity β€” onCreate() setContent { … } β†’ Composition β€” NotesTheme NotesApp / screen; Supporting files: Build setup β€” Gradle + version catalog β†’ App module β€” Dependencies and Android settings β†’ Resources β€” res/ icons and startup theme

Configuration supports the project; the entry path leads to the first screen.

Visual guide. Locate the module build file between the version catalog and the selected Compose libraries.

Dependency configuration: Version catalog β€” libs.versions.toml Named versions and aliases β†’ Module build file β€” app/build.gradle.kts Uses libs.* aliases β†’ Compose BOM β€” Aligns the versions of Compose libraries. Dependency configuration: Version catalog β€” libs.versions.toml Named versions and aliases β†’ Module build file β€” app/build.gradle.kts Uses libs.* aliases β†’ Compose BOM β€” Aligns the versions of Compose libraries.

The BOM aligns Compose library versions; it does not choose the Kotlin compiler version.

Visual guide. Ask three different questions: install where, compile against what, and target which behavior?

Setting: minSdk, Question: Which devices can install?, Effect: Minimum supported Android API level.; Setting: compileSdk, Question: Which APIs can I compile against?, Effect: APIs available while compiling the app.; Setting: targetSdk, Question: Which platform behavior do I target?, Effect: Opts into platform behavior for that target. Setting: minSdk, Question: Which devices can install?, Effect: Minimum supported Android API level.; Setting: compileSdk, Question: Which APIs can I compile against?, Effect: APIs available while compiling the app.; Setting: targetSdk, Question: Which platform behavior do I target?, Effect: Opts into platform behavior for that target.

These roles stay the same when the numeric API levels change.

Android Project Structure

Reference checkpoint. Reference checklist: identify the entry point, theme, manifest and app module. This section does not add a new screen.

project β€” Android Project Structure project β€” Android Project Structure

Notes/
β”œβ”€β”€ gradle/
β”‚   └── libs.versions.toml           // Version catalog: all versions in one place
β”œβ”€β”€ build.gradle.kts                 // Project-level build file (plugins, versions)
β”œβ”€β”€ settings.gradle.kts              // Project name and modules
β”œβ”€β”€ app/                             // The "app" module
β”‚   β”œβ”€β”€ build.gradle.kts             // Module build file: SDK versions, dependencies
β”‚   └── src/main/
β”‚       β”œβ”€β”€ AndroidManifest.xml      // App declaration: activities, permissions
β”‚       β”œβ”€β”€ java/com/example/notes/  // Kotlin source code (yes, the folder is called "java")
β”‚       β”‚   β”œβ”€β”€ MainActivity.kt
β”‚       β”‚   └── ui/theme/Theme.kt
β”‚       └── res/                     // Resources: strings, themes, icons

Add the libraries we need in this part to the version catalog:

# gradle/libs.versions.toml
[versions]
agp = "9.4.1"
kotlin = "2.4.20"
composeBom = "2026.09.00"
activityCompose = "1.13.0"
coreKtx = "1.19.1"
lifecycle = "2.11.0"
navigation3 = "1.2.0"
serialization = "1.11.0"

[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-navigation3 = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-navigation3", version.ref = "lifecycle" }
androidx-navigation3-runtime = { group = "androidx.navigation3", name = "navigation3-runtime", version.ref = "navigation3" }
androidx-navigation3-ui = { group = "androidx.navigation3", name = "navigation3-ui", version.ref = "navigation3" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
// build.gradle.kts
plugins {
    alias(libs.plugins.android.application) apply false
    alias(libs.plugins.kotlin.compose) apply false
    alias(libs.plugins.kotlin.serialization) apply false
}

The wizard also generated settings.gradle.kts. Just check that it declares the two repositories where our libraries live (google() and mavenCentral()):

// settings.gradle.kts
pluginManagement {
    repositories {
        google()
        mavenCentral()
        gradlePluginPortal()
    }
}
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
    }
}

rootProject.name = "Notes"
include(":app")
// app/build.gradle.kts
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.compose)
    alias(libs.plugins.kotlin.serialization)
}

android {
    namespace = "com.example.notes"
    compileSdk = 37

    defaultConfig {
        applicationId = "com.example.notes"
        minSdk = 26
        targetSdk = 36
        versionCode = 1
        versionName = "1.0"
    }

    buildTypes {
        release {
            isMinifyEnabled = false
        }
    }

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }

    buildFeatures {
        compose = true
    }
}

dependencies {
    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.activity.compose)

    // The BOM fixes the version of every Compose library: no version on the lines below
    implementation(platform(libs.androidx.compose.bom))
    implementation(libs.androidx.compose.ui)
    implementation(libs.androidx.compose.ui.tooling.preview)
    implementation(libs.androidx.compose.material3)
    implementation(libs.androidx.compose.material.icons.core)
    debugImplementation(libs.androidx.compose.ui.tooling)

    implementation(libs.androidx.lifecycle.viewmodel.compose)
    implementation(libs.androidx.lifecycle.runtime.compose)

    // Navigation 3 (the back stack is a list that you own)
    implementation(libs.androidx.navigation3.runtime)
    implementation(libs.androidx.navigation3.ui)
    implementation(libs.androidx.lifecycle.viewmodel.navigation3)
    implementation(libs.kotlinx.serialization.json)
}

Explanation:

  • The version catalog lets every build.gradle.kts write libs.androidx... instead of repeating version numbers (the equivalent of package.json for versions and pubspec.yaml)
  • The Compose BOM (Bill of Materials) picks a set of Compose library versions that are tested together: you only choose the BOM version
  • The Compose compiler plugin (kotlin.compose) is what turns @Composable functions into UI code; its version is the Kotlin version
  • The serialization plugin is needed by Navigation 3 (section 5.1) to save the back stack
  • material-icons-core provides the basic icons (Icons.Filled.Add, Icons.Outlined.Delete…)
  • minSdk = 26 covers practically all active devices. compileSdk is the Android API you compile against: recent Compose libraries require a recent one (the build fails with a clear message when a library needs a higher value). targetSdk is the Android behavior you have tested your app on, and Google Play requires a recent one. They can be different
  • After editing Gradle files click Sync Now in Studio

Now the Android entry points. The manifest declares the app and its launcher activity:

<?xml version="1.0" encoding="utf-8"?>
<!-- app/src/main/AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <application
        android:allowBackup="true"
        android:label="Notes"
        android:supportsRtl="true"
        android:theme="@style/Theme.Notes">
        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>
<?xml version="1.0" encoding="utf-8"?>
<!-- app/src/main/res/values/themes.xml -->
<resources>
    <style name="Theme.Notes" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

Explanation:

  • AndroidManifest.xml is the app’s identity card: its activities, permissions and launcher entry. (Remember to keep the android:icon and android:roundIcon lines generated by the wizard.)
  • exported="true" plus the MAIN / LAUNCHER filter make MainActivity the screen started from the home screen of the phone
  • The XML theme only sets the window background and status bar before Compose draws; the real theme is written in Compose below

The theme, written in Kotlin:

// app/src/main/java/com/example/notes/ui/theme/Theme.kt
package com.example.notes.ui.theme

import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext

private val LightColors = lightColorScheme(primary = Color(0xFF208AEF))
private val DarkColors = darkColorScheme(primary = Color(0xFF7DB8FF))

@Composable
fun NotesTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit,
) {
    val context = LocalContext.current
    val colorScheme = when {
        // Dynamic color: colors taken from the user's wallpaper (Android 12+)
        Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
            if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
        darkTheme -> DarkColors
        else -> LightColors
    }
    MaterialTheme(colorScheme = colorScheme, content = content)
}

Explanation:

  • @Composable marks a function that describes UI. It can only be called from another composable function (or from setContent)
  • content: @Composable () -> Unit is a slot: the theme wraps whatever UI you give it. This is the Compose equivalent of children in React
  • MaterialTheme provides colors, typography and shapes to every component below it, through an implicit mechanism called CompositionLocal (like React Context)
  • Dark mode and dynamic color work in 10 lines; fallback colors are used on Android 11 and below

Finally the activity:

// app/src/main/java/com/example/notes/MainActivity.kt
package com.example.notes

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.sp
import com.example.notes.ui.theme.NotesTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge() // draw behind the system bars
        setContent {
            NotesTheme {
                Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                    Text("Hello, Jetpack Compose!", fontSize = 24.sp)
                }
            }
        }
    }
}

Explanation:

  • An Activity is an Android screen container and the app’s entry point. onCreate is called when it starts
  • setContent { } is where the View world ends and Compose starts
  • Box stacks its children, Modifier.fillMaxSize() makes it as big as its parent, and contentAlignment centers the child
  • 24.sp is a scalable pixel for text (it follows the user’s font size setting); dp is the unit for sizes and spacing
  • enableEdgeToEdge() is required for modern Android: the app draws under the status and navigation bars, and we add padding with Scaffold (section 5)

4. UI Components and Styling

Expected result β€” native UI preview. A blue rounded square is centered on a light background; its label is white and bold.

styling β€” 4. UI Components and Styling styling β€” 4. UI Components and Styling

Concepts in this section:

  • Core components: Text, Image, TextField, Button, LazyColumn, Scaffold
  • Modifiers: the way to size, pad, decorate and make a composable clickable. The order matters.
  • Layout: Column, Row, Box and weights (instead of Flexbox or Expanded)

Visual guide. Match each region of this schematic screen to a component name.

An annotated notes screen connects its container, text and list, input field and action to Jetpack Compose components. An annotated notes screen connects its container, text and list, input field and action to Jetpack Compose components.

The form is shown inside the screen only to label its parts; the code opens it in a dialog.

Compose Components and Styling

Expected result β€” native UI preview. Compare the size, corner radius, shadow and text alignment with StyleDemo.

styling β€” Compose Components and Styling styling β€” Compose Components and Styling

Visual guide. Compare the outer padding area in both examples before reading the Modifier chain.

Padding before background leaves the outer padding uncolored; background before padding colors the content and padding. Both examples reserve the same total space. Padding before background leaves the outer padding uncolored; background before padding colors the content and padding. Both examples reserve the same total space.

Both reserve padding, but only the second colors the padding area.

Compose provides small building blocks that you combine:

  • Text: displays text
  • Image / Icon: displays a picture or a vector icon
  • OutlinedTextField / TextField: text input
  • Button, TextButton, IconButton: touchable elements
  • Box: a container that stacks children (similar to View)
  • Column / Row: vertical / horizontal layout
  • LazyColumn: scrollable list that only composes the visible rows (like FlatList or ListView.builder)
  • Card, Scaffold, TopAppBar, AlertDialog: Material 3 components

Styling Example:

// app/src/main/java/com/example/notes/demo/StyleDemo.kt
package com.example.notes.demo

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp

@Composable
fun StyleDemo() {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color(0xFFF5F5F5)),
        contentAlignment = Alignment.Center,
    ) {
        Box(
            modifier = Modifier
                .size(200.dp)
                .shadow(elevation = 4.dp, shape = RoundedCornerShape(10.dp))
                .background(Color(0xFF3498DB), RoundedCornerShape(10.dp)),
            contentAlignment = Alignment.Center,
        ) {
            Text(
                text = "Styled Component",
                color = Color.White,
                fontSize = 18.sp,
                fontWeight = FontWeight.Bold,
            )
        }
    }
}

@Preview(showBackground = true)
@Composable
private fun StyleDemoPreview() {
    StyleDemo()
}

Explanation:

  • There is no stylesheet: appearance is set with parameters (color, fontSize) and with a Modifier chain
  • A Modifier is applied from the first call to the last. .shadow(...).background(...) is not the same as .background(...).shadow(...), and .padding(16.dp).background(color) leaves the padding outside the colored area, while .background(color).padding(16.dp) puts it inside
  • @Preview makes Android Studio render the composable in the editor without running the app: open the Split view
  • dp for sizes, sp for text: Compose has no px in everyday code
  • Named arguments (color = Color.White) are idiomatic Kotlin and make composable calls readable

Layout in Compose

Expected result β€” native UI preview. Header and Footer keep fixed heights; Content occupies the remaining vertical space.

layout β€” Layout in Compose layout β€” Layout in Compose

Visual guide. First identify the main axis, then find the child that receives the remaining space.

A vertical column has fixed header and footer and expanding content. Its main axis is vertical. A horizontal row places A, B and C along a horizontal main axis; the cross axis is perpendicular. A vertical column has fixed header and footer and expanding content. Its main axis is vertical. A horizontal row places A, B and C along a horizontal main axis; the cross axis is perpendicular.

Header and footer keep their size while the content area grows.

Visual guide. Use overlap to distinguish Box from the sequential layouts above.

Inside a Box, a foreground child overlaps a background child. Row and Column instead arrange children in a sequence. Inside a Box, a foreground child overlaps a background child. Row and Column instead arrange children in a sequence.

Later foreground content can cover earlier background content.

Compose has three basic layouts: Column (vertical), Row (horizontal) and Box (stacked). There is no Flexbox: weights play the role of flex: 1 and Expanded.

// app/src/main/java/com/example/notes/demo/LayoutDemo.kt
package com.example.notes.demo

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp

@Composable
fun LayoutDemo() {
    Column(modifier = Modifier.fillMaxSize()) {
        // Header: fixed height
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .height(80.dp)
                .background(Color(0xFF3498DB)),
            contentAlignment = Alignment.Center,
        ) {
            Text("Header", color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold)
        }

        // Content: takes all the remaining height
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .weight(1f)
                .background(Color(0xFFECF0F1)),
            contentAlignment = Alignment.Center,
        ) {
            Text("Content", fontSize = 18.sp)
        }

        // Footer: a Row with two items spaced evenly
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .height(60.dp)
                .background(Color(0xFF2C3E50))
                .padding(horizontal = 16.dp),
            horizontalArrangement = Arrangement.SpaceBetween,
            verticalAlignment = Alignment.CenterVertically,
        ) {
            Text("Footer", color = Color.White, fontSize = 16.sp)
            Text("v1.0", color = Color.White, fontSize = 16.sp)
        }
    }
}

@Preview(showBackground = true)
@Composable
private fun LayoutDemoPreview() {
    LayoutDemo()
}

Explanation:

  • Column stacks children vertically; Arrangement controls the spacing along the main axis (like justifyContent) and Alignment the position on the cross axis (like alignItems)
  • Modifier.weight(1f) (only available inside Column / Row) gives a child the remaining space, exactly like flex: 1 in React Native or Expanded in Flutter
  • fillMaxWidth() makes a child as wide as its parent. By default a composable is only as big as its content
  • Arrangement.spacedBy(12.dp) adds a gap between children (equivalent of gap / spacing:)

5. Building the Notes App

Expected result β€” native UI preview. The final local flow starts on Home and opens My Notes.

home β€” 5. Building the Notes App home β€” 5. Building the Notes App notes β€” 5. Building the Notes App notes β€” 5. Building the Notes App

Concepts in this section:

  • Navigation: a back stack of screens (Navigation 3: the back stack is a plain list you control)
  • State: values that, when they change, make Compose redraw the UI (remember, mutableStateOf)
  • Lists: LazyColumn with stable keys
  • State hoisting: a composable receives its state and callbacks as parameters, instead of owning them
  • ViewModel: a class that holds the screen’s state and survives configuration changes (rotation)
  • Unidirectional data flow: state flows down, events flow up

For now notes live in memory: they disappear when the app process dies. We fix that in Part 2.

5.1 Setting up Navigation

Expected result β€” native UI preview. Open my notes opens the list; Back returns to Home. Captures show the destination UI; the preview harness does not validate the Navigation 3 back stack.

home β€” 5.1 Setting up Navigation home β€” 5.1 Setting up Navigation notes β€” 5.1 Setting up Navigation notes β€” 5.1 Setting up Navigation

Concepts in this section: screens are identified by keys (small serializable objects); the back stack is a list of keys; NavDisplay shows the last one.

Visual guide. Read the stack from left to right and identify which screen is visible after each action.

Open Notes, then return: Start β€” [Home] Home is visible. β†’ add(Notes) β€” [Home, Notes] Notes is visible. β†’ Remove last β€” [Home] Home is visible again. Open Notes, then return: Start β€” [Home] Home is visible. β†’ add(Notes) β€” [Home, Notes] Notes is visible. β†’ Remove last β€” [Home] Home is visible again.

Navigation state determines the visible screen; the examples below provide the exact API calls.

Create the following structure:

app/src/main/java/com/example/notes/
β”œβ”€β”€ MainActivity.kt
β”œβ”€β”€ NotesApp.kt              // Navigation
β”œβ”€β”€ model/
β”‚   └── Note.kt
β”œβ”€β”€ ui/
β”‚   β”œβ”€β”€ theme/Theme.kt
β”‚   β”œβ”€β”€ HomeScreen.kt
β”‚   β”œβ”€β”€ NotesScreen.kt
β”‚   β”œβ”€β”€ NotesViewModel.kt
β”‚   β”œβ”€β”€ NoteItem.kt
β”‚   └── NoteInputDialog.kt

Define the screens as NavKey objects:

// app/src/main/java/com/example/notes/Routes.kt
package com.example.notes

import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable

@Serializable
data object Home : NavKey

@Serializable
data object Notes : NavKey
// app/src/main/java/com/example/notes/NotesApp.kt
package com.example.notes

import androidx.compose.runtime.Composable
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import com.example.notes.ui.HomeScreen
import com.example.notes.ui.NotesScreen

@Composable
fun NotesApp() {
    // The back stack: a list of keys, saved across rotation and process death
    val backStack = rememberNavBackStack(Home)

    NavDisplay(
        backStack = backStack,
        onBack = { backStack.removeLastOrNull() },
        entryDecorators = listOf(
            rememberSaveableStateHolderNavEntryDecorator(),
            rememberViewModelStoreNavEntryDecorator(), // one ViewModel store per screen
        ),
        entryProvider = entryProvider {
            entry<Home> {
                HomeScreen(onOpenNotes = { backStack.add(Notes) })
            }
            entry<Notes> {
                NotesScreen(onBack = { backStack.removeLastOrNull() })
            }
        },
    )
}

Use it from the activity (replace the Box with Hello created earlier):

// app/src/main/java/com/example/notes/MainActivity.kt
package com.example.notes

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.example.notes.ui.theme.NotesTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            NotesTheme {
                NotesApp()
            }
        }
    }
}

Explanation:

  • Home and Notes are the “routes”. They are @Serializable so that Android can save and restore the back stack (after rotation or if the system kills the process)
  • rememberNavBackStack(Home) creates the back stack with Home at the bottom. It behaves like a mutable list: add(Notes) navigates forward, removeLastOrNull() goes back
  • NavDisplay always displays the last entry and handles the system back gesture through onBack
  • entryProvider { entry<Home> { ... } } maps each key type to the composable to display
  • The ViewModel decorator makes viewModel() calls inside an entry return a ViewModel that lives as long as that entry is on the back stack
  • Compare: React Native uses files as routes (Expo Router), Flutter lists GoRoutes. Here the navigation state is just a list you own, so a login guard (Part 3) is simply “replace the list”

Alternative: you may also meet Navigation Compose (NavHost + composable("route")), the previous library. It is still maintained and used in many existing projects, but Navigation 3 is the recommended choice for new Compose apps.

5.2 Home Screen

Expected result β€” native UI preview. Center the app title, subtitle and Open my notes button.

home β€” 5.2 Home Screen home β€” 5.2 Home Screen

Visual guide. Use the navigation diagram in section 5.1: the Home button opens the Notes destination.

// app/src/main/java/com/example/notes/ui/HomeScreen.kt
package com.example.notes.ui

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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.example.notes.ui.theme.NotesTheme

@Composable
fun HomeScreen(
    onOpenNotes: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Scaffold(modifier = modifier) { innerPadding ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(innerPadding),
            verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
            horizontalAlignment = Alignment.CenterHorizontally,
        ) {
            Text("Notes App", style = MaterialTheme.typography.headlineLarge)
            Text("Kotlin + Jetpack Compose", style = MaterialTheme.typography.bodyLarge)
            Spacer(Modifier.height(16.dp))
            Button(onClick = onOpenNotes) {
                Text("Open my notes β†’")
            }
        }
    }
}

@Preview(showBackground = true)
@Composable
private fun HomeScreenPreview() {
    NotesTheme { HomeScreen(onOpenNotes = {}) }
}

Explanation:

  • Scaffold is the Material page skeleton (top bar, floating button, content). Its content lambda receives innerPadding: apply it, otherwise the content goes under the system bars (we enabled edge-to-edge)
  • The screen does not know about the back stack: it receives onOpenNotes: () -> Unit. This is state hoisting: events go up through callbacks, so the screen is easy to preview and test
  • modifier: Modifier = Modifier as first optional parameter is a Compose convention: it lets the caller adjust the layout
  • MaterialTheme.typography.headlineLarge reads the text style from the theme, like Theme.of(context).textTheme in Flutter
  • Button(onClick = ...) { Text(...) }: the trailing lambda is the button’s content slot

5.3 Notes Screen

Expected result β€” native UI preview. Tap +, enter a title and content, then Save: the new card appears first. Tap a card to edit it; the trash icon removes it immediately in Part 1. More states: empty form and after deletion.

notes β€” 5.3 Notes Screen notes β€” 5.3 Notes Screen new note filled β€” 5.3 Notes Screen new note filled β€” 5.3 Notes Screen

note created β€” 5.3 Notes Screen note created β€” 5.3 Notes Screen

Visual guide. Follow one action through the state update, then check how ids survive list changes.

One update: User action β€” Create, edit or delete a note. β†’ State changes β€” Apply the change to the notes list. β†’ UI updates β€” Render the current list again.; Stable identity after deleting note A: Before β€” A: Welcome B: Shopping β†’ Delete A β€” Remove by id, not by screen position. β†’ After β€” B: Shopping B keeps its identity. One update: User action β€” Create, edit or delete a note. β†’ State changes β€” Apply the change to the notes list. β†’ UI updates β€” Render the current list again.; Stable identity after deleting note A: Before β€” A: Welcome B: Shopping β†’ Delete A β€” Remove by id, not by screen position. β†’ After β€” B: Shopping B keeps its identity.

The list is local in Part 1; an id identifies a note independently of its position.

Visual guide. Compare the create and edit paths; cancellation must leave the list unchanged.

Create: New note β€” Start with empty fields. β†’ Edit draft β€” Validate the title. β†’ Save β€” Return / emit a NoteDraft.; Edit: Existing note β€” Start with current values. β†’ Edit draft β€” Keep the note id outside the draft. β†’ Save β€” Caller updates the note with that id.; Cancel: Dismiss β€” Close the form. β†’ No saved draft β€” Do not change the notes list. Create: New note β€” Start with empty fields. β†’ Edit draft β€” Validate the title. β†’ Save β€” Return / emit a NoteDraft.; Edit: Existing note β€” Start with current values. β†’ Edit draft β€” Keep the note id outside the draft. β†’ Save β€” Caller updates the note with that id.; Cancel: Dismiss β€” Close the form. β†’ No saved draft β€” Do not change the notes list.

Conceptual form flow: the explicit NoteDraft contract is introduced by the refactoring in section 5.4.

Notes are described by two small data classes. A data class automatically gets equals, hashCode, toString and copy:

// app/src/main/java/com/example/notes/model/Note.kt
package com.example.notes.model

data class Note(
    val id: String,
    val title: String,
    val content: String,
)

/** Title and content typed by the user, before an id is assigned. */
data class NoteDraft(
    val title: String,
    val content: String,
)

First version: the screen keeps the list in its own state, and the dialog code is inline. This is the equivalent of the first version we wrote in React Native and Flutter.

// app/src/main/java/com/example/notes/ui/NotesScreen.kt
package com.example.notes.ui

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.example.notes.model.Note
import java.util.UUID

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NotesScreen(onBack: () -> Unit) {
    // State: when `notes` changes, Compose redraws what reads it
    var notes by remember {
        mutableStateOf(
            listOf(
                Note("1", "Welcome", "Tap + to add a note."),
                Note("2", "Shopping", "Milk, bread, coffee."),
            ),
        )
    }
    var dialogOpen by rememberSaveable { mutableStateOf(false) }
    var editingId by rememberSaveable { mutableStateOf<String?>(null) }
    var title by rememberSaveable { mutableStateOf("") }
    var content by rememberSaveable { mutableStateOf("") }

    fun openNew() {
        editingId = null
        title = ""
        content = ""
        dialogOpen = true
    }

    fun openEdit(note: Note) {
        editingId = note.id
        title = note.title
        content = note.content
        dialogOpen = true
    }

    fun save() {
        val id = editingId
        notes = if (id == null) {
            // Never modify the list in place: create a new one
            listOf(Note(UUID.randomUUID().toString(), title.trim(), content.trim())) + notes
        } else {
            notes.map { if (it.id == id) it.copy(title = title.trim(), content = content.trim()) else it }
        }
        dialogOpen = false
    }

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("My Notes") },
                navigationIcon = {
                    IconButton(onClick = onBack) {
                        Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
                    }
                },
            )
        },
        floatingActionButton = {
            FloatingActionButton(onClick = ::openNew) {
                Icon(Icons.Filled.Add, contentDescription = "Add note")
            }
        },
    ) { innerPadding ->
        if (notes.isEmpty()) {
            Box(Modifier.fillMaxSize().padding(innerPadding), contentAlignment = Alignment.Center) {
                Text("No notes yet.")
            }
        } else {
            LazyColumn(
                modifier = Modifier.fillMaxSize().padding(innerPadding),
                contentPadding = PaddingValues(16.dp),
                verticalArrangement = Arrangement.spacedBy(12.dp),
            ) {
                items(notes, key = { it.id }) { note ->
                    Card(onClick = { openEdit(note) }, modifier = Modifier.fillMaxWidth()) {
                        Row(
                            modifier = Modifier.padding(16.dp),
                            verticalAlignment = Alignment.CenterVertically,
                        ) {
                            Column(modifier = Modifier.weight(1f)) {
                                Text(note.title, style = MaterialTheme.typography.titleMedium)
                                Text(note.content, maxLines = 2, overflow = TextOverflow.Ellipsis)
                            }
                            IconButton(onClick = { notes = notes.filterNot { it.id == note.id } }) {
                                Icon(Icons.Outlined.Delete, contentDescription = "Delete ${note.title}")
                            }
                        }
                    }
                }
            }
        }
    }

    if (dialogOpen) {
        AlertDialog(
            onDismissRequest = { dialogOpen = false },
            title = { Text(if (editingId == null) "New note" else "Edit note") },
            text = {
                Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
                    OutlinedTextField(
                        value = title,
                        onValueChange = { title = it },
                        label = { Text("Title") },
                        singleLine = true,
                    )
                    OutlinedTextField(
                        value = content,
                        onValueChange = { content = it },
                        label = { Text("Content") },
                        minLines = 3,
                    )
                }
            },
            confirmButton = {
                TextButton(onClick = ::save, enabled = title.isNotBlank()) { Text("Save") }
            },
            dismissButton = {
                TextButton(onClick = { dialogOpen = false }) { Text("Cancel") }
            },
        )
    }
}

Explanation:

  • mutableStateOf creates an observable value. When it changes, Compose recomposes (calls again) only the composables that read it
  • remember keeps the value between recompositions (otherwise the function would recreate it on every call). rememberSaveable also keeps it across rotation and process death, for values that can be saved in a Bundle (strings, booleans, numbers)
  • var notes by remember { ... } uses Kotlin property delegation (by): you read and write notes directly instead of notes.value
  • State is immutable, as in React: we build a new list (+, map, filterNot) instead of modifying the existing one. copy(...) creates a modified copy of a data class
  • LazyColumn only composes the visible rows. items(notes, key = { it.id }) gives each row a stable key, like keyExtractor or ValueKey, so Compose can keep the right row state when the list changes
  • Card(onClick = ...) is clickable without any extra modifier. Row layout: the Column with weight(1f) pushes the delete button to the end
  • AlertDialog is shown only when dialogOpen is true: showing or hiding UI is just an if
  • contentDescription is read by screen readers (TalkBack): always set it on icons that act as buttons
  • ::openNew is a function reference: it passes the local function as the callback
  • This screen works but it is long and owns the data. In 5.4 we split it, and move the data to a ViewModel

5.4 Component Refactoring and ViewModel

Expected result β€” native UI preview. Refactoring keeps the same UI. The edit dialog starts with existing values; deleting every note shows No notes yet. Also test rotation to check ViewModel ownership.

edit note β€” 5.4 Component Refactoring and ViewModel edit note β€” 5.4 Component Refactoring and ViewModel empty β€” 5.4 Component Refactoring and ViewModel empty β€” 5.4 Component Refactoring and ViewModel

Concepts in this section:

  • Single responsibility: one composable, one job
  • State hoisting: stateless composables receive data and callbacks (data down, events up)
  • ViewModel + StateFlow: the data and the business logic live outside the UI
  • collectAsStateWithLifecycle: turn a StateFlow into Compose state, only while the screen is visible

Visual guide. Read solid arrows downward for data and dashed arrows upward for user intent.

The Notes screen gives each NoteItem its note and gives the form initial values. Edit, delete, save and cancel events return to the screen. The ViewModel owns note state in Compose. The Notes screen gives each NoteItem its note and gives the form initial values. Edit, delete, save and cancel events return to the screen. The ViewModel owns note state in Compose.

The screen coordinates reusable pieces through input values and callbacks or dialog results.

Visual guide. Compare rotation with owner removal: keeping a ViewModel through one does not imply keeping it through the other.

Event: Recomposition, remember: Retained, rememberSaveable: Retained, ViewModel: Retained; Event: Rotation, remember: Recreated, rememberSaveable: Restored for supported values, ViewModel: Same instance; Event: Owner removed, remember: Leaves composition, rememberSaveable: Not permanent storage, ViewModel: Cleared when owner ends; Event: Process recreation, remember: Lost, rememberSaveable: May restore saved UI values, ViewModel: New instance; no automatic data restore Event: Recomposition, remember: Retained, rememberSaveable: Retained, ViewModel: Retained; Event: Rotation, remember: Recreated, rememberSaveable: Restored for supported values, ViewModel: Same instance; Event: Owner removed, remember: Leaves composition, rememberSaveable: Not permanent storage, ViewModel: Cleared when owner ends; Event: Process recreation, remember: Lost, rememberSaveable: May restore saved UI values, ViewModel: New instance; no automatic data restore

These lifetimes do not replace database persistence.

Extract the card into NoteItem:

// app/src/main/java/com/example/notes/ui/NoteItem.kt
package com.example.notes.ui

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.example.notes.model.Note
import com.example.notes.ui.theme.NotesTheme

@Composable
fun NoteItem(
    note: Note,
    onEdit: () -> Unit,
    onDelete: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Card(onClick = onEdit, modifier = modifier.fillMaxWidth()) {
        Row(
            modifier = Modifier.padding(16.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            Column(modifier = Modifier.weight(1f)) {
                Text(note.title, style = MaterialTheme.typography.titleMedium)
                Text(note.content, maxLines = 2, overflow = TextOverflow.Ellipsis)
            }
            IconButton(onClick = onDelete) {
                Icon(Icons.Outlined.Delete, contentDescription = "Delete ${note.title}")
            }
        }
    }
}

@Preview
@Composable
private fun NoteItemPreview() {
    NotesTheme {
        NoteItem(Note("1", "Shopping", "Milk, bread, coffee."), onEdit = {}, onDelete = {})
    }
}

Extract the dialog into NoteInputDialog:

// app/src/main/java/com/example/notes/ui/NoteInputDialog.kt
package com.example.notes.ui

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.unit.dp
import com.example.notes.model.Note
import com.example.notes.model.NoteDraft

/**
 * Form shown in a dialog. [initial] is null when creating a note.
 * It does not know where the note goes: it just returns a [NoteDraft].
 */
@Composable
fun NoteInputDialog(
    initial: Note?,
    onSave: (NoteDraft) -> Unit,
    onDismiss: () -> Unit,
) {
    // The dialog is only composed while it is open, so these values start fresh each time
    var title by rememberSaveable { mutableStateOf(initial?.title.orEmpty()) }
    var content by rememberSaveable { mutableStateOf(initial?.content.orEmpty()) }

    AlertDialog(
        onDismissRequest = onDismiss,
        title = { Text(if (initial == null) "New note" else "Edit note") },
        text = {
            Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
                OutlinedTextField(
                    value = title,
                    onValueChange = { title = it },
                    label = { Text("Title") },
                    singleLine = true,
                )
                OutlinedTextField(
                    value = content,
                    onValueChange = { content = it },
                    label = { Text("Content") },
                    minLines = 3,
                )
            }
        },
        confirmButton = {
            TextButton(
                enabled = title.isNotBlank(),
                onClick = { onSave(NoteDraft(title.trim(), content.trim())) },
            ) { Text("Save") }
        },
        dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
    )
}

Move the data and the logic to a ViewModel:

// app/src/main/java/com/example/notes/ui/NotesViewModel.kt
package com.example.notes.ui

import androidx.lifecycle.ViewModel
import com.example.notes.model.Note
import com.example.notes.model.NoteDraft
import java.util.UUID
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update

class NotesViewModel : ViewModel() {

    // Private mutable state, public read-only view
    private val _notes = MutableStateFlow(
        listOf(
            Note("1", "Welcome", "Tap + to add a note."),
            Note("2", "Shopping", "Milk, bread, coffee."),
        ),
    )
    val notes: StateFlow<List<Note>> = _notes.asStateFlow()

    fun add(draft: NoteDraft) {
        val note = Note(UUID.randomUUID().toString(), draft.title, draft.content)
        _notes.update { listOf(note) + it }
    }

    fun update(id: String, draft: NoteDraft) {
        _notes.update { list ->
            list.map { if (it.id == id) it.copy(title = draft.title, content = draft.content) else it }
        }
    }

    fun delete(id: String) {
        _notes.update { list -> list.filterNot { it.id == id } }
    }
}

The screen becomes much shorter:

// app/src/main/java/com/example/notes/ui/NotesScreen.kt
package com.example.notes.ui

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
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.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NotesScreen(
    onBack: () -> Unit,
    viewModel: NotesViewModel = viewModel(),
) {
    val notes by viewModel.notes.collectAsStateWithLifecycle()

    // UI-only state: is the dialog open, and for which note?
    var dialogOpen by rememberSaveable { mutableStateOf(false) }
    var editingId by rememberSaveable { mutableStateOf<String?>(null) }
    val editing = notes.find { it.id == editingId }

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("My Notes") },
                navigationIcon = {
                    IconButton(onClick = onBack) {
                        Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
                    }
                },
            )
        },
        floatingActionButton = {
            FloatingActionButton(
                onClick = {
                    editingId = null
                    dialogOpen = true
                },
            ) {
                Icon(Icons.Filled.Add, contentDescription = "Add note")
            }
        },
    ) { innerPadding ->
        if (notes.isEmpty()) {
            Box(Modifier.fillMaxSize().padding(innerPadding), contentAlignment = Alignment.Center) {
                Text("No notes yet.")
            }
        } else {
            LazyColumn(
                modifier = Modifier.fillMaxSize().padding(innerPadding),
                contentPadding = PaddingValues(16.dp),
                verticalArrangement = Arrangement.spacedBy(12.dp),
            ) {
                items(notes, key = { it.id }) { note ->
                    NoteItem(
                        note = note,
                        onEdit = {
                            editingId = note.id
                            dialogOpen = true
                        },
                        onDelete = { viewModel.delete(note.id) },
                    )
                }
            }
        }
    }

    if (dialogOpen) {
        NoteInputDialog(
            initial = editing,
            onSave = { draft ->
                val id = editingId
                if (id == null) viewModel.add(draft) else viewModel.update(id, draft)
                dialogOpen = false
            },
            onDismiss = { dialogOpen = false },
        )
    }
}

Explanation:

  • NoteItem and NoteInputDialog are stateless (apart from the text being typed): they receive data and callbacks. They can be previewed and reused anywhere
  • The ViewModel owns the list. viewModel() returns the same instance after a rotation, so the notes survive configuration changes (a plain remember would lose them)
  • MutableStateFlow is an observable holder of the current value. We expose it as a read-only StateFlow; only the ViewModel can change it. update { ... } modifies it safely
  • collectAsStateWithLifecycle() collects the flow only while the screen is at least STARTED (visible) and exposes it as Compose state. Always prefer it to collectAsState() on Android
  • Unidirectional data flow: the state goes down (viewModel.notes β†’ screen β†’ NoteItem) and events go up (onDelete β†’ viewModel.delete). The UI never changes the data directly
  • The dialog is UI-only state (open / closed), so it stays in the screen with rememberSaveable; the business data is in the ViewModel. This split is a standard Android architecture rule
  • Run the app: add, edit and delete notes, rotate the emulator, and check the notes and the open dialog survive

Compose vs React Native vs Flutter Comparison

Expected result β€” native UI preview. The same screen combines Scaffold, LazyColumn, Card and a floating action button.

notes β€” Compose vs React Native vs Flutter Comparison notes β€” Compose vs React Native vs Flutter Comparison

Visual guide. Review the rendering overview and the data-flow diagram while comparing the framework names below.

Concept React Native (Expo) Flutter Kotlin + Compose
Project creation npx create-expo-app flutter create Android Studio Empty Activity
Language TypeScript Dart Kotlin
UI building block Component (function returning JSX) Widget (class with build) @Composable function
Layout Flexbox (View + style) Row, Column, Expanded Row, Column, Box, Modifier.weight
Styling StyleSheet.create Widget properties, BoxDecoration Parameters + Modifier chain, MaterialTheme
Long lists FlatList ListView.builder LazyColumn
Local state useState StatefulWidget + setState remember { mutableStateOf() }
State that survives rotation not an issue (no rotation reset) not an issue rememberSaveable, ViewModel
List keys keyExtractor ValueKey items(list, key = { it.id })
Navigation Expo Router (files) go_router (route list) Navigation 3 (back stack = list of keys)
Dialog Modal showDialog + AlertDialog AlertDialog shown with an if
Passing data down Props Constructor parameters Function parameters
Sending events up Callback props VoidCallback Lambda parameters (onSave: (NoteDraft) -> Unit)
Business logic Reducer / hooks ChangeNotifier, Riverpod… ViewModel + StateFlow
Live preview Fast Refresh Hot Reload @Preview + Live Edit

In Part 2 we replace the in-memory list with a real database.


By Wahid Hamdi