update task and schema module

This commit is contained in:
Marek Lenczewski
2026-04-14 18:15:05 +02:00
parent d576747155
commit 0d028beda8
23 changed files with 227 additions and 59 deletions

1
app/.gitignore vendored
View File

@@ -10,4 +10,3 @@
.cxx
local.properties
app/build
*.jks

View File

@@ -13,8 +13,8 @@ android {
applicationId = "de.haushalt.app"
minSdk = 26
targetSdk = 35
versionCode = 4
versionName = "0.1.0"
versionCode = 5
versionName = "0.1.1"
}
signingConfigs {
@@ -23,6 +23,9 @@ android {
storePassword = "haushalt123"
keyAlias = "haushalt"
keyPassword = "haushalt123"
enableV1Signing = true
enableV2Signing = true
enableV3Signing = true
}
}

View File

@@ -100,6 +100,7 @@ private fun SchemaRow(
schema.repeat.containsKey("2week") -> "2-Wöchentlich"
schema.repeat.containsKey("4week") -> "4-Wöchentlich"
schema.repeat.containsKey("monthly") -> "Monatlich"
schema.repeat.containsKey("days") -> "Mehrere Tage"
else -> ""
}

View File

@@ -5,16 +5,23 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -23,10 +30,12 @@ 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.unit.dp
import de.haushalt.app.data.TaskSchemaStatus
import de.haushalt.app.data.TaskStatus
import de.haushalt.app.ui.task.DatePickerField
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -91,7 +100,7 @@ fun RepeatTypeDropdown(
current: String,
onChange: (String) -> Unit,
) {
val options = listOf("none" to "Keine (Einmalig)", "daily" to "Täglich", "weekly" to "Wöchentlich", "2week" to "2-Wöchentlich", "4week" to "4-Wöchentlich", "monthly" to "Monatlich")
val options = listOf("none" to "Keine (Einmalig)", "daily" to "Täglich", "weekly" to "Wöchentlich", "2week" to "2-Wöchentlich", "4week" to "4-Wöchentlich", "monthly" to "Monatlich", "days" to "Mehrere Tage")
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
OutlinedTextField(
@@ -172,3 +181,31 @@ fun MonthdaySelector(
}
}
}
@Composable
fun DaysSelector(
days: List<String>,
onChange: (List<String>) -> Unit,
) {
Text("Termine", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
days.forEachIndexed { i, day ->
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
DatePickerField(
value = day,
onChange = { newVal -> onChange(days.toMutableList().also { it[i] = newVal }) },
label = "Datum",
modifier = Modifier.weight(1f),
)
IconButton(onClick = { onChange(days.toMutableList().also { it.removeAt(i) }) }) {
Icon(Icons.Filled.Delete, contentDescription = "Entfernen")
}
}
}
OutlinedButton(onClick = { onChange(days + "") }) {
Icon(Icons.Filled.Add, contentDescription = null)
Text("Termin")
}
}

View File

@@ -78,6 +78,13 @@ fun SchemaCreateScreen(
)
}
if (viewModel.repeatType == "days") {
DaysSelector(
days = viewModel.days,
onChange = { viewModel.days = it },
)
}
if (viewModel.repeatType == "monthly") {
MonthdaySelector(
selected = viewModel.monthly,
@@ -85,7 +92,7 @@ fun SchemaCreateScreen(
)
}
if (viewModel.repeatType != "none") {
if (viewModel.repeatType !in listOf("none", "days")) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),

View File

@@ -24,6 +24,7 @@ class SchemaCreateViewModel : ViewModel() {
var end by mutableStateOf("")
var weekly by mutableStateOf(List(7) { false })
var monthly by mutableStateOf(List(31) { false })
var days by mutableStateOf(listOf<String>())
var isSubmitting by mutableStateOf(false)
private set
var error by mutableStateOf<String?>(null)
@@ -51,6 +52,7 @@ class SchemaCreateViewModel : ViewModel() {
"2week" -> JsonObject(mapOf("2week" to buildJsonArray { weekly.forEach { add(JsonPrimitive(it)) } }))
"4week" -> JsonObject(mapOf("4week" to buildJsonArray { weekly.forEach { add(JsonPrimitive(it)) } }))
"monthly" -> JsonObject(mapOf("monthly" to buildJsonArray { monthly.forEach { add(JsonPrimitive(it)) } }))
"days" -> JsonObject(mapOf("days" to buildJsonArray { days.filter { it.isNotBlank() }.forEach { add(JsonPrimitive(it)) } }))
else -> null
}

View File

@@ -87,6 +87,13 @@ fun SchemaEditScreen(
)
}
if (viewModel.repeatType == "days") {
DaysSelector(
days = viewModel.days,
onChange = { viewModel.days = it },
)
}
if (viewModel.repeatType == "monthly") {
MonthdaySelector(
selected = viewModel.monthly,
@@ -94,7 +101,7 @@ fun SchemaEditScreen(
)
}
if (viewModel.repeatType != "none") {
if (viewModel.repeatType !in listOf("none", "days")) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -125,9 +132,6 @@ fun SchemaEditScreen(
) {
Text("Aktualisieren")
}
OutlinedButton(onClick = { viewModel.reset() }) {
Text("Zurücksetzen")
}
OutlinedButton(onClick = { navController.popBackStack() }) {
Text("Abbrechen")
}

View File

@@ -16,6 +16,7 @@ import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonPrimitive
class SchemaEditViewModel : ViewModel() {
@@ -28,6 +29,7 @@ class SchemaEditViewModel : ViewModel() {
var end by mutableStateOf("")
var weekly by mutableStateOf(List(7) { false })
var monthly by mutableStateOf(List(31) { false })
var days by mutableStateOf(listOf<String>())
var isLoading by mutableStateOf(false)
private set
var isSubmitting by mutableStateOf(false)
@@ -35,15 +37,12 @@ class SchemaEditViewModel : ViewModel() {
var error by mutableStateOf<String?>(null)
private set
private var original: TaskSchema? = null
fun load(id: Int) {
viewModelScope.launch {
isLoading = true
error = null
try {
val schema = ApiClient.schemaApi.get(id)
original = schema
applySchema(schema)
} catch (e: Exception) {
error = e.message
@@ -68,10 +67,6 @@ class SchemaEditViewModel : ViewModel() {
}
}
fun reset() {
original?.let { applySchema(it) }
}
private fun applySchema(schema: TaskSchema) {
name = schema.name
status = schema.status
@@ -85,31 +80,43 @@ class SchemaEditViewModel : ViewModel() {
repeatType = "none"
weekly = List(7) { false }
monthly = List(31) { false }
days = listOf()
}
schema.repeat.containsKey("daily") -> {
repeatType = "daily"
weekly = List(7) { false }
monthly = List(31) { false }
days = listOf()
}
schema.repeat.containsKey("weekly") -> {
repeatType = "weekly"
weekly = schema.repeat["weekly"]!!.jsonArray.map { it.jsonPrimitive.boolean }
monthly = List(31) { false }
days = listOf()
}
schema.repeat.containsKey("2week") -> {
repeatType = "2week"
weekly = schema.repeat["2week"]!!.jsonArray.map { it.jsonPrimitive.boolean }
monthly = List(31) { false }
days = listOf()
}
schema.repeat.containsKey("4week") -> {
repeatType = "4week"
weekly = schema.repeat["4week"]!!.jsonArray.map { it.jsonPrimitive.boolean }
monthly = List(31) { false }
days = listOf()
}
schema.repeat.containsKey("monthly") -> {
repeatType = "monthly"
weekly = List(7) { false }
monthly = schema.repeat["monthly"]!!.jsonArray.map { it.jsonPrimitive.boolean }
days = listOf()
}
schema.repeat.containsKey("days") -> {
repeatType = "days"
weekly = List(7) { false }
monthly = List(31) { false }
days = schema.repeat["days"]!!.jsonArray.map { it.jsonPrimitive.contentOrNull ?: "" }
}
}
}
@@ -121,6 +128,7 @@ class SchemaEditViewModel : ViewModel() {
"2week" -> JsonObject(mapOf("2week" to buildJsonArray { weekly.forEach { add(JsonPrimitive(it)) } }))
"4week" -> JsonObject(mapOf("4week" to buildJsonArray { weekly.forEach { add(JsonPrimitive(it)) } }))
"monthly" -> JsonObject(mapOf("monthly" to buildJsonArray { monthly.forEach { add(JsonPrimitive(it)) } }))
"days" -> JsonObject(mapOf("days" to buildJsonArray { days.filter { it.isNotBlank() }.forEach { add(JsonPrimitive(it)) } }))
else -> null
}

View File

@@ -69,9 +69,6 @@ fun TaskEditScreen(
) {
Text("Aktualisieren")
}
OutlinedButton(onClick = { viewModel.reset() }) {
Text("Zurücksetzen")
}
OutlinedButton(onClick = { navController.popBackStack() }) {
Text("Abbrechen")
}

View File

@@ -12,8 +12,6 @@ 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)
@@ -42,7 +40,6 @@ class TaskEditViewModel : ViewModel() {
error = null
try {
val task = ApiClient.taskApi.get(id)
original = task
applyTask(task)
} catch (e: Exception) {
error = e.message
@@ -74,10 +71,6 @@ class TaskEditViewModel : ViewModel() {
}
}
fun reset() {
original?.let { applyTask(it) }
}
private fun applyTask(task: Task) {
name = task.name
date = task.date?.take(10) ?: ""

BIN
app/haushalt.jks Executable file

Binary file not shown.

View File

@@ -2,7 +2,3 @@ framework:
messenger:
transports:
sync: 'sync://'
scheduler_default: 'scheduler://default'
routing:
App\Message\GenerateTasksMessage: scheduler_default

Binary file not shown.

View File

@@ -1,4 +1,4 @@
{
"versionCode": 4,
"versionCode": 5,
"apkFile": "haushalt.apk"
}

View File

@@ -24,7 +24,7 @@ class TaskSchemaRequest
#[Context([DateTimeNormalizer::FORMAT_KEY => '!Y-m-d'])]
public readonly ?\DateTimeImmutable $date = null,
public readonly ?array $repeat = null,
public ?array $repeat = null,
#[Context([DateTimeNormalizer::FORMAT_KEY => '!Y-m-d'])]
public readonly ?\DateTimeImmutable $start = null,
@@ -32,5 +32,8 @@ class TaskSchemaRequest
#[Context([DateTimeNormalizer::FORMAT_KEY => '!Y-m-d'])]
public readonly ?\DateTimeImmutable $end = null,
) {
if (isset($this->repeat['days']) && is_array($this->repeat['days'])) {
$this->repeat['days'] = array_values(array_unique($this->repeat['days']));
}
}
}

View File

@@ -32,4 +32,15 @@ class TaskSchemaRepository extends ServiceEntityRepository
->getQuery()
->getResult();
}
/** @return list<TaskSchema> */
public function findExpired(): array
{
return $this->createQueryBuilder('s')
->andWhere('s.end IS NOT NULL')
->andWhere('s.end < :today')
->setParameter('today', new \DateTimeImmutable('today'))
->getQuery()
->getResult();
}
}

View File

@@ -22,6 +22,6 @@ class Schedule implements ScheduleProviderInterface
return (new SymfonySchedule())
->stateful($this->cache)
->processOnlyLastMissedRun(true)
->add(RecurringMessage::cron('0 3 * * *', new GenerateTasksMessage()));
->add(RecurringMessage::every('1 day', new GenerateTasksMessage(), from: new \DateTimeImmutable('03:00')));
}
}

View File

@@ -19,11 +19,20 @@ class TaskGenerator
public function generateNewTasks(): void
{
$this->deleteExpiredSchemas();
$today = new \DateTimeImmutable('today');
$schemas = $this->schemaRepo->findActive();
foreach ($schemas as $schema) {
$this->removeTasks($schema);
$this->generateTasks($schema);
if ($schema->getRepeatType() === null) {
$date = $schema->getDate();
if ($date !== null && $date->format('Y-m-d') === $today->format('Y-m-d')) {
$this->createTask($schema, $today);
}
} elseif ($this->matchesDate($schema, $today)) {
$this->createTask($schema, $today);
}
}
$this->em->flush();
@@ -42,6 +51,12 @@ class TaskGenerator
$dates = $this->getDates($schema);
foreach ($dates as $date) {
$this->createTask($schema, $date);
}
}
private function createTask(TaskSchema $schema, \DateTimeImmutable $date): void
{
$task = new Task();
$task->setName($schema->getName());
$task->setDate($date);
@@ -49,6 +64,14 @@ class TaskGenerator
$task->setSchema($schema);
$this->em->persist($task);
}
private function deleteExpiredSchemas(): void
{
$expired = $this->schemaRepo->findExpired();
foreach ($expired as $schema) {
$this->removeTasks($schema);
$this->em->remove($schema);
}
}
/** @return list<\DateTimeImmutable> */
@@ -94,6 +117,10 @@ class TaskGenerator
return $repeat['monthly'][$monthday];
}
if ($type === 'days') {
return in_array($date->format('Y-m-d'), $repeat['days'], true);
}
return false;
}
}

View File

@@ -14,6 +14,7 @@ function repeatLabel(schema) {
if (schema.repeat['2week']) return '2-Wöchentlich'
if (schema.repeat['4week']) return '4-Wöchentlich'
if (schema.repeat.monthly) return 'Monatlich'
if (schema.repeat.days) return 'Mehrere Tage'
return ''
}

View File

@@ -2,6 +2,7 @@
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useSchemasStore } from '../stores/schemas'
import Icon from '../components/Icon.vue'
const router = useRouter()
const store = useSchemasStore()
@@ -16,6 +17,7 @@ const form = ref({
end: '',
weekly: Array(7).fill(false),
monthly: Array(31).fill(false),
days: [],
})
const submitting = ref(false)
@@ -46,6 +48,8 @@ function buildPayload() {
data.repeat = { '4week': [...form.value.weekly] }
} else if (form.value.repeatType === 'monthly') {
data.repeat = { monthly: [...form.value.monthly] }
} else if (form.value.repeatType === 'days') {
data.repeat = { days: form.value.days.filter(d => d) }
}
}
@@ -100,6 +104,7 @@ async function onSave() {
<option value="2week">2-Wöchentlich</option>
<option value="4week">4-Wöchentlich</option>
<option value="monthly">Monatlich</option>
<option value="days">Mehrere Tage</option>
</select>
</div>
@@ -128,7 +133,18 @@ async function onSave() {
</div>
</div>
<div v-if="form.repeatType !== 'none'" class="field-row">
<div v-if="form.repeatType === 'days'" class="field">
<label>Termine</label>
<div v-for="(d, i) in form.days" :key="i" class="days-row">
<input type="date" v-model="form.days[i]" />
<button type="button" class="icon-btn" @click="form.days.splice(i, 1)" title="Entfernen">
<Icon name="trash" />
</button>
</div>
<button type="button" class="add-termin" @click="form.days.push('')">+ Termin</button>
</div>
<div v-if="!['none', 'days'].includes(form.repeatType)" class="field-row">
<div class="field">
<label for="start">Start</label>
<input id="start" v-model="form.start" type="date" />
@@ -249,6 +265,35 @@ button:disabled {
cursor: not-allowed;
}
.days-row {
display: flex;
gap: 0.5rem;
}
.days-row input {
flex: 1;
}
.icon-btn {
width: 2.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
background: transparent;
border: 1px solid var(--border);
border-radius: 0.375rem;
cursor: pointer;
}
.icon-btn:hover {
background: var(--breadcrumb-bg);
}
.add-termin {
align-self: flex-start;
}
.error {
color: #dc2626;
}

View File

@@ -2,6 +2,7 @@
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useSchemasStore } from '../stores/schemas'
import Icon from '../components/Icon.vue'
const route = useRoute()
const router = useRouter()
@@ -19,6 +20,7 @@ const form = ref({
end: '',
weekly: Array(7).fill(false),
monthly: Array(31).fill(false),
days: [],
})
const submitting = ref(false)
const error = ref(null)
@@ -37,6 +39,7 @@ function detectRepeatType(schema) {
if (schema.repeat['2week']) return '2week'
if (schema.repeat['4week']) return '4week'
if (schema.repeat.monthly) return 'monthly'
if (schema.repeat.days) return 'days'
return 'none'
}
@@ -55,6 +58,7 @@ function loadFromSchema(schema) {
: schema.repeat?.['4week'] ? [...schema.repeat['4week']]
: Array(7).fill(false),
monthly: schema.repeat?.monthly ? [...schema.repeat.monthly] : Array(31).fill(false),
days: schema.repeat?.days ? [...schema.repeat.days] : [],
}
}
@@ -81,6 +85,8 @@ function buildPayload() {
data.repeat = { '4week': [...form.value.weekly] }
} else if (form.value.repeatType === 'monthly') {
data.repeat = { monthly: [...form.value.monthly] }
} else if (form.value.repeatType === 'days') {
data.repeat = { days: form.value.days.filter(d => d) }
}
}
@@ -110,11 +116,6 @@ async function onUpdate() {
}
}
function onReset() {
if (original.value) {
form.value = loadFromSchema(original.value)
}
}
</script>
<template>
@@ -151,6 +152,7 @@ function onReset() {
<option value="2week">2-Wöchentlich</option>
<option value="4week">4-Wöchentlich</option>
<option value="monthly">Monatlich</option>
<option value="days">Mehrere Tage</option>
</select>
</div>
@@ -179,7 +181,18 @@ function onReset() {
</div>
</div>
<div v-if="form.repeatType !== 'none'" class="field-row">
<div v-if="form.repeatType === 'days'" class="field">
<label>Termine</label>
<div v-for="(d, i) in form.days" :key="i" class="days-row">
<input type="date" v-model="form.days[i]" />
<button type="button" class="icon-btn" @click="form.days.splice(i, 1)" title="Entfernen">
<Icon name="trash" />
</button>
</div>
<button type="button" class="add-termin" @click="form.days.push('')">+ Termin</button>
</div>
<div v-if="!['none', 'days'].includes(form.repeatType)" class="field-row">
<div class="field">
<label for="start">Start</label>
<input id="start" v-model="form.start" type="date" />
@@ -194,7 +207,6 @@ function onReset() {
<div class="form-actions">
<button type="submit" :disabled="submitting || !form.name">Aktualisieren</button>
<button type="button" @click="onReset">Zurücksetzen</button>
<button type="button" @click="router.back()">Abbrechen</button>
</div>
</form>
@@ -303,6 +315,35 @@ button:disabled {
cursor: not-allowed;
}
.days-row {
display: flex;
gap: 0.5rem;
}
.days-row input {
flex: 1;
}
.icon-btn {
width: 2.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
background: transparent;
border: 1px solid var(--border);
border-radius: 0.375rem;
cursor: pointer;
}
.icon-btn:hover {
background: var(--breadcrumb-bg);
}
.add-termin {
align-self: flex-start;
}
.error {
color: #dc2626;
}

View File

@@ -54,12 +54,6 @@ async function onUpdate() {
}
}
function onReset() {
if (original.value) {
form.value = loadFromTask(original.value)
}
}
function onAbort() {
router.back()
}
@@ -90,7 +84,6 @@ function onAbort() {
<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>

View File

@@ -20,8 +20,8 @@
- start page: /tasks button
- tasks page: list tasks (name, toggle onclick), only period, group by date, no-date first, hide inactive, navigation (schemas, create, list, toggle, refresh)
- all tasks page: list all task (name, toggle onclick, edit, delete), sort by date desc, no-date-first, navigation (schemas, create, refresh)
- edit page: form (name, date, status), current values, buttons(save, reset, abort) remove schema on update
- navigation: calender icon (schemas), + icon (create), list icon (all tasks), eye icon (toggle), arrow (refresh), pencil icon (edit), bin icon (delete), save icon (save), reset icon (reset), abort icon (abort)
- edit page: form (name, date, status), current values, buttons(save, abort) remove schema on update
- navigation: calender icon (schemas), + icon (create), list icon (all tasks), eye icon (toggle), arrow (refresh), pencil icon (edit), bin icon (delete), save icon (save), abort icon (abort)
# AppUpdate module
- version: public/app/version.json
@@ -35,7 +35,7 @@
- type(single, !date): just create task
- type(single, date): create schema, schema creates task
- type(repeat, daily/weekly/2weekly/4weekly/monthly): create schema, schema creates tasks
- type(repeat, days): + icon (add input:date), add multiple dates, like single+date in bulk, create schema, schema creates tasks
- type(repeat, days): + icon (add input:date), add multiple dates, like single+date in bulk, create schema, schema creates tasks, bin icon (remove date)
- schema: creates tasks in period, schema update and delete affects only tasks in period (no-past), start(today if null), delete schema (if end < today)
- schema create: create tasks in period
- schema update: remove and create task in period (if task ref schema)