Task module
This commit is contained in:
@@ -14,6 +14,8 @@ composer_version: "2"
|
|||||||
composer_root: backend
|
composer_root: backend
|
||||||
web_environment: []
|
web_environment: []
|
||||||
corepack_enable: false
|
corepack_enable: false
|
||||||
|
host_webserver_port: "8080"
|
||||||
|
bind_all_interfaces: true
|
||||||
|
|
||||||
web_extra_exposed_ports:
|
web_extra_exposed_ports:
|
||||||
- name: vite
|
- name: vite
|
||||||
|
|||||||
95
CLAUDE.md
95
CLAUDE.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Basis-Software mit 3 geplanten Apps: Task Manager, Shopping List, Meal Planner.
|
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
|
## 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 |
|
| Backend | Symfony 7.4, PHP 8.3, Doctrine ORM |
|
||||||
| Frontend Web | Vue 3 (Composition API), Vite, Pinia, Vue Router 4 |
|
| 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) |
|
| Datenbank | MariaDB 10.11 (utf8mb4) |
|
||||||
| CORS | Nelmio CORS Bundle |
|
| CORS | Nelmio CORS Bundle |
|
||||||
| Umgebung | DDEV |
|
| Umgebung | DDEV |
|
||||||
@@ -20,19 +20,85 @@ Aktueller Stand: **Setup module** (siehe `module.md`) — minimales Symfony + Vu
|
|||||||
```
|
```
|
||||||
backend/
|
backend/
|
||||||
src/
|
src/
|
||||||
Kernel.php — Standard-Symfony-Kernel, keine App-Klassen
|
Collection/TaskCollection.php — IteratorAggregate, filterInactive/sortByDueDate*
|
||||||
config/ — Symfony-Config (nelmio_cors, doctrine, framework, ...)
|
Controller/Api/TaskController.php — REST-Routen unter /api/tasks
|
||||||
migrations/ — leer
|
DTO/TaskRequest.php — readonly promoted constructor, Validator + Context(!Y-m-d)
|
||||||
public/index.php — Symfony-Einstieg
|
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/
|
frontend/
|
||||||
src/
|
src/
|
||||||
main.js — Vue-Init mit Pinia + Router
|
components/Icon.vue — zentrales Icon-Component
|
||||||
App.vue — RouterView, kein Content
|
router/index.js — /, /tasks, /tasks/all, /tasks/create, /tasks/:id mit breadcrumb meta
|
||||||
router/index.js — Router mit leerem routes-Array
|
services/api.js — fetch wrapper, tasks() + statuses() Endpoints
|
||||||
style.css — leer
|
stores/tasks.js — Pinia store: fetchCurrent, fetchAll, get, create, update, remove, toggle, fetchStatuses
|
||||||
index.html, vite.config.js
|
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
|
## Dokumentation
|
||||||
|
|
||||||
- **`base.md`** — Vision: was gebaut wird (3 Apps, Systeme, Datenbank-Skizze)
|
- **`base.md`** — Vision: was gebaut wird (3 Apps, Systeme, Datenbank-Skizze)
|
||||||
@@ -44,6 +110,8 @@ frontend/
|
|||||||
- **Sprache Code**: Englisch (Klassen, Methoden, Variablen)
|
- **Sprache Code**: Englisch (Klassen, Methoden, Variablen)
|
||||||
- **Sprache UI**: Deutsch
|
- **Sprache UI**: Deutsch
|
||||||
- **Enum-Werte**: Englisch in DB und Code
|
- **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>`
|
- **Frontend**: Vue 3 Composition API mit `<script setup>`
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
@@ -54,10 +122,15 @@ ddev start
|
|||||||
|
|
||||||
# Backend
|
# Backend
|
||||||
ddev exec "cd backend && php bin/console cache:clear"
|
ddev exec "cd backend && php bin/console cache:clear"
|
||||||
|
ddev exec "cd backend && php bin/console doctrine:migrations:migrate"
|
||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
ddev exec "cd frontend && npm install"
|
ddev exec "cd frontend && npm install"
|
||||||
ddev exec "cd frontend && npm run dev -- --host 0.0.0.0"
|
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
|
# URLs
|
||||||
Frontend: https://haushalt.ddev.site:5173
|
Frontend: https://haushalt.ddev.site:5173
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ plugins {
|
|||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -49,5 +50,11 @@ dependencies {
|
|||||||
implementation(libs.androidx.ui.graphics)
|
implementation(libs.androidx.ui.graphics)
|
||||||
implementation(libs.androidx.ui.tooling.preview)
|
implementation(libs.androidx.ui.tooling.preview)
|
||||||
implementation(libs.androidx.material3)
|
implementation(libs.androidx.material3)
|
||||||
|
implementation(libs.androidx.compose.material.icons.extended)
|
||||||
implementation(libs.androidx.navigation.compose)
|
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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:icon="@android:drawable/sym_def_app_icon"
|
android:icon="@android:drawable/sym_def_app_icon"
|
||||||
android:theme="@style/Theme.Haushalt">
|
android:theme="@style/Theme.Haushalt"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
|
|||||||
@@ -16,10 +16,36 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import androidx.navigation.compose.rememberNavController
|
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
|
@Composable
|
||||||
fun MainScreen() {
|
fun MainScreen() {
|
||||||
@@ -35,17 +61,33 @@ fun MainScreen() {
|
|||||||
startDestination = "start",
|
startDestination = "start",
|
||||||
modifier = Modifier.padding(innerPadding)
|
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
|
@Composable
|
||||||
private fun Breadcrumb(currentRoute: String?, navController: NavController) {
|
private fun Breadcrumb(currentRoute: String?, navController: NavController) {
|
||||||
// Trail: (route, label). Erweitert sich pro neuer Destination in späteren Modulen.
|
val trail = trailMap[currentRoute] ?: listOf("start" to "Haushalt")
|
||||||
val trail = listOf(
|
|
||||||
"start" to "Haushalt",
|
|
||||||
)
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
color = MaterialTheme.colorScheme.surfaceContainer
|
color = MaterialTheme.colorScheme.surfaceContainer
|
||||||
|
|||||||
@@ -1,7 +1,44 @@
|
|||||||
package de.haushalt.app
|
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.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
@Composable
|
@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"
|
activityCompose = "1.9.3"
|
||||||
composeBom = "2024.12.01"
|
composeBom = "2024.12.01"
|
||||||
navigationCompose = "2.8.5"
|
navigationCompose = "2.8.5"
|
||||||
|
retrofit = "2.11.0"
|
||||||
|
okhttp = "4.12.0"
|
||||||
|
kotlinxSerialization = "1.7.3"
|
||||||
|
lifecycleViewModelCompose = "2.8.7"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||||
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
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-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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", 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'),
|
component: () => import('../views/Startpage.vue'),
|
||||||
meta: { breadcrumb: [] },
|
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>
|
<template>
|
||||||
<div class="start-page"></div>
|
<div class="tile-grid">
|
||||||
|
<RouterLink to="/tasks" class="tile">Tasks</RouterLink>
|
||||||
|
</div>
|
||||||
</template>
|
</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)
|
- 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)
|
- top right nav - list icon (all tasks), + icon (create), eye icon (toggle task visibility by active/done)
|
||||||
- TaskAll.vue
|
- 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)
|
- top right nav - + icon (create)
|
||||||
- TaskCreate.vue - Display form with name-text, date-date, status-select, save-button, abort-button
|
- TaskCreate.vue - Display form with name-text, date-date, status-select, save-button, abort-button
|
||||||
- TaskEdit.vue
|
- 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)
|
- 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)
|
- top right nav - list icon (all tasks), + icon (create), eye icon (toggle task visibility by active/done)
|
||||||
- TaskAllScreen.kt
|
- 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)
|
- top right nav - + icon (create)
|
||||||
- TaskCreateScreen.kt - Display form with name-text, date-date, status-select, save-button, abort-button
|
- TaskCreateScreen.kt - Display form with name-text, date-date, status-select, save-button, abort-button
|
||||||
- TaskEditScreen.kt
|
- TaskEditScreen.kt
|
||||||
@@ -70,7 +70,7 @@ Implementierungs-Schritte als Feature-Module - WIE es gebaut wird
|
|||||||
## Features
|
## Features
|
||||||
- Start page: task button
|
- Start page: task button
|
||||||
- Task page: current tasks ordered by date, filter done
|
- 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
|
- TaskCreate page: create task
|
||||||
- TaskEdit page: update task
|
- TaskEdit page: update task
|
||||||
|
|
||||||
@@ -100,22 +100,3 @@ Implementierungs-Schritte als Feature-Module - WIE es gebaut wird
|
|||||||
- TaskGenerator - Create tasks from schema
|
- TaskGenerator - Create tasks from schema
|
||||||
- generate
|
- 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