diff --git a/app/.gitignore b/app/.gitignore index 8e3d472..24fd7c0 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -10,4 +10,3 @@ .cxx local.properties app/build -*.jks diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts index 777b017..7c91730 100644 --- a/app/app/build.gradle.kts +++ b/app/app/build.gradle.kts @@ -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 } } diff --git a/app/app/src/main/java/de/haushalt/app/ui/schema/SchemaAllScreen.kt b/app/app/src/main/java/de/haushalt/app/ui/schema/SchemaAllScreen.kt index 1090141..748f0e3 100644 --- a/app/app/src/main/java/de/haushalt/app/ui/schema/SchemaAllScreen.kt +++ b/app/app/src/main/java/de/haushalt/app/ui/schema/SchemaAllScreen.kt @@ -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 -> "" } diff --git a/app/app/src/main/java/de/haushalt/app/ui/schema/SchemaComponents.kt b/app/app/src/main/java/de/haushalt/app/ui/schema/SchemaComponents.kt index b3ff08d..04ba851 100644 --- a/app/app/src/main/java/de/haushalt/app/ui/schema/SchemaComponents.kt +++ b/app/app/src/main/java/de/haushalt/app/ui/schema/SchemaComponents.kt @@ -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, + onChange: (List) -> 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") + } +} diff --git a/app/app/src/main/java/de/haushalt/app/ui/schema/SchemaCreateScreen.kt b/app/app/src/main/java/de/haushalt/app/ui/schema/SchemaCreateScreen.kt index efefbbc..6955a11 100644 --- a/app/app/src/main/java/de/haushalt/app/ui/schema/SchemaCreateScreen.kt +++ b/app/app/src/main/java/de/haushalt/app/ui/schema/SchemaCreateScreen.kt @@ -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), diff --git a/app/app/src/main/java/de/haushalt/app/ui/schema/SchemaCreateViewModel.kt b/app/app/src/main/java/de/haushalt/app/ui/schema/SchemaCreateViewModel.kt index 8cd244d..117289f 100644 --- a/app/app/src/main/java/de/haushalt/app/ui/schema/SchemaCreateViewModel.kt +++ b/app/app/src/main/java/de/haushalt/app/ui/schema/SchemaCreateViewModel.kt @@ -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()) var isSubmitting by mutableStateOf(false) private set var error by mutableStateOf(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 } diff --git a/app/app/src/main/java/de/haushalt/app/ui/schema/SchemaEditScreen.kt b/app/app/src/main/java/de/haushalt/app/ui/schema/SchemaEditScreen.kt index 492b74f..d3ccb51 100644 --- a/app/app/src/main/java/de/haushalt/app/ui/schema/SchemaEditScreen.kt +++ b/app/app/src/main/java/de/haushalt/app/ui/schema/SchemaEditScreen.kt @@ -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") } diff --git a/app/app/src/main/java/de/haushalt/app/ui/schema/SchemaEditViewModel.kt b/app/app/src/main/java/de/haushalt/app/ui/schema/SchemaEditViewModel.kt index 5b95b6f..1860a67 100644 --- a/app/app/src/main/java/de/haushalt/app/ui/schema/SchemaEditViewModel.kt +++ b/app/app/src/main/java/de/haushalt/app/ui/schema/SchemaEditViewModel.kt @@ -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()) var isLoading by mutableStateOf(false) private set var isSubmitting by mutableStateOf(false) @@ -35,15 +37,12 @@ class SchemaEditViewModel : ViewModel() { var error by mutableStateOf(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 } diff --git a/app/app/src/main/java/de/haushalt/app/ui/task/TaskEditScreen.kt b/app/app/src/main/java/de/haushalt/app/ui/task/TaskEditScreen.kt index 371f610..677c7d2 100644 --- a/app/app/src/main/java/de/haushalt/app/ui/task/TaskEditScreen.kt +++ b/app/app/src/main/java/de/haushalt/app/ui/task/TaskEditScreen.kt @@ -69,9 +69,6 @@ fun TaskEditScreen( ) { Text("Aktualisieren") } - OutlinedButton(onClick = { viewModel.reset() }) { - Text("Zurücksetzen") - } OutlinedButton(onClick = { navController.popBackStack() }) { Text("Abbrechen") } diff --git a/app/app/src/main/java/de/haushalt/app/ui/task/TaskEditViewModel.kt b/app/app/src/main/java/de/haushalt/app/ui/task/TaskEditViewModel.kt index 400cabe..6382427 100644 --- a/app/app/src/main/java/de/haushalt/app/ui/task/TaskEditViewModel.kt +++ b/app/app/src/main/java/de/haushalt/app/ui/task/TaskEditViewModel.kt @@ -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) ?: "" diff --git a/app/haushalt.jks b/app/haushalt.jks new file mode 100755 index 0000000..1ceb3e7 Binary files /dev/null and b/app/haushalt.jks differ diff --git a/backend/config/packages/messenger.yaml b/backend/config/packages/messenger.yaml index 70886b0..56333f7 100644 --- a/backend/config/packages/messenger.yaml +++ b/backend/config/packages/messenger.yaml @@ -2,7 +2,3 @@ framework: messenger: transports: sync: 'sync://' - scheduler_default: 'scheduler://default' - - routing: - App\Message\GenerateTasksMessage: scheduler_default diff --git a/backend/public/app/haushalt.apk b/backend/public/app/haushalt.apk index 978f40e..d072eb8 100644 Binary files a/backend/public/app/haushalt.apk and b/backend/public/app/haushalt.apk differ diff --git a/backend/public/app/version.json b/backend/public/app/version.json index 89d93d9..25766d2 100644 --- a/backend/public/app/version.json +++ b/backend/public/app/version.json @@ -1,4 +1,4 @@ { - "versionCode": 4, + "versionCode": 5, "apkFile": "haushalt.apk" } diff --git a/backend/src/DTO/TaskSchemaRequest.php b/backend/src/DTO/TaskSchemaRequest.php index 9859fa3..df2dce2 100644 --- a/backend/src/DTO/TaskSchemaRequest.php +++ b/backend/src/DTO/TaskSchemaRequest.php @@ -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'])); + } } } diff --git a/backend/src/Repository/TaskSchemaRepository.php b/backend/src/Repository/TaskSchemaRepository.php index 48af970..23c763e 100644 --- a/backend/src/Repository/TaskSchemaRepository.php +++ b/backend/src/Repository/TaskSchemaRepository.php @@ -32,4 +32,15 @@ class TaskSchemaRepository extends ServiceEntityRepository ->getQuery() ->getResult(); } + + /** @return list */ + 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(); + } } diff --git a/backend/src/Schedule.php b/backend/src/Schedule.php index b6860cf..b762edf 100644 --- a/backend/src/Schedule.php +++ b/backend/src/Schedule.php @@ -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'))); } } diff --git a/backend/src/Service/TaskGenerator.php b/backend/src/Service/TaskGenerator.php index 6a4fb29..ebec417 100644 --- a/backend/src/Service/TaskGenerator.php +++ b/backend/src/Service/TaskGenerator.php @@ -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,12 +51,26 @@ class TaskGenerator $dates = $this->getDates($schema); foreach ($dates as $date) { - $task = new Task(); - $task->setName($schema->getName()); - $task->setDate($date); - $task->setStatus($schema->getTaskStatus()); - $task->setSchema($schema); - $this->em->persist($task); + $this->createTask($schema, $date); + } + } + + private function createTask(TaskSchema $schema, \DateTimeImmutable $date): void + { + $task = new Task(); + $task->setName($schema->getName()); + $task->setDate($date); + $task->setStatus($schema->getTaskStatus()); + $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); } } @@ -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; } } diff --git a/frontend/src/views/SchemaAll.vue b/frontend/src/views/SchemaAll.vue index 5fe5c44..3de61eb 100644 --- a/frontend/src/views/SchemaAll.vue +++ b/frontend/src/views/SchemaAll.vue @@ -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 '' } diff --git a/frontend/src/views/SchemaCreate.vue b/frontend/src/views/SchemaCreate.vue index 8005d60..52b4343 100644 --- a/frontend/src/views/SchemaCreate.vue +++ b/frontend/src/views/SchemaCreate.vue @@ -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() { + @@ -128,7 +133,18 @@ async function onSave() { -
+
+ +
+ + +
+ +
+ +
@@ -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; } diff --git a/frontend/src/views/SchemaEdit.vue b/frontend/src/views/SchemaEdit.vue index 98771ac..6b800cd 100644 --- a/frontend/src/views/SchemaEdit.vue +++ b/frontend/src/views/SchemaEdit.vue @@ -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) - } -}