Task module

This commit is contained in:
Marek Lenczewski
2026-04-12 10:06:17 +02:00
parent efe0cfe361
commit 27b34eb90f
39 changed files with 2454 additions and 41 deletions

View File

@@ -2,6 +2,7 @@ plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}
android {
@@ -49,5 +50,11 @@ dependencies {
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.retrofit)
implementation(libs.retrofit.kotlinx.serialization.converter)
implementation(libs.okhttp.logging.interceptor)
implementation(libs.kotlinx.serialization.json)
}

View File

@@ -1,10 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="@string/app_name"
android:icon="@android:drawable/sym_def_app_icon"
android:theme="@style/Theme.Haushalt">
android:theme="@style/Theme.Haushalt"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true">

View File

@@ -16,10 +16,36 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import de.haushalt.app.ui.task.TaskAllScreen
import de.haushalt.app.ui.task.TaskCreateScreen
import de.haushalt.app.ui.task.TaskEditScreen
import de.haushalt.app.ui.task.TaskScreen
private val trailMap: Map<String, List<Pair<String, String>>> = mapOf(
"start" to listOf("start" to "Haushalt"),
"tasks" to listOf("start" to "Haushalt", "tasks" to "Tasks"),
"tasks/all" to listOf(
"start" to "Haushalt",
"tasks" to "Tasks",
"tasks/all" to "All",
),
"tasks/create" to listOf(
"start" to "Haushalt",
"tasks" to "Tasks",
"tasks/create" to "Create",
),
"tasks/{id}" to listOf(
"start" to "Haushalt",
"tasks" to "Tasks",
"tasks/{id}" to "Edit",
),
)
@Composable
fun MainScreen() {
@@ -35,17 +61,33 @@ fun MainScreen() {
startDestination = "start",
modifier = Modifier.padding(innerPadding)
) {
composable("start") { StartScreen() }
composable("start") {
StartScreen(onOpenTasks = { navController.navigate("tasks") })
}
composable("tasks") {
TaskScreen(navController = navController)
}
composable("tasks/all") {
TaskAllScreen(navController = navController)
}
composable("tasks/create") {
TaskCreateScreen(navController = navController)
}
composable(
route = "tasks/{id}",
arguments = listOf(navArgument("id") { type = NavType.IntType })
) { entry ->
val id = entry.arguments?.getInt("id") ?: return@composable
TaskEditScreen(navController = navController, taskId = id)
}
}
}
}
@Composable
private fun Breadcrumb(currentRoute: String?, navController: NavController) {
// Trail: (route, label). Erweitert sich pro neuer Destination in späteren Modulen.
val trail = listOf(
"start" to "Haushalt",
)
val trail = trailMap[currentRoute] ?: listOf("start" to "Haushalt")
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceContainer

View File

@@ -1,7 +1,44 @@
package de.haushalt.app
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.material3.Card
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 StartScreen() {
fun StartScreen(onOpenTasks: () -> Unit = {}) {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 160.dp),
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
) {
item {
Card(
onClick = onOpenTasks,
modifier = Modifier
.padding(8.dp)
.aspectRatio(1f),
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
text = "Tasks",
style = MaterialTheme.typography.titleLarge,
)
}
}
}
}
}

View File

@@ -0,0 +1,30 @@
package de.haushalt.app.data
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
object ApiClient {
private const val BASE_URL = "http://192.168.178.34:8080/api/"
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
}
private val okHttp: OkHttpClient = OkHttpClient.Builder()
.addInterceptor(
HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC }
)
.build()
val taskApi: TaskApi = Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttp)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(TaskApi::class.java)
}

View File

@@ -0,0 +1,26 @@
package de.haushalt.app.data
import kotlinx.serialization.Serializable
@Serializable
enum class TaskStatus {
active,
done,
inactive,
past,
}
@Serializable
data class Task(
val id: Int? = null,
val name: String,
val date: String? = null,
val status: TaskStatus = TaskStatus.active,
)
@Serializable
data class TaskRequest(
val name: String,
val date: String? = null,
val status: TaskStatus = TaskStatus.active,
)

View File

@@ -0,0 +1,33 @@
package de.haushalt.app.data
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query
interface TaskApi {
@GET("tasks")
suspend fun list(@Query("filter") filter: String? = null): List<Task>
@GET("tasks/{id}")
suspend fun get(@Path("id") id: Int): Task
@POST("tasks")
suspend fun create(@Body body: TaskRequest): Task
@PUT("tasks/{id}")
suspend fun update(@Path("id") id: Int, @Body body: TaskRequest): Task
@DELETE("tasks/{id}")
suspend fun delete(@Path("id") id: Int)
@PATCH("tasks/{id}/toggle")
suspend fun toggle(@Path("id") id: Int): Task
@GET("tasks/statuses")
suspend fun statuses(): List<TaskStatus>
}

View File

@@ -0,0 +1,12 @@
package de.haushalt.app.ui.task
import java.time.LocalDate
import java.time.format.DateTimeFormatter
private val germanFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
fun formatDate(iso: String): String = try {
LocalDate.parse(iso.take(10)).format(germanFormatter)
} catch (e: Exception) {
iso
}

View File

@@ -0,0 +1,97 @@
package de.haushalt.app.ui.task
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DatePickerField(
value: String,
onChange: (String) -> Unit,
label: String = "Datum",
modifier: Modifier = Modifier,
) {
var showDialog by remember { mutableStateOf(false) }
val display = if (value.isBlank()) "" else try {
formatDate(value)
} catch (e: Exception) {
value
}
Box(
modifier = modifier
.fillMaxWidth()
.clickable { showDialog = true }
) {
OutlinedTextField(
value = display,
onValueChange = {},
enabled = false,
readOnly = true,
label = { Text(label) },
placeholder = { Text("tt.mm.jjjj") },
modifier = Modifier.fillMaxWidth(),
colors = OutlinedTextFieldDefaults.colors(
disabledTextColor = MaterialTheme.colorScheme.onSurface,
disabledBorderColor = MaterialTheme.colorScheme.outline,
disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
disabledPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
if (showDialog) {
val initialMillis = value.takeIf { it.isNotBlank() }?.let {
try {
LocalDate.parse(it.take(10))
.atStartOfDay(ZoneOffset.UTC)
.toInstant()
.toEpochMilli()
} catch (e: Exception) {
null
}
}
val state = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
DatePickerDialog(
onDismissRequest = { showDialog = false },
confirmButton = {
TextButton(onClick = {
state.selectedDateMillis?.let { millis ->
val date = Instant.ofEpochMilli(millis)
.atZone(ZoneOffset.UTC)
.toLocalDate()
onChange(date.format(DateTimeFormatter.ISO_LOCAL_DATE))
}
showDialog = false
}) { Text("OK") }
},
dismissButton = {
TextButton(onClick = { showDialog = false }) { Text("Abbrechen") }
},
) {
DatePicker(state = state)
}
}
}

View File

@@ -0,0 +1,57 @@
package de.haushalt.app.ui.task
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import de.haushalt.app.data.TaskStatus
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StatusDropdown(
current: TaskStatus,
selectable: List<TaskStatus>,
onChange: (TaskStatus) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
) {
OutlinedTextField(
value = current.name,
onValueChange = {},
readOnly = true,
label = { Text("Status") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.menuAnchor(MenuAnchorType.PrimaryNotEditable, enabled = true)
.fillMaxWidth(),
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
selectable.forEach { status ->
DropdownMenuItem(
text = { Text(status.name) },
onClick = {
onChange(status)
expanded = false
},
)
}
}
}
}

View File

@@ -0,0 +1,160 @@
package de.haushalt.app.ui.task
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import de.haushalt.app.data.Task
import de.haushalt.app.data.TaskStatus
@Composable
fun TaskAllScreen(
navController: NavController,
viewModel: TaskAllViewModel = viewModel(),
) {
var deleteCandidate by remember { mutableStateOf<Int?>(null) }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
viewModel.refresh()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
IconButton(onClick = { navController.navigate("tasks/create") }) {
Icon(Icons.Filled.Add, contentDescription = "Neuer Task")
}
IconButton(onClick = { viewModel.refresh() }) {
Icon(Icons.Filled.Refresh, contentDescription = "Neu laden")
}
}
Spacer(Modifier.padding(4.dp))
when {
viewModel.isLoading && viewModel.tasks.isEmpty() -> Text("Lädt…")
viewModel.error != null -> Text(
viewModel.error ?: "",
color = MaterialTheme.colorScheme.error
)
viewModel.tasks.isEmpty() -> Text("Keine Tasks.")
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
items(viewModel.tasks, key = { it.id ?: 0 }) { task ->
TaskAllRow(
task = task,
onClick = { task.id?.let(viewModel::toggle) },
onEdit = { task.id?.let { navController.navigate("tasks/$it") } },
onDelete = { deleteCandidate = task.id },
)
HorizontalDivider()
}
}
}
}
if (deleteCandidate != null) {
AlertDialog(
onDismissRequest = { deleteCandidate = null },
title = { Text("Task löschen?") },
text = { Text("Dieser Task wird dauerhaft entfernt.") },
confirmButton = {
TextButton(onClick = {
deleteCandidate?.let(viewModel::delete)
deleteCandidate = null
}) { Text("Löschen") }
},
dismissButton = {
TextButton(onClick = { deleteCandidate = null }) { Text("Abbrechen") }
},
)
}
}
@Composable
private fun TaskAllRow(
task: Task,
onClick: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.alpha(if (task.status == TaskStatus.past) 0.5f else 1f)
.clickable(onClick = onClick)
.padding(vertical = 8.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = task.name,
style = MaterialTheme.typography.bodyLarge,
textDecoration = if (task.status == TaskStatus.done) TextDecoration.LineThrough else null,
fontStyle = if (task.status == TaskStatus.inactive) FontStyle.Italic else null,
color = when (task.status) {
TaskStatus.active -> MaterialTheme.colorScheme.onSurface
TaskStatus.done -> MaterialTheme.colorScheme.onSurfaceVariant
TaskStatus.inactive -> MaterialTheme.colorScheme.onSurfaceVariant
TaskStatus.past -> MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.weight(1f),
)
task.date?.let {
Text(
text = formatDate(it),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(end = 8.dp),
)
}
IconButton(onClick = onEdit) {
Icon(Icons.Filled.Edit, contentDescription = "Bearbeiten")
}
IconButton(onClick = onDelete) {
Icon(Icons.Filled.Delete, contentDescription = "Löschen")
}
}
}

View File

@@ -0,0 +1,59 @@
package de.haushalt.app.ui.task
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.haushalt.app.data.ApiClient
import de.haushalt.app.data.Task
import kotlinx.coroutines.launch
class TaskAllViewModel : ViewModel() {
var tasks by mutableStateOf<List<Task>>(emptyList())
private set
var isLoading by mutableStateOf(false)
private set
var error by mutableStateOf<String?>(null)
private set
init {
refresh()
}
fun refresh() {
viewModelScope.launch {
isLoading = true
error = null
try {
tasks = ApiClient.taskApi.list()
} catch (e: Exception) {
error = e.message
} finally {
isLoading = false
}
}
}
fun toggle(id: Int) {
viewModelScope.launch {
try {
val updated = ApiClient.taskApi.toggle(id)
tasks = tasks.map { if (it.id == id) updated else it }
} catch (e: Exception) {
error = e.message
}
}
}
fun delete(id: Int) {
viewModelScope.launch {
try {
ApiClient.taskApi.delete(id)
tasks = tasks.filter { it.id != id }
} catch (e: Exception) {
error = e.message
}
}
}
}

View File

@@ -0,0 +1,66 @@
package de.haushalt.app.ui.task
import androidx.compose.foundation.layout.Arrangement
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.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
@Composable
fun TaskCreateScreen(
navController: NavController,
viewModel: TaskCreateViewModel = viewModel(),
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = viewModel.name,
onValueChange = { viewModel.name = it },
label = { Text("Name") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
DatePickerField(
value = viewModel.date,
onChange = { viewModel.date = it },
)
StatusDropdown(
current = viewModel.status,
selectable = viewModel.availableStatuses,
onChange = { viewModel.status = it },
)
viewModel.error?.let {
Text(it, color = MaterialTheme.colorScheme.error)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
enabled = !viewModel.isSubmitting && viewModel.name.isNotBlank(),
onClick = { viewModel.save { navController.popBackStack() } },
) {
Text("Speichern")
}
OutlinedButton(onClick = { navController.popBackStack() }) {
Text("Abbrechen")
}
}
}
}

View File

@@ -0,0 +1,54 @@
package de.haushalt.app.ui.task
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.haushalt.app.data.ApiClient
import de.haushalt.app.data.TaskRequest
import de.haushalt.app.data.TaskStatus
import kotlinx.coroutines.launch
class TaskCreateViewModel : ViewModel() {
var name by mutableStateOf("")
var date by mutableStateOf("")
var status by mutableStateOf(TaskStatus.active)
var availableStatuses by mutableStateOf<List<TaskStatus>>(emptyList())
private set
var isSubmitting by mutableStateOf(false)
private set
var error by mutableStateOf<String?>(null)
private set
init {
viewModelScope.launch {
try {
availableStatuses = ApiClient.taskApi.statuses()
} catch (e: Exception) {
availableStatuses = emptyList()
}
}
}
fun save(onSuccess: () -> Unit) {
viewModelScope.launch {
isSubmitting = true
error = null
try {
ApiClient.taskApi.create(
TaskRequest(
name = name,
date = date.ifBlank { null },
status = status,
)
)
onSuccess()
} catch (e: Exception) {
error = e.message
} finally {
isSubmitting = false
}
}
}
}

View File

@@ -0,0 +1,80 @@
package de.haushalt.app.ui.task
import androidx.compose.foundation.layout.Arrangement
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.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
@Composable
fun TaskEditScreen(
navController: NavController,
taskId: Int,
viewModel: TaskEditViewModel = viewModel(),
) {
LaunchedEffect(taskId) {
viewModel.load(taskId)
}
if (viewModel.isLoading) {
Text("Lädt…", modifier = Modifier.padding(16.dp))
return
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = viewModel.name,
onValueChange = { viewModel.name = it },
label = { Text("Name") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
DatePickerField(
value = viewModel.date,
onChange = { viewModel.date = it },
)
StatusDropdown(
current = viewModel.status,
selectable = viewModel.availableStatuses,
onChange = { viewModel.status = it },
)
viewModel.error?.let {
Text(it, color = MaterialTheme.colorScheme.error)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
enabled = !viewModel.isSubmitting && viewModel.name.isNotBlank(),
onClick = { viewModel.update(taskId) { navController.popBackStack() } },
) {
Text("Aktualisieren")
}
OutlinedButton(onClick = { viewModel.reset() }) {
Text("Zurücksetzen")
}
OutlinedButton(onClick = { navController.popBackStack() }) {
Text("Abbrechen")
}
}
}
}

View File

@@ -0,0 +1,86 @@
package de.haushalt.app.ui.task
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.haushalt.app.data.ApiClient
import de.haushalt.app.data.Task
import de.haushalt.app.data.TaskRequest
import de.haushalt.app.data.TaskStatus
import kotlinx.coroutines.launch
class TaskEditViewModel : ViewModel() {
private var original: Task? = null
var name by mutableStateOf("")
var date by mutableStateOf("")
var status by mutableStateOf(TaskStatus.active)
var availableStatuses by mutableStateOf<List<TaskStatus>>(emptyList())
private set
var isLoading by mutableStateOf(false)
private set
var isSubmitting by mutableStateOf(false)
private set
var error by mutableStateOf<String?>(null)
private set
init {
viewModelScope.launch {
try {
availableStatuses = ApiClient.taskApi.statuses()
} catch (e: Exception) {
availableStatuses = emptyList()
}
}
}
fun load(id: Int) {
viewModelScope.launch {
isLoading = true
error = null
try {
val task = ApiClient.taskApi.get(id)
original = task
applyTask(task)
} catch (e: Exception) {
error = e.message
} finally {
isLoading = false
}
}
}
fun update(id: Int, onSuccess: () -> Unit) {
viewModelScope.launch {
isSubmitting = true
error = null
try {
ApiClient.taskApi.update(
id,
TaskRequest(
name = name,
date = date.ifBlank { null },
status = status,
)
)
onSuccess()
} catch (e: Exception) {
error = e.message
} finally {
isSubmitting = false
}
}
}
fun reset() {
original?.let { applyTask(it) }
}
private fun applyTask(task: Task) {
name = task.name
date = task.date?.take(10) ?: ""
status = task.status
}
}

View File

@@ -0,0 +1,60 @@
package de.haushalt.app.ui.task
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.haushalt.app.data.ApiClient
import de.haushalt.app.data.Task
import de.haushalt.app.data.TaskStatus
import kotlinx.coroutines.launch
class TaskListViewModel : ViewModel() {
var tasks by mutableStateOf<List<Task>>(emptyList())
private set
var showDone by mutableStateOf(false)
var isLoading by mutableStateOf(false)
private set
var error by mutableStateOf<String?>(null)
private set
val visibleTasks: List<Task>
get() = if (showDone) tasks else tasks.filter { it.status != TaskStatus.done }
val groupedTasks: Map<String, List<Task>>
get() = visibleTasks.groupBy { task -> task.date?.let { formatDate(it) } ?: "" }
init {
refresh()
}
fun refresh() {
viewModelScope.launch {
isLoading = true
error = null
try {
tasks = ApiClient.taskApi.list("current")
} catch (e: Exception) {
error = e.message
} finally {
isLoading = false
}
}
}
fun toggle(id: Int) {
viewModelScope.launch {
try {
val updated = ApiClient.taskApi.toggle(id)
tasks = tasks.map { if (it.id == id) updated else it }
} catch (e: Exception) {
error = e.message
}
}
}
fun toggleShowDone() {
showDone = !showDone
}
}

View File

@@ -0,0 +1,163 @@
package de.haushalt.app.ui.task
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import de.haushalt.app.data.Task
import de.haushalt.app.data.TaskStatus
@Composable
fun TaskScreen(
navController: NavController,
viewModel: TaskListViewModel = viewModel(),
) {
val tasks = viewModel.visibleTasks
val showDone by androidx.compose.runtime.rememberUpdatedState(viewModel.showDone)
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
viewModel.refresh()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
IconButton(onClick = { navController.navigate("tasks/create") }) {
Icon(Icons.Filled.Add, contentDescription = "Neuer Task")
}
IconButton(onClick = { navController.navigate("tasks/all") }) {
Icon(Icons.AutoMirrored.Filled.List, contentDescription = "Alle Tasks")
}
IconButton(onClick = { viewModel.toggleShowDone() }) {
Icon(
imageVector = if (showDone) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
contentDescription = "Erledigte ein-/ausblenden",
)
}
IconButton(onClick = { viewModel.refresh() }) {
Icon(Icons.Filled.Refresh, contentDescription = "Neu laden")
}
}
Spacer(Modifier.padding(4.dp))
when {
viewModel.isLoading && tasks.isEmpty() -> Text("Lädt…")
viewModel.error != null -> Text(viewModel.error ?: "", color = MaterialTheme.colorScheme.error)
tasks.isEmpty() -> Text("Keine Tasks.")
else -> LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(top = 12.dp, bottom = 12.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
viewModel.groupedTasks.forEach { (key, groupTasks) ->
item(key = key.ifEmpty { "no-date" }) {
TaskGroupCard(
title = key.ifEmpty { null },
tasks = groupTasks,
onToggle = viewModel::toggle,
)
}
}
}
}
}
}
@Composable
private fun TaskRow(task: Task, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 12.dp, horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = task.name,
style = MaterialTheme.typography.bodyLarge,
textDecoration = if (task.status == TaskStatus.done) TextDecoration.LineThrough else null,
color = if (task.status == TaskStatus.done) MaterialTheme.colorScheme.onSurfaceVariant
else MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
}
}
@Composable
private fun TaskGroupCard(
title: String?,
tasks: List<Task>,
onToggle: (Int) -> Unit,
) {
Box(modifier = Modifier.fillMaxWidth()) {
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column {
tasks.forEachIndexed { index, task ->
TaskRow(task = task, onClick = { task.id?.let(onToggle) })
if (index < tasks.lastIndex) HorizontalDivider()
}
}
}
if (title != null) {
Surface(
modifier = Modifier
.align(Alignment.TopCenter)
.offset(y = (-10).dp),
shape = RoundedCornerShape(6.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
color = MaterialTheme.colorScheme.background,
) {
Text(
text = title,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}

View File

@@ -6,6 +6,10 @@ lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.9.3"
composeBom = "2024.12.01"
navigationCompose = "2.8.5"
retrofit = "2.11.0"
okhttp = "4.12.0"
kotlinxSerialization = "1.7.3"
lifecycleViewModelCompose = "2.8.7"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -16,9 +20,16 @@ androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewModelCompose" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-kotlinx-serialization-converter = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" }
okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }