This commit is contained in:
2026-02-26 20:49:21 -05:00
parent bebcf48a32
commit 4d9e715361
243 changed files with 25648 additions and 14573 deletions
+45
View File
@@ -0,0 +1,45 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.compose.compiler)
}
android {
namespace = "com.example.todokmp.android"
compileSdk = 35
defaultConfig {
applicationId = "com.example.todokmp.android"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0"
}
buildFeatures {
compose = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation(project(":shared"))
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
implementation(libs.compose.material.icons)
implementation(libs.activity.compose)
implementation(libs.lifecycle.viewmodel.compose)
implementation(libs.lifecycle.runtime.compose)
implementation(libs.coroutines.android)
debugImplementation(libs.compose.ui.tooling)
}
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:label="Todo KMP"
android:supportsRtl="true"
android:theme="@style/Theme.TodoKmp">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.TodoKmp">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,26 @@
package com.example.todokmp.android
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.example.todokmp.android.ui.TodoApp
import com.example.todokmp.android.ui.theme.TodoKmpTheme
import com.example.todokmp.db.DatabaseDriverFactory
import com.example.todokmp.db.TodoRepository
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val repository = TodoRepository(DatabaseDriverFactory(applicationContext))
val viewModel = TodoViewModel(repository)
setContent {
TodoKmpTheme {
TodoApp(viewModel = viewModel)
}
}
}
}
@@ -0,0 +1,85 @@
package com.example.todokmp.android
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.todokmp.db.TodoEntity
import com.example.todokmp.db.TodoRepository
import com.example.todokmp.model.Priority
import com.example.todokmp.model.TodoFilter
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
class TodoViewModel(private val repository: TodoRepository) : ViewModel() {
private val _filter = MutableStateFlow(TodoFilter.ALL)
val filter: StateFlow<TodoFilter> = _filter.asStateFlow()
private val _showForm = MutableStateFlow(false)
val showForm: StateFlow<Boolean> = _showForm.asStateFlow()
private val _editingTodo = MutableStateFlow<TodoEntity?>(null)
val editingTodo: StateFlow<TodoEntity?> = _editingTodo.asStateFlow()
private val todos: Flow<List<TodoEntity>> = repository.getAllTodos()
val filteredTodos: StateFlow<List<TodoEntity>> = combine(todos, _filter) { list, f ->
when (f) {
TodoFilter.ALL -> list
TodoFilter.ACTIVE -> list.filter { !it.completed }
TodoFilter.DONE -> list.filter { it.completed }
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val counts: StateFlow<Map<TodoFilter, Int>> = todos.map { list ->
mapOf(
TodoFilter.ALL to list.size,
TodoFilter.ACTIVE to list.count { !it.completed },
TodoFilter.DONE to list.count { it.completed }
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
fun setFilter(filter: TodoFilter) {
_filter.value = filter
}
fun showAddForm() {
_editingTodo.value = null
_showForm.value = true
}
fun showEditForm(todo: TodoEntity) {
_editingTodo.value = todo
_showForm.value = true
}
fun dismissForm() {
_showForm.value = false
_editingTodo.value = null
}
fun addTodo(title: String, category: String, priority: Priority) {
viewModelScope.launch {
repository.addTodo(title, category, priority)
}
dismissForm()
}
fun updateTodo(id: Long, title: String, category: String, priority: Priority) {
viewModelScope.launch {
repository.updateTodo(id, title, category, priority)
}
dismissForm()
}
fun toggleTodo(todo: TodoEntity) {
viewModelScope.launch {
repository.toggleTodo(todo.id, !todo.completed)
}
}
fun deleteTodo(todo: TodoEntity) {
viewModelScope.launch {
repository.deleteTodo(todo.id)
}
}
}
@@ -0,0 +1,33 @@
package com.example.todokmp.android.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.todokmp.model.categories
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CategoryPicker(
selected: String,
onCategorySelected: (String) -> Unit,
modifier: Modifier = Modifier
) {
FlowRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
categories.forEach { category ->
FilterChip(
selected = selected == category,
onClick = { onCategorySelected(category) },
label = { Text(category) }
)
}
}
}
@@ -0,0 +1,40 @@
package com.example.todokmp.android.ui
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material3.Icon
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.unit.dp
@Composable
fun EmptyState(modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Outlined.CheckCircle,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "No todos yet",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Tap + to add your first todo",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
)
}
}
@@ -0,0 +1,39 @@
package com.example.todokmp.android.ui
import androidx.compose.foundation.layout.*
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.todokmp.model.TodoFilter
@Composable
fun FilterBar(
currentFilter: TodoFilter,
counts: Map<TodoFilter, Int>,
onFilterSelected: (TodoFilter) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
TodoFilter.entries.forEach { filter ->
val count = counts[filter] ?: 0
FilterChip(
selected = currentFilter == filter,
onClick = { onFilterSelected(filter) },
label = {
Text(
text = "${filter.label} ($count)",
style = MaterialTheme.typography.labelLarge
)
}
)
}
}
}
@@ -0,0 +1,47 @@
package com.example.todokmp.android.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.example.todokmp.model.Priority
private fun priorityChipColor(priority: Priority): Color = when (priority) {
Priority.HIGH -> Color(0xFFE53935)
Priority.MEDIUM -> Color(0xFFFB8C00)
Priority.LOW -> Color(0xFF43A047)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun PriorityPicker(
selected: Priority,
onPrioritySelected: (Priority) -> Unit,
modifier: Modifier = Modifier
) {
FlowRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Priority.entries.forEach { priority ->
val color = priorityChipColor(priority)
FilterChip(
selected = selected == priority,
onClick = { onPrioritySelected(priority) },
label = { Text(priority.label) },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = color.copy(alpha = 0.2f),
selectedLabelColor = color
)
)
}
}
}
@@ -0,0 +1,72 @@
package com.example.todokmp.android.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.example.todokmp.android.TodoViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TodoApp(viewModel: TodoViewModel) {
val filteredTodos by viewModel.filteredTodos.collectAsState()
val currentFilter by viewModel.filter.collectAsState()
val counts by viewModel.counts.collectAsState()
val showForm by viewModel.showForm.collectAsState()
val editingTodo by viewModel.editingTodo.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Todos Kotlin KMP") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary
)
)
},
floatingActionButton = {
FloatingActionButton(onClick = { viewModel.showAddForm() }) {
Icon(Icons.Default.Add, contentDescription = "Add todo")
}
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
) {
FilterBar(
currentFilter = currentFilter,
counts = counts,
onFilterSelected = { viewModel.setFilter(it) }
)
TodoList(
todos = filteredTodos,
onToggle = { viewModel.toggleTodo(it) },
onEdit = { viewModel.showEditForm(it) },
onDelete = { viewModel.deleteTodo(it) },
modifier = Modifier.weight(1f)
)
}
}
if (showForm) {
TodoFormDialog(
editingTodo = editingTodo,
onDismiss = { viewModel.dismissForm() },
onSave = { title, category, priority ->
val editing = editingTodo
if (editing != null) {
viewModel.updateTodo(editing.id, title, category, priority)
} else {
viewModel.addTodo(title, category, priority)
}
}
)
}
}
@@ -0,0 +1,71 @@
package com.example.todokmp.android.ui
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.todokmp.db.TodoEntity
import com.example.todokmp.model.Priority
@Composable
fun TodoFormDialog(
editingTodo: TodoEntity?,
onDismiss: () -> Unit,
onSave: (title: String, category: String, priority: Priority) -> Unit
) {
var title by remember(editingTodo) {
mutableStateOf(editingTodo?.title ?: "")
}
var category by remember(editingTodo) {
mutableStateOf(editingTodo?.category ?: "Personal")
}
var priority by remember(editingTodo) {
mutableStateOf(
editingTodo?.let { Priority.fromString(it.priority) } ?: Priority.MEDIUM
)
}
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(if (editingTodo != null) "Edit Todo" else "Add Todo")
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text("Title") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Text("Category", style = MaterialTheme.typography.labelLarge)
CategoryPicker(
selected = category,
onCategorySelected = { category = it }
)
Text("Priority", style = MaterialTheme.typography.labelLarge)
PriorityPicker(
selected = priority,
onPrioritySelected = { priority = it }
)
}
},
confirmButton = {
TextButton(
onClick = { onSave(title.trim(), category, priority) },
enabled = title.isNotBlank()
) {
Text("Save")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
@@ -0,0 +1,90 @@
package com.example.todokmp.android.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.example.todokmp.db.TodoEntity
import com.example.todokmp.model.Priority
private fun priorityColor(priority: String): Color = when (Priority.fromString(priority)) {
Priority.HIGH -> Color(0xFFE53935)
Priority.MEDIUM -> Color(0xFFFB8C00)
Priority.LOW -> Color(0xFF43A047)
}
@Composable
fun TodoItem(
todo: TodoEntity,
onToggle: () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
onClick = onClick,
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = todo.completed,
onCheckedChange = { onToggle() }
)
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = todo.title,
style = MaterialTheme.typography.bodyLarge,
textDecoration = if (todo.completed) TextDecoration.LineThrough else null,
color = if (todo.completed) {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
} else {
MaterialTheme.colorScheme.onSurface
},
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
SuggestionChip(
onClick = { },
label = {
Text(
text = todo.category,
style = MaterialTheme.typography.labelSmall
)
},
modifier = Modifier.height(24.dp)
)
}
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(priorityColor(todo.priority))
)
}
}
}
@@ -0,0 +1,67 @@
package com.example.todokmp.android.ui
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.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.example.todokmp.db.TodoEntity
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TodoList(
todos: List<TodoEntity>,
onToggle: (TodoEntity) -> Unit,
onEdit: (TodoEntity) -> Unit,
onDelete: (TodoEntity) -> Unit,
modifier: Modifier = Modifier
) {
if (todos.isEmpty()) {
EmptyState(modifier = modifier)
} else {
LazyColumn(modifier = modifier.fillMaxSize()) {
items(items = todos, key = { it.id }) { todo ->
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { value ->
if (value == SwipeToDismissBoxValue.EndToStart) {
onDelete(todo)
true
} else {
false
}
}
)
SwipeToDismissBox(
state = dismissState,
backgroundContent = {
SwipeToDismissBackground(color = MaterialTheme.colorScheme.error)
},
enableDismissFromStartToEnd = false,
enableDismissFromEndToStart = true
) {
TodoItem(
todo = todo,
onToggle = { onToggle(todo) },
onClick = { onEdit(todo) }
)
}
}
}
}
}
@Composable
private fun SwipeToDismissBackground(color: Color) {
Surface(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 4.dp),
color = color,
shape = CardDefaults.shape
) {}
}
@@ -0,0 +1,27 @@
package com.example.todokmp.android.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val LightColorScheme = lightColorScheme(
primary = Color(0xFF6200EE),
onPrimary = Color.White,
primaryContainer = Color(0xFFE8DEF8),
onPrimaryContainer = Color(0xFF21005D),
secondary = Color(0xFF03DAC6),
onSecondary = Color.Black,
surface = Color.White,
onSurface = Color(0xFF1C1B1F),
surfaceVariant = Color(0xFFF5F5F5),
error = Color(0xFFB00020),
)
@Composable
fun TodoKmpTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = LightColorScheme,
content = content
)
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.TodoKmp" parent="android:Theme.Material.Light.NoActionBar" />
</resources>