Task module
This commit is contained in:
@@ -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
|
||||
|
||||
95
CLAUDE.md
95
CLAUDE.md
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
30
app/app/src/main/java/de/haushalt/app/data/ApiClient.kt
Normal file
30
app/app/src/main/java/de/haushalt/app/data/ApiClient.kt
Normal 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)
|
||||
}
|
||||
26
app/app/src/main/java/de/haushalt/app/data/Task.kt
Normal file
26
app/app/src/main/java/de/haushalt/app/data/Task.kt
Normal 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,
|
||||
)
|
||||
33
app/app/src/main/java/de/haushalt/app/data/TaskApi.kt
Normal file
33
app/app/src/main/java/de/haushalt/app/data/TaskApi.kt
Normal 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>
|
||||
}
|
||||
12
app/app/src/main/java/de/haushalt/app/ui/task/DateFormat.kt
Normal file
12
app/app/src/main/java/de/haushalt/app/ui/task/DateFormat.kt
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
160
app/app/src/main/java/de/haushalt/app/ui/task/TaskAllScreen.kt
Normal file
160
app/app/src/main/java/de/haushalt/app/ui/task/TaskAllScreen.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
163
app/app/src/main/java/de/haushalt/app/ui/task/TaskScreen.kt
Normal file
163
app/app/src/main/java/de/haushalt/app/ui/task/TaskScreen.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
35
backend/migrations/Version20260411141650.php
Normal file
35
backend/migrations/Version20260411141650.php
Normal 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');
|
||||
}
|
||||
}
|
||||
63
backend/src/Collection/TaskCollection.php
Normal file
63
backend/src/Collection/TaskCollection.php
Normal 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;
|
||||
}
|
||||
}
|
||||
79
backend/src/Controller/Api/TaskController.php
Normal file
79
backend/src/Controller/Api/TaskController.php
Normal 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']]);
|
||||
}
|
||||
}
|
||||
24
backend/src/DTO/TaskRequest.php
Normal file
24
backend/src/DTO/TaskRequest.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
80
backend/src/Entity/Task.php
Normal file
80
backend/src/Entity/Task.php
Normal 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;
|
||||
}
|
||||
}
|
||||
20
backend/src/Enum/TaskStatus.php
Normal file
20
backend/src/Enum/TaskStatus.php
Normal 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)
|
||||
));
|
||||
}
|
||||
}
|
||||
39
backend/src/Repository/TaskRepository.php
Normal file
39
backend/src/Repository/TaskRepository.php
Normal 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());
|
||||
}
|
||||
}
|
||||
61
backend/src/Service/TaskManager.php
Normal file
61
backend/src/Service/TaskManager.php
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
64
frontend/src/components/Icon.vue
Normal file
64
frontend/src/components/Icon.vue
Normal 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>
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
23
frontend/src/services/api.js
Normal file
23
frontend/src/services/api.js
Normal 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'),
|
||||
}
|
||||
77
frontend/src/stores/tasks.js
Normal file
77
frontend/src/stores/tasks.js
Normal 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)
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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
170
frontend/src/views/Task.vue
Normal 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>
|
||||
171
frontend/src/views/TaskAll.vue
Normal file
171
frontend/src/views/TaskAll.vue
Normal 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>
|
||||
132
frontend/src/views/TaskCreate.vue
Normal file
132
frontend/src/views/TaskCreate.vue
Normal 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>
|
||||
165
frontend/src/views/TaskEdit.vue
Normal file
165
frontend/src/views/TaskEdit.vue
Normal 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>
|
||||
25
module.md
25
module.md
@@ -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
|
||||
Reference in New Issue
Block a user