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

@@ -14,6 +14,8 @@ composer_version: "2"
composer_root: backend
web_environment: []
corepack_enable: false
host_webserver_port: "8080"
bind_all_interfaces: true
web_extra_exposed_ports:
- name: vite

View File

@@ -2,7 +2,7 @@
Basis-Software mit 3 geplanten Apps: Task Manager, Shopping List, Meal Planner.
Aktueller Stand: **Setup module** (siehe `module.md`) — minimales Symfony + Vue Gerüst, kein Feature-Code.
Aktueller Stand: **Task module** implementiert (siehe `module.md`) — Backend + Vue + Kotlin App jeweils end-to-end mit Task-CRUD, Status-Handling und Datum.
## Tech-Stack
@@ -10,7 +10,7 @@ Aktueller Stand: **Setup module** (siehe `module.md`) — minimales Symfony + Vu
|---------|------------|
| Backend | Symfony 7.4, PHP 8.3, Doctrine ORM |
| Frontend Web | Vue 3 (Composition API), Vite, Pinia, Vue Router 4 |
| Frontend Mobile | Kotlin + Jetpack Compose (noch nicht aufgesetzt) |
| Frontend Mobile | Kotlin + Jetpack Compose (Material 3), Retrofit + kotlinx.serialization |
| Datenbank | MariaDB 10.11 (utf8mb4) |
| CORS | Nelmio CORS Bundle |
| Umgebung | DDEV |
@@ -20,19 +20,85 @@ Aktueller Stand: **Setup module** (siehe `module.md`) — minimales Symfony + Vu
```
backend/
src/
Kernel.php — Standard-Symfony-Kernel, keine App-Klassen
config/ — Symfony-Config (nelmio_cors, doctrine, framework, ...)
migrations/ — leer
public/index.php — Symfony-Einstieg
Collection/TaskCollection.php — IteratorAggregate, filterInactive/sortByDueDate*
Controller/Api/TaskController.php — REST-Routen unter /api/tasks
DTO/TaskRequest.php — readonly promoted constructor, Validator + Context(!Y-m-d)
Entity/Task.php — Doctrine entity, getStatus() derived-past
Enum/TaskStatus.php — active, done, inactive, past (past nicht user-selectable)
Repository/TaskRepository.php — currentTasks(), allTasks() → TaskCollection
Service/TaskManager.php — create/update/delete/toggle Business-Logik
Kernel.php
config/ — nelmio_cors, doctrine, framework, ...
migrations/Version20260411141650.php — tasks table
public/index.php
frontend/
src/
main.js — Vue-Init mit Pinia + Router
App.vue — RouterView, kein Content
router/index.js — Router mit leerem routes-Array
style.css — leer
index.html, vite.config.js
components/Icon.vue — zentrales Icon-Component
router/index.js — /, /tasks, /tasks/all, /tasks/create, /tasks/:id mit breadcrumb meta
services/api.js — fetch wrapper, tasks() + statuses() Endpoints
stores/tasks.js — Pinia store: fetchCurrent, fetchAll, get, create, update, remove, toggle, fetchStatuses
views/
Startpage.vue — Nav-Kacheln
Task.vue — /tasks (aktuelle Tasks), Card-pro-Datum mit Legend-Titel
TaskAll.vue — /tasks/all (alle Tasks, flache Liste mit Edit/Delete)
TaskCreate.vue — /tasks/create
TaskEdit.vue — /tasks/:id
App.vue — Breadcrumb-Layout + RouterView
main.js — Vue-Init (Pinia + Router)
app/app/src/main/java/de/haushalt/app/
MainActivity.kt — Compose entry
MainScreen.kt — NavHost (start, tasks, tasks/all, tasks/create, tasks/{id})
StartScreen.kt — Nav-Kacheln
data/
ApiClient.kt — Retrofit + kotlinx.serialization Setup
TaskApi.kt — Retrofit interface (list, get, create, update, delete, toggle, statuses)
Task.kt — Task + TaskStatus enum
ui/task/
TaskScreen.kt — /tasks, gleiches Card-Gruppierungs-Muster wie Vue
TaskAllScreen.kt — /tasks/all flache Liste, Edit/Delete
TaskCreateScreen.kt / TaskCreateViewModel.kt
TaskEditScreen.kt / TaskEditViewModel.kt
TaskListViewModel.kt — visibleTasks, groupedTasks, showDone, refresh, toggle
TaskAllViewModel.kt
DatePickerField.kt — Material 3 DatePickerDialog Wrapper
StatusDropdown.kt — ExposedDropdownMenuBox, Status-Labels vom Backend
DateFormat.kt — formatDate() mit dd.MM.yyyy
```
## Domänenmodell
**Task**
- `id`, `name`, `date` (nullable, ISO), `status`
- Status-Enum (`TaskStatus`): `active`, `done`, `inactive`, `past`
- `past` ist **derived** — wenn `date < today`, liefert `Task::getStatus()` automatisch `past`; der rohe gespeicherte Wert bleibt erhalten (`getRawStatus()`)
- `past` ist **nicht user-selectable** (siehe `TaskStatus::userSelectableValues()`)
- Filter `/api/tasks?filter=current` → Tasks ohne `inactive`, sortiert nach `date` aufsteigend (null-first)
- Default (`/api/tasks`) → alle Tasks, sortiert nach `date` absteigend
## REST-API
| Methode | Route | Zweck |
|---|---|---|
| GET | `/api/tasks?filter=current` | aktuelle Tasks (für `/tasks`) |
| GET | `/api/tasks` | alle Tasks (für `/tasks/all`) |
| GET | `/api/tasks/statuses` | user-selectable Statuswerte als `string[]` |
| GET | `/api/tasks/{id}` | einzelner Task |
| POST | `/api/tasks` | Task anlegen |
| PUT | `/api/tasks/{id}` | Task aktualisieren |
| DELETE | `/api/tasks/{id}` | Task löschen |
| PATCH | `/api/tasks/{id}/toggle` | Status zwischen `active`/`done` togglen |
Request-DTO: `TaskRequest` (name, date `!Y-m-d`, status). Deserialisiert + validiert via `#[MapRequestPayload]`.
Response-Serialisierung: Symfony Serializer mit `groups: ['task:read']`, Datum als ISO `Y-m-d` String.
## UI-Muster
- **`/tasks`** (Vue + Kotlin): Tasks nach Datum gruppiert in Cards. Datum sitzt als Pill auf dem Top-Border der Card (fieldset/legend-Look). No-Date-Tasks oben in titelloser Card. `showDone` default `false`.
- **`/tasks/all`** (Vue + Kotlin): Flache Liste mit Edit- und Delete-Icons. `past`-Tasks mit Opacity 0.5, `inactive` kursiv, `done` durchgestrichen.
- **Icon-Reihenfolge `/tasks` Header**: plus, list, eye, refresh.
- Kotlin-Screens refreshen auf `Lifecycle.Event.ON_RESUME` via `DisposableEffect` (gegen stale Daten nach Navigation).
- Status-Labels in Create/Edit kommen aus dem Backend (`/api/tasks/statuses`) — nicht clientseitig hardcoded.
## Dokumentation
- **`base.md`** — Vision: was gebaut wird (3 Apps, Systeme, Datenbank-Skizze)
@@ -44,6 +110,8 @@ frontend/
- **Sprache Code**: Englisch (Klassen, Methoden, Variablen)
- **Sprache UI**: Deutsch
- **Enum-Werte**: Englisch in DB und Code
- **Datum im Backend**: ISO `Y-m-d` (als DTO-Input und in Response-JSON)
- **Datum im UI**: `dd.MM.yyyy` — Formatierung in Vue (`formatDate` in `Task.vue`) und Kotlin (`DateFormat.kt`)
- **Frontend**: Vue 3 Composition API mit `<script setup>`
## Development
@@ -54,10 +122,15 @@ ddev start
# Backend
ddev exec "cd backend && php bin/console cache:clear"
ddev exec "cd backend && php bin/console doctrine:migrations:migrate"
# Frontend
ddev exec "cd frontend && npm install"
ddev exec "cd frontend && npm run dev -- --host 0.0.0.0"
ddev exec "cd frontend && npm run build"
# Android App (Java 17 für Gradle)
cd app && JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 ./gradlew :app:installDebug
# URLs
Frontend: https://haushalt.ddev.site:5173

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" }

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260411141650 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
$table = $schema->createTable('task');
$table->addColumn('id', 'integer', ['autoincrement' => true, 'notnull' => true]);
$table->addColumn('name', 'string', ['length' => 255, 'notnull' => true]);
$table->addColumn('date', 'date', ['notnull' => false]);
$table->addColumn('status', 'string', ['length' => 255, 'notnull' => true]);
$table->setPrimaryKey(['id']);
$table->addOption('charset', 'utf8mb4');
}
public function down(Schema $schema): void
{
$schema->dropTable('task');
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Collection;
use App\Entity\Task;
use App\Enum\TaskStatus;
/**
* @implements \IteratorAggregate<int, Task>
*/
final class TaskCollection implements \IteratorAggregate
{
/** @var list<Task> */
private array $tasks = [];
/**
* @param list<Task> $tasks
*/
public function __construct(array $tasks = [])
{
foreach ($tasks as $task) {
$this->add($task);
}
}
private function add(Task $task): void
{
$this->tasks[] = $task;
}
public function getIterator(): \Iterator
{
return new \ArrayIterator($this->tasks);
}
public function filterInactive(): self
{
$this->tasks = [...array_filter(
$this->tasks,
fn (Task $t) => $t->getStatus() !== TaskStatus::Inactive
)];
return $this;
}
public function sortByDueDate(): self
{
usort($this->tasks, fn (Task $a, Task $b) => $a->getDate() <=> $b->getDate());
return $this;
}
public function sortByDueDateDesc(): self
{
usort($this->tasks, fn (Task $a, Task $b) =>
$a->getDate() === null || $b->getDate() === null
? $a->getDate() <=> $b->getDate()
: $b->getDate() <=> $a->getDate()
);
return $this;
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Controller\Api;
use App\DTO\TaskRequest;
use App\Entity\Task;
use App\Enum\TaskStatus;
use App\Repository\TaskRepository;
use App\Service\TaskManager;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload as Payload;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/tasks')]
class TaskController extends AbstractController
{
public function __construct(
private readonly TaskRepository $repo,
private readonly TaskManager $manager,
) {
}
#[Route('', methods: ['GET'])]
public function index(Request $req): JsonResponse
{
$tasks = match ($req->query->get('filter')) {
'current' => $this->repo->currentTasks()->filterInactive()->sortByDueDate(),
default => $this->repo->allTasks()->sortByDueDateDesc(),
};
return $this->json($tasks, 200, [], ['groups' => ['task:read']]);
}
#[Route('/statuses', methods: ['GET'])]
public function statuses(): JsonResponse
{
return $this->json(TaskStatus::userSelectableValues());
}
#[Route('/{id}', methods: ['GET'], requirements: ['id' => '\d+'])]
public function show(Task $task): JsonResponse
{
return $this->json($task, 200, [], ['groups' => ['task:read']]);
}
#[Route('', methods: ['POST'])]
public function create(#[Payload] TaskRequest $dto): JsonResponse
{
$task = $this->manager->create($dto);
return $this->json($task, 201, [], ['groups' => ['task:read']]);
}
#[Route('/{id}', methods: ['PUT'], requirements: ['id' => '\d+'])]
public function update(Task $task, #[Payload] TaskRequest $dto): JsonResponse
{
$task = $this->manager->update($task, $dto);
return $this->json($task, 200, [], ['groups' => ['task:read']]);
}
#[Route('/{id}', methods: ['DELETE'], requirements: ['id' => '\d+'])]
public function delete(Task $task): JsonResponse
{
$this->manager->delete($task);
return new JsonResponse(null, 204);
}
#[Route('/{id}/toggle', methods: ['PATCH'], requirements: ['id' => '\d+'])]
public function toggle(Task $task): JsonResponse
{
$task = $this->manager->toggle($task);
return $this->json($task, 200, [], ['groups' => ['task:read']]);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\DTO;
use App\Enum\TaskStatus;
use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
use Symfony\Component\Validator\Constraints as Assert;
class TaskRequest
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Length(max: 255)]
public readonly string $name,
#[Context([DateTimeNormalizer::FORMAT_KEY => '!Y-m-d'])]
public readonly ?\DateTimeImmutable $date = null,
#[Assert\NotNull]
public readonly TaskStatus $status = TaskStatus::Active,
) {
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Entity;
use App\Enum\TaskStatus;
use App\Repository\TaskRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity(repositoryClass: TaskRepository::class)]
class Task
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['task:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['task:read'])]
private string $name = '';
#[ORM\Column(type: 'date_immutable', nullable: true)]
#[Groups(['task:read'])]
private ?\DateTimeImmutable $date = null;
#[ORM\Column(enumType: TaskStatus::class)]
private TaskStatus $status = TaskStatus::Active;
public function getId(): ?int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getDate(): ?\DateTimeImmutable
{
return $this->date;
}
public function setDate(?\DateTimeImmutable $date): self
{
$this->date = $date;
return $this;
}
#[Groups(['task:read'])]
public function getStatus(): TaskStatus
{
if ($this->date !== null && $this->date < new \DateTimeImmutable('today')) {
return TaskStatus::Past;
}
return $this->status;
}
public function getRawStatus(): TaskStatus
{
return $this->status;
}
public function setStatus(TaskStatus $status): self
{
$this->status = $status;
return $this;
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Enum;
enum TaskStatus: string
{
case Active = 'active';
case Done = 'done';
case Inactive = 'inactive';
case Past = 'past';
/** @return list<string> */
public static function userSelectableValues(): array
{
return array_values(array_map(
fn (self $s) => $s->value,
array_filter(self::cases(), fn (self $s) => $s !== self::Past)
));
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Repository;
use App\Collection\TaskCollection;
use App\Entity\Task;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Task>
*/
class TaskRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Task::class);
}
public function currentTasks(): TaskCollection
{
$from = new \DateTimeImmutable('today');
$to = $from->modify('+14 days');
$tasks = $this->createQueryBuilder('t')
->andWhere('t.date IS NULL OR (t.date >= :from AND t.date <= :to)')
->setParameter('from', $from)
->setParameter('to', $to)
->getQuery()
->getResult();
return new TaskCollection($tasks);
}
public function allTasks(): TaskCollection
{
return new TaskCollection(parent::findAll());
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Service;
use App\DTO\TaskRequest;
use App\Entity\Task;
use App\Enum\TaskStatus;
use Doctrine\ORM\EntityManagerInterface;
class TaskManager
{
public function __construct(private EntityManagerInterface $em)
{
}
public function create(TaskRequest $req): Task
{
$task = new Task();
$task->setName($req->name);
$task->setDate($req->date);
$task->setStatus($req->status);
$this->em->persist($task);
$this->em->flush();
return $task;
}
public function update(Task $task, TaskRequest $req): Task
{
$task->setName($req->name);
$task->setDate($req->date);
$task->setStatus($req->status);
$this->em->flush();
return $task;
}
public function delete(Task $task): void
{
$this->em->remove($task);
$this->em->flush();
}
public function toggle(Task $task): Task
{
$new = match ($task->getRawStatus()) {
TaskStatus::Active => TaskStatus::Done,
TaskStatus::Done => TaskStatus::Active,
TaskStatus::Inactive => TaskStatus::Inactive,
TaskStatus::Past => TaskStatus::Past,
};
$task->setStatus($new);
$this->em->flush();
return $task;
}
}

View File

@@ -0,0 +1,64 @@
<script setup>
const props = defineProps({
name: { type: String, required: true },
size: { type: [String, Number], default: 20 },
})
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="size"
:height="size"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon"
>
<template v-if="name === 'list'">
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" />
<line x1="3" y1="12" x2="3.01" y2="12" />
<line x1="3" y1="18" x2="3.01" y2="18" />
</template>
<template v-else-if="name === 'plus'">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</template>
<template v-else-if="name === 'eye'">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</template>
<template v-else-if="name === 'eye-off'">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
<line x1="1" y1="1" x2="23" y2="23" />
</template>
<template v-else-if="name === 'pencil'">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</template>
<template v-else-if="name === 'trash'">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</template>
<template v-else-if="name === 'refresh'">
<polyline points="23 4 23 10 17 10" />
<polyline points="1 20 1 14 7 14" />
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</template>
</svg>
</template>
<style scoped>
.icon {
display: inline-block;
vertical-align: middle;
}
</style>

View File

@@ -9,6 +9,45 @@ const router = createRouter({
component: () => import('../views/Startpage.vue'),
meta: { breadcrumb: [] },
},
{
path: '/tasks',
name: 'tasks',
component: () => import('../views/Task.vue'),
meta: { breadcrumb: [{ label: 'Tasks', to: '/tasks' }] },
},
{
path: '/tasks/all',
name: 'tasks-all',
component: () => import('../views/TaskAll.vue'),
meta: {
breadcrumb: [
{ label: 'Tasks', to: '/tasks' },
{ label: 'All' },
],
},
},
{
path: '/tasks/create',
name: 'tasks-create',
component: () => import('../views/TaskCreate.vue'),
meta: {
breadcrumb: [
{ label: 'Tasks', to: '/tasks' },
{ label: 'Create' },
],
},
},
{
path: '/tasks/:id(\\d+)',
name: 'tasks-edit',
component: () => import('../views/TaskEdit.vue'),
meta: {
breadcrumb: [
{ label: 'Tasks', to: '/tasks' },
{ label: 'Edit' },
],
},
},
],
})

View File

@@ -0,0 +1,23 @@
const BASE = 'https://haushalt.ddev.site/api'
async function request(path, opts = {}) {
const res = await fetch(`${BASE}${path}`, {
headers: { 'Content-Type': 'application/json', ...(opts.headers ?? {}) },
...opts,
})
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new Error(`API ${res.status} ${res.statusText}${body ? `: ${body}` : ''}`)
}
return res.status === 204 ? null : res.json()
}
export const taskApi = {
list: (filter) => request(`/tasks${filter ? `?filter=${filter}` : ''}`),
get: (id) => request(`/tasks/${id}`),
create: (data) => request('/tasks', { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => request(`/tasks/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
remove: (id) => request(`/tasks/${id}`, { method: 'DELETE' }),
toggle: (id) => request(`/tasks/${id}/toggle`, { method: 'PATCH' }),
statuses: () => request('/tasks/statuses'),
}

View File

@@ -0,0 +1,77 @@
import { defineStore } from 'pinia'
import { taskApi } from '../services/api'
export const useTasksStore = defineStore('tasks', {
state: () => ({
tasks: [],
currentTasks: [],
availableStatuses: [],
loading: false,
error: null,
}),
actions: {
async fetchAll() {
this.loading = true
this.error = null
try {
this.tasks = await taskApi.list()
} catch (e) {
this.error = e.message
} finally {
this.loading = false
}
},
async fetchCurrent() {
this.loading = true
this.error = null
try {
this.currentTasks = await taskApi.list('current')
} catch (e) {
this.error = e.message
} finally {
this.loading = false
}
},
async get(id) {
return taskApi.get(id)
},
async fetchStatuses() {
if (this.availableStatuses.length > 0) return
this.availableStatuses = await taskApi.statuses()
},
async create(data) {
const task = await taskApi.create(data)
this.tasks.push(task)
return task
},
async update(id, data) {
const updated = await taskApi.update(id, data)
this.replaceLocal(updated)
return updated
},
async remove(id) {
await taskApi.remove(id)
this.tasks = this.tasks.filter((t) => t.id !== id)
this.currentTasks = this.currentTasks.filter((t) => t.id !== id)
},
async toggle(id) {
const updated = await taskApi.toggle(id)
this.replaceLocal(updated)
return updated
},
replaceLocal(task) {
const replace = (list) => list.map((t) => (t.id === task.id ? task : t))
this.tasks = replace(this.tasks)
this.currentTasks = replace(this.currentTasks)
},
},
})

View File

@@ -1,3 +1,35 @@
<script setup>
import { RouterLink } from 'vue-router'
</script>
<template>
<div class="start-page"></div>
<div class="tile-grid">
<RouterLink to="/tasks" class="tile">Tasks</RouterLink>
</div>
</template>
<style scoped>
.tile-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 1rem;
}
.tile {
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1;
border: 1px solid var(--border);
border-radius: 0.5rem;
text-decoration: none;
color: var(--text);
font-weight: 600;
font-size: 1.125rem;
background: var(--breadcrumb-bg);
}
.tile:hover {
border-color: var(--text);
}
</style>

170
frontend/src/views/Task.vue Normal file
View File

@@ -0,0 +1,170 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useTasksStore } from '../stores/tasks'
import Icon from '../components/Icon.vue'
const router = useRouter()
const store = useTasksStore()
const showDone = ref(false)
const visibleTasks = computed(() => {
if (showDone.value) return store.currentTasks
return store.currentTasks.filter((t) => t.status !== 'done')
})
function formatDate(iso) {
return new Date(iso).toLocaleDateString('de-DE')
}
const grouped = computed(() =>
Object.groupBy(visibleTasks.value, (t) => (t.date ? formatDate(t.date) : ''))
)
onMounted(() => {
store.fetchCurrent()
})
</script>
<template>
<div class="task-view">
<div class="actions">
<button class="icon-btn" @click="router.push('/tasks/create')" title="Neuer Task">
<Icon name="plus" />
</button>
<button class="icon-btn" @click="router.push('/tasks/all')" title="Alle Tasks">
<Icon name="list" />
</button>
<button class="icon-btn" @click="showDone = !showDone" :title="showDone ? 'Erledigte ausblenden' : 'Erledigte einblenden'">
<Icon :name="showDone ? 'eye' : 'eye-off'" />
</button>
<button class="icon-btn" @click="store.fetchCurrent()" title="Neu laden">
<Icon name="refresh" />
</button>
</div>
<p v-if="store.loading && visibleTasks.length === 0" class="hint">Lädt</p>
<p v-else-if="store.error" class="error">{{ store.error }}</p>
<p v-else-if="visibleTasks.length === 0" class="hint">Keine Tasks.</p>
<div v-else class="task-groups">
<section
v-for="(tasks, key) in grouped"
:key="key || 'no-date'"
class="task-group"
>
<h3 v-if="key" class="group-title">{{ key }}</h3>
<ul class="task-list">
<li
v-for="task in tasks"
:key="task.id"
class="task-item"
:class="{ done: task.status === 'done' }"
@click="store.toggle(task.id)"
>
<span class="task-name">{{ task.name }}</span>
</li>
</ul>
</section>
</div>
</div>
</template>
<style scoped>
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-bottom: 1rem;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
padding: 0;
background: transparent;
border: 1px solid var(--border);
border-radius: 0.375rem;
color: var(--text);
cursor: pointer;
}
.icon-btn:hover {
background: var(--breadcrumb-bg);
}
.task-groups {
display: flex;
flex-direction: column;
gap: 2rem;
}
.task-group {
position: relative;
border: 1px solid var(--border);
border-radius: 0.375rem;
padding-top: 0.25rem;
}
.group-title {
position: absolute;
top: -0.75rem;
left: 50%;
transform: translateX(-50%);
margin: 0;
padding: 0.1rem 0.5rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 0.375rem;
font-size: 0.85rem;
font-weight: 400;
line-height: 1.2;
color: var(--text);
}
.task-list {
list-style: none;
padding: 0;
margin: 0;
}
.task-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
cursor: pointer;
}
.task-item:last-child {
border-bottom: none;
}
.task-item:hover {
background: var(--breadcrumb-bg);
}
.task-item.done .task-name {
text-decoration: line-through;
color: var(--text-muted);
}
.task-date {
font-size: 0.85rem;
color: var(--text-muted);
}
.hint {
color: var(--text-muted);
}
.error {
color: #dc2626;
}
</style>

View File

@@ -0,0 +1,171 @@
<script setup>
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useTasksStore } from '../stores/tasks'
import Icon from '../components/Icon.vue'
const router = useRouter()
const store = useTasksStore()
function formatDate(iso) {
return new Date(iso).toLocaleDateString('de-DE')
}
async function onDelete(id) {
if (!confirm('Task wirklich löschen?')) return
await store.remove(id)
}
onMounted(() => {
store.fetchAll()
})
</script>
<template>
<div class="task-all-view">
<div class="actions">
<button class="icon-btn" @click="router.push('/tasks/create')" title="Neuer Task">
<Icon name="plus" />
</button>
<button class="icon-btn" @click="store.fetchAll()" title="Neu laden">
<Icon name="refresh" />
</button>
</div>
<p v-if="store.loading && store.tasks.length === 0" class="hint">Lädt</p>
<p v-else-if="store.error" class="error">{{ store.error }}</p>
<p v-else-if="store.tasks.length === 0" class="hint">Keine Tasks.</p>
<ul v-else class="task-list">
<li
v-for="task in store.tasks"
:key="task.id"
class="task-item"
:class="{
done: task.status === 'done',
inactive: task.status === 'inactive',
past: task.status === 'past',
}"
@click="store.toggle(task.id)"
>
<span class="task-name">{{ task.name }}</span>
<span v-if="task.date" class="task-date">{{ formatDate(task.date) }}</span>
<span class="row-actions">
<button
class="icon-btn"
@click.stop="router.push(`/tasks/${task.id}`)"
title="Bearbeiten"
>
<Icon name="pencil" :size="16" />
</button>
<button
class="icon-btn danger"
@click.stop="onDelete(task.id)"
title="Löschen"
>
<Icon name="trash" :size="16" />
</button>
</span>
</li>
</ul>
</div>
</template>
<style scoped>
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-bottom: 1rem;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
padding: 0;
background: transparent;
border: 1px solid var(--border);
border-radius: 0.375rem;
color: var(--text);
cursor: pointer;
}
.icon-btn:hover {
background: var(--breadcrumb-bg);
}
.icon-btn.danger:hover {
border-color: #dc2626;
color: #dc2626;
}
.task-list {
list-style: none;
padding: 0;
margin: 0;
border: 1px solid var(--border);
border-radius: 0.375rem;
}
.task-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
cursor: pointer;
}
.task-item:last-child {
border-bottom: none;
}
.task-item:hover {
background: var(--breadcrumb-bg);
}
.task-name {
flex: 1;
}
.task-item.done .task-name {
text-decoration: line-through;
color: var(--text-muted);
}
.task-item.inactive .task-name {
color: var(--text-muted);
font-style: italic;
}
.task-item.past {
opacity: 0.5;
}
.task-date {
font-size: 0.85rem;
color: var(--text-muted);
margin-right: 0.5rem;
}
.row-actions {
display: inline-flex;
gap: 0.25rem;
}
.row-actions .icon-btn {
width: 2rem;
height: 2rem;
}
.hint {
color: var(--text-muted);
}
.error {
color: #dc2626;
}
</style>

View File

@@ -0,0 +1,132 @@
<script setup>
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useTasksStore } from '../stores/tasks'
const router = useRouter()
const store = useTasksStore()
const form = ref({
name: '',
date: '',
status: 'active',
})
const submitting = ref(false)
const error = ref(null)
onMounted(() => {
store.fetchStatuses()
})
async function onSave() {
submitting.value = true
error.value = null
try {
await store.create({
name: form.value.name,
date: form.value.date || null,
status: form.value.status,
})
router.push('/tasks')
} catch (e) {
error.value = e.message
} finally {
submitting.value = false
}
}
function onAbort() {
router.back()
}
</script>
<template>
<form class="task-form" @submit.prevent="onSave">
<div class="field">
<label for="name">Name</label>
<input id="name" v-model="form.name" type="text" required />
</div>
<div class="field">
<label for="date">Datum</label>
<input id="date" v-model="form.date" type="date" />
</div>
<div class="field">
<label for="status">Status</label>
<select id="status" v-model="form.status">
<option v-for="key in store.availableStatuses" :key="key" :value="key">
{{ key }}
</option>
</select>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="form-actions">
<button type="submit" :disabled="submitting">Speichern</button>
<button type="button" @click="onAbort">Abbrechen</button>
</div>
</form>
</template>
<style scoped>
.task-form {
max-width: 480px;
display: flex;
flex-direction: column;
gap: 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.field label {
font-size: 0.85rem;
color: var(--text-muted);
}
.field input,
.field select {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
font-size: 1rem;
font-family: inherit;
color: var(--text);
background: var(--bg);
}
.form-actions {
display: flex;
gap: 0.5rem;
}
button {
padding: 0.5rem 1rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
background: var(--breadcrumb-bg);
color: var(--text);
font-family: inherit;
cursor: pointer;
}
button[type='submit'] {
background: var(--text);
color: var(--bg);
border-color: var(--text);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error {
color: #dc2626;
}
</style>

View File

@@ -0,0 +1,165 @@
<script setup>
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTasksStore } from '../stores/tasks'
const route = useRoute()
const router = useRouter()
const store = useTasksStore()
const id = Number(route.params.id)
const original = ref(null)
const form = ref({ name: '', date: '', status: 'active' })
const submitting = ref(false)
const error = ref(null)
function toFormDate(iso) {
if (!iso) return ''
return iso.slice(0, 10)
}
function loadFromTask(task) {
return {
name: task.name,
date: toFormDate(task.date),
status: task.status,
}
}
onMounted(async () => {
store.fetchStatuses()
try {
const task = await store.get(id)
original.value = task
form.value = loadFromTask(task)
} catch (e) {
error.value = e.message
}
})
async function onUpdate() {
submitting.value = true
error.value = null
try {
await store.update(id, {
name: form.value.name,
date: form.value.date || null,
status: form.value.status,
})
router.push('/tasks/all')
} catch (e) {
error.value = e.message
} finally {
submitting.value = false
}
}
function onReset() {
if (original.value) {
form.value = loadFromTask(original.value)
}
}
function onAbort() {
router.back()
}
</script>
<template>
<form v-if="original" class="task-form" @submit.prevent="onUpdate">
<div class="field">
<label for="name">Name</label>
<input id="name" v-model="form.name" type="text" required />
</div>
<div class="field">
<label for="date">Datum</label>
<input id="date" v-model="form.date" type="date" />
</div>
<div class="field">
<label for="status">Status</label>
<select id="status" v-model="form.status">
<option v-for="key in store.availableStatuses" :key="key" :value="key">
{{ key }}
</option>
</select>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="form-actions">
<button type="submit" :disabled="submitting">Aktualisieren</button>
<button type="button" @click="onReset">Zurücksetzen</button>
<button type="button" @click="onAbort">Abbrechen</button>
</div>
</form>
<p v-else-if="error" class="error">{{ error }}</p>
<p v-else class="hint">Lädt</p>
</template>
<style scoped>
.task-form {
max-width: 480px;
display: flex;
flex-direction: column;
gap: 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.field label {
font-size: 0.85rem;
color: var(--text-muted);
}
.field input,
.field select {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
font-size: 1rem;
font-family: inherit;
color: var(--text);
background: var(--bg);
}
.form-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
button {
padding: 0.5rem 1rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
background: var(--breadcrumb-bg);
color: var(--text);
font-family: inherit;
cursor: pointer;
}
button[type='submit'] {
background: var(--text);
color: var(--bg);
border-color: var(--text);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error {
color: #dc2626;
}
.hint {
color: var(--text-muted);
}
</style>

View File

@@ -47,7 +47,7 @@ Implementierungs-Schritte als Feature-Module - WIE es gebaut wird
- Display current tasks (now to +2 weeks and without date) as list with name (done strikethrough), onclick toggle status, order by date (no-date then date asc, hide inactive)
- top right nav - list icon (all tasks), + icon (create), eye icon (toggle task visibility by active/done)
- TaskAll.vue
- Display all tasks as list with name (done strikethrough), pencil icon (edit), bin icon (delete), onclick toggle status, order by date (no-date then date asc then inactive asc)
- Display all tasks as list with name (done strikethrough, past faded), pencil icon (edit), bin icon (delete), onclick toggle status, order by date (no-date then date asc)
- top right nav - + icon (create)
- TaskCreate.vue - Display form with name-text, date-date, status-select, save-button, abort-button
- TaskEdit.vue
@@ -61,7 +61,7 @@ Implementierungs-Schritte als Feature-Module - WIE es gebaut wird
- Display current tasks (now to +2 weeks and without date) as list with name (done strikethrough), onclick toggle status, order by date (no-date then date asc, hide inactive)
- top right nav - list icon (all tasks), + icon (create), eye icon (toggle task visibility by active/done)
- TaskAllScreen.kt
- Display all tasks as list with name (done strikethrough), pencil icon (edit), bin icon (delete), onclick toggle status, order by date (no-date then date asc then inactive asc)
- Display all tasks as list with name (done strikethrough, past faded), pencil icon (edit), bin icon (delete), onclick toggle status, order by date (no-date then date asc)
- top right nav - + icon (create)
- TaskCreateScreen.kt - Display form with name-text, date-date, status-select, save-button, abort-button
- TaskEditScreen.kt
@@ -70,7 +70,7 @@ Implementierungs-Schritte als Feature-Module - WIE es gebaut wird
## Features
- Start page: task button
- Task page: current tasks ordered by date, filter done
- TaskAll page: all tasks ordered by date and status, delete task
- TaskAll page: all tasks ordered by date, past faded, delete task
- TaskCreate page: create task
- TaskEdit page: update task
@@ -100,22 +100,3 @@ Implementierungs-Schritte als Feature-Module - WIE es gebaut wird
- TaskGenerator - Create tasks from schema
- generate
# Item module
- Item - Item entity
- ItemController - Item routes
- ItemManager - Item CRUD
- ItemRepository - Item queries
- UnitEnum - Unit for Item
# Meal module
- Meal - Meal entity
- MealController - Meal routes
- MealManager - Meal CRUD
- MealRepository - Meal queries
# Shopping module
- ShoppingList - ShoppingList entity
- ShoppingListController - ShoppingList routes
- ShoppingListManager - ShoppingList CRUD
- ShoppingListRepository - ShoppingList queries