Building Your First Android App: Step-by-Step Guide with Kotlin
Build your first Android app with Kotlin and Android Studio — from project setup to Play Store submission. A practical, no-fluff guide for absolute beginners.
Get more content like this on Telegram!
Daily AI tips, notes & resources — free
Building Your First Android App: Step-by-Step Guide with Kotlin
The first Android app I published had a bug that showed the wrong date for every timezone that wasn't UTC. I found out from a one-star review. The reviewer was in Tokyo.
I had tested the app thoroughly — on my phone, on the emulator, on a colleague's device. All US-based. That experience taught me two things: users find bugs you never imagined, and shipping something real teaches you more than any tutorial.
This guide will take you from zero to a working Android app using Kotlin and Jetpack Compose. Not just a "Hello World" — an actual app with UI, state management, and data persistence. We'll ship it to the Play Store at the end.
What You'll Build
A habit tracker app. Simple concept, real complexity. It will have:
- A list of daily habits with completion checkboxes
- The ability to add and delete habits
- Persistent storage so data survives app restarts
- Clean Material Design 3 UI
This covers 90% of the skills you need for real Android development.
Step 1: Set Up Android Studio
Download Android Studio from developer.android.com/studio. It's free and includes everything: the IDE, the Android emulator, build tools, and the SDK manager.
Installation takes 10–20 minutes. After first launch, let the Setup Wizard run — it downloads the Android SDK components you'll need.
Create your first project:
- Click "New Project"
- Select "Empty Activity" (this gives you Compose by default)
- Name:
HabitTracker - Package name:
com.yourname.habittracker - Language: Kotlin
- Minimum SDK: API 26 (Android 8.0) — covers 95%+ of active devices
Step 2: Understand the Project Structure
Open your new project. The key folders:
app/
├── src/
│ └── main/
│ ├── java/com/yourname/habittracker/
│ │ └── MainActivity.kt ← Your entry point
│ ├── res/
│ │ ├── values/
│ │ │ ├── strings.xml ← Text strings
│ │ │ └── themes.xml ← App theme
│ │ └── drawable/ ← Images and icons
│ └── AndroidManifest.xml ← App metadata & permissions
├── build.gradle.kts ← App-level build config
└── proguard-rules.pro ← Code obfuscation rules
Open MainActivity.kt. It looks like this:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
HabitTrackerTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Greeting("Android")
}
}
}
}
}
@Composable
fun Greeting(name: String) {
Text(text = "Hello $name!")
}
setContent { } is where your Compose UI lives. @Composable marks functions that describe UI. This is the fundamental pattern in Jetpack Compose.
Step 3: Add Dependencies
Open app/build.gradle.kts and add these dependencies:
dependencies {
// Compose BOM (manages all Compose versions)
val composeBom = platform("androidx.compose:compose-bom:2024.06.00")
implementation(composeBom)
// Core Compose
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui-tooling-preview")
// Lifecycle & ViewModel
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.0")
implementation("androidx.activity:activity-compose:1.9.0")
// DataStore for persistence
implementation("androidx.datastore:datastore-preferences:1.1.1")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0")
}
Sync the project when prompted (Sync Now button in the top toolbar).
Step 4: Build the Data Model
Create a new Kotlin file: Habit.kt
package com.yourname.habittracker
import java.time.LocalDate
data class Habit(
val id: Long = System.currentTimeMillis(),
val name: String,
val completedDates: Set<LocalDate> = emptySet()
) {
val isCompletedToday: Boolean
get() = LocalDate.now() in completedDates
}
Kotlin's data class is one of the language features that makes it so much cleaner than Java. This one line replaces about 40 lines of Java boilerplate (constructor, getters, equals(), hashCode(), toString()):
data class Habit(val id: Long, val name: String, ...)
// Java equivalent: ~50 lines of code
The isCompletedToday computed property is another Kotlin strength — it's calculated on access, not stored. No need to call a method.
Step 5: Create the ViewModel
The ViewModel holds your app's state and survives screen rotations. This is Android-specific architecture that beginners often skip — and then wonder why their app loses all data when the user rotates the phone.
Create HabitViewModel.kt:
package com.yourname.habittracker
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.time.LocalDate
class HabitViewModel : ViewModel() {
// StateFlow emits updates to the UI whenever data changes
private val _habits = MutableStateFlow<List<Habit>>(emptyList())
val habits: StateFlow<List<Habit>> = _habits.asStateFlow()
fun addHabit(name: String) {
if (name.isBlank()) return
val newHabit = Habit(name = name.trim())
_habits.value = _habits.value + newHabit
}
fun deleteHabit(habitId: Long) {
_habits.value = _habits.value.filter { it.id != habitId }
}
fun toggleHabitCompletion(habitId: Long) {
val today = LocalDate.now()
_habits.value = _habits.value.map { habit ->
if (habit.id != habitId) return@map habit
val updatedDates = if (today in habit.completedDates) {
habit.completedDates - today
} else {
habit.completedDates + today
}
habit.copy(completedDates = updatedDates)
}
}
}
Key concepts here: StateFlow is a Kotlin coroutine-based observable. When _habits.value changes, anything observing habits gets updated automatically. The copy() function on a data class creates a new instance with modified fields — standard immutable update pattern.
Step 6: Build the UI with Compose
Replace the content of MainActivity.kt:
package com.yourname.habittracker
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.*
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
import com.yourname.habittracker.ui.theme.HabitTrackerTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
HabitTrackerTheme {
HabitTrackerApp()
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HabitTrackerApp(viewModel: HabitViewModel = viewModel()) {
val habits by viewModel.habits.collectAsStateWithLifecycle()
var showAddDialog by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(title = { Text("Habit Tracker") })
},
floatingActionButton = {
FloatingActionButton(onClick = { showAddDialog = true }) {
Icon(Icons.Default.Add, contentDescription = "Add habit")
}
}
) { paddingValues ->
if (habits.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize().padding(paddingValues),
contentAlignment = Alignment.Center
) {
Text("No habits yet. Tap + to add one.", style = MaterialTheme.typography.bodyLarge)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(paddingValues),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(habits, key = { it.id }) { habit ->
HabitCard(
habit = habit,
onToggle = { viewModel.toggleHabitCompletion(habit.id) },
onDelete = { viewModel.deleteHabit(habit.id) }
)
}
}
}
if (showAddDialog) {
AddHabitDialog(
onDismiss = { showAddDialog = false },
onAdd = { name ->
viewModel.addHabit(name)
showAddDialog = false
}
)
}
}
}
@Composable
fun HabitCard(habit: Habit, onToggle: () -> Unit, onDelete: () -> Unit) {
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = habit.isCompletedToday,
onCheckedChange = { onToggle() }
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = habit.name,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, contentDescription = "Delete habit")
}
}
}
}
@Composable
fun AddHabitDialog(onDismiss: () -> Unit, onAdd: (String) -> Unit) {
var text by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("New Habit") },
text = {
TextField(
value = text,
onValueChange = { text = it },
label = { Text("Habit name") },
singleLine = true
)
},
confirmButton = {
TextButton(onClick = { if (text.isNotBlank()) onAdd(text) }) {
Text("Add")
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
)
}
Run the app with Shift+F10 or the green Play button. You should see a Material Design 3 app with a floating action button that opens a dialog to add habits.
Step 7: Add Data Persistence
Right now data is lost when the app closes. Fix that with DataStore:
// HabitRepository.kt
package com.yourname.habittracker
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
private val Context.dataStore by preferencesDataStore(name = "habits")
private val HABITS_KEY = stringPreferencesKey("habits_json")
class HabitRepository(private val context: Context) {
val habitsFlow: Flow<List<Habit>> = context.dataStore.data.map { prefs ->
val json = prefs[HABITS_KEY] ?: return@map emptyList()
try {
Json.decodeFromString<List<Habit>>(json)
} catch (e: Exception) {
emptyList()
}
}
suspend fun saveHabits(habits: List<Habit>) {
context.dataStore.edit { prefs ->
prefs[HABITS_KEY] = Json.encodeToString(habits)
}
}
}
Update your ViewModel to use the repository and launch coroutines for persistence. This is a pattern you'll use constantly in real Android development.
Step 8: Understanding the Android Lifecycle
Android kills and recreates Activities (screens) in many situations: screen rotation, low memory, app going to background. This lifecycle is the #1 source of crashes for Android beginners.
| Lifecycle Event | When It Fires | What To Do |
|---|---|---|
onCreate() | App first starts | Initialize ViewModel, set up Compose |
onStart() | App becomes visible | Resume location updates |
onResume() | App in foreground | Start animations, audio |
onPause() | Another app comes forward | Save unsaved data |
onStop() | App not visible | Release camera, stop network calls |
onDestroy() | Activity being destroyed | Final cleanup |
With Compose and ViewModels, you handle most of this automatically. The ViewModel survives configuration changes. collectAsStateWithLifecycle() starts/stops collection based on the lifecycle. You rarely need to override these methods in modern Android code.
Step 9: Build for Release
When your app is ready, build a signed release bundle:
- In Android Studio: Build → Generate Signed Bundle / APK
- Choose Android App Bundle (preferred for Play Store)
- Create a new keystore (keep this file and password safe — losing it means you can never update your app)
- Build type: Release
The resulting .aab file is what you upload to the Play Store. AABs are smaller than APKs because Google Play optimizes the download for each device type.
What Comes Next
You now have a working Android app. The natural next steps:
Add a backend — Firebase Realtime Database or Firestore for syncing data across devices. Firebase integrates with Android through straightforward Gradle dependencies.
Improve the UI — Material Design 3 has a rich component library. Explore LazyColumn with swipe-to-delete, animated transitions between states, and custom themes.
Write tests — Android testing uses JUnit for unit tests and Espresso or Compose testing APIs for UI tests. A ViewModel is easy to unit test because it has no Android dependencies.
Notifications — WorkManager for scheduled local notifications, Firebase Cloud Messaging for push notifications from a server.
If you're interested in the cross-platform angle before committing fully to native Android, check the comparison of React Native vs Flutter and the broader cross-platform framework comparison. For TypeScript fundamentals that apply equally to React Native, the TypeScript cheatsheet is worth bookmarking.
💬 DiscussionPowered by GitHub Discussions
Frequently Asked Questions
AiTechWorlds Team
✓ Verified WriterThe AiTechWorlds team is passionate about AI, technology, and education. We create high-quality, research-backed content to help you learn, grow, and succeed in the modern digital world.
Related Articles
Cross-Platform App Development: Best Frameworks Compared 2026
Compare React Native, Flutter, Kotlin Multiplatform, and .NET MAUI for cross-platform app development in 2026. Real performance data, use cases, and honest tradeoffs.
How to Build a Mobile App in 2026: Complete Beginner's Guide
Learn how to build a mobile app in 2026 from scratch — choosing the right tech stack, designing your first screens, and shipping to the App Store or Play Store.
iOS App Development for Beginners: Swift and Xcode Complete Guide
Learn iOS app development from scratch with Swift and SwiftUI. Set up Xcode, build your first app, and submit to the App Store — a complete beginners guide for 2026.
React Native vs Flutter: Which to Choose for Your App in 2026
React Native vs Flutter in 2026 — an honest comparison of performance, DX, hiring, and real-world use cases to help you pick the right framework for your app.