diff --git a/.ddev/config.yaml b/.ddev/config.yaml index d001d3f..2bfad4c 100644 --- a/.ddev/config.yaml +++ b/.ddev/config.yaml @@ -27,6 +27,9 @@ web_extra_daemons: - name: vite command: "npm run dev" directory: /var/www/html/frontend +- name: scheduler + command: "php /var/www/html/backend/bin/console messenger:consume scheduler_default --time-limit=3600" + directory: /var/www/html/backend # Key features of DDEV's config.yaml: diff --git a/app/app/src/main/java/de/haushalt/app/MainScreen.kt b/app/app/src/main/java/de/haushalt/app/MainScreen.kt index 54b1765..41727c7 100644 --- a/app/app/src/main/java/de/haushalt/app/MainScreen.kt +++ b/app/app/src/main/java/de/haushalt/app/MainScreen.kt @@ -22,8 +22,10 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument +import de.haushalt.app.ui.schema.SchemaAllScreen +import de.haushalt.app.ui.schema.SchemaCreateScreen +import de.haushalt.app.ui.schema.SchemaEditScreen 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 @@ -35,16 +37,28 @@ private val trailMap: Map>> = mapOf( "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", ), + "schemas" to listOf( + "start" to "Haushalt", + "tasks" to "Tasks", + "schemas" to "Schemas", + ), + "schemas/create" to listOf( + "start" to "Haushalt", + "tasks" to "Tasks", + "schemas" to "Schemas", + "schemas/create" to "Create", + ), + "schemas/{id}" to listOf( + "start" to "Haushalt", + "tasks" to "Tasks", + "schemas" to "Schemas", + "schemas/{id}" to "Edit", + ), ) @Composable @@ -70,9 +84,6 @@ fun MainScreen() { composable("tasks/all") { TaskAllScreen(navController = navController) } - composable("tasks/create") { - TaskCreateScreen(navController = navController) - } composable( route = "tasks/{id}", arguments = listOf(navArgument("id") { type = NavType.IntType }) @@ -80,6 +91,19 @@ fun MainScreen() { val id = entry.arguments?.getInt("id") ?: return@composable TaskEditScreen(navController = navController, taskId = id) } + composable("schemas") { + SchemaAllScreen(navController = navController) + } + composable("schemas/create") { + SchemaCreateScreen(navController = navController) + } + composable( + route = "schemas/{id}", + arguments = listOf(navArgument("id") { type = NavType.IntType }) + ) { entry -> + val id = entry.arguments?.getInt("id") ?: return@composable + SchemaEditScreen(navController = navController, schemaId = id) + } } } } diff --git a/app/app/src/main/java/de/haushalt/app/data/ApiClient.kt b/app/app/src/main/java/de/haushalt/app/data/ApiClient.kt index 8890602..9de2e72 100644 --- a/app/app/src/main/java/de/haushalt/app/data/ApiClient.kt +++ b/app/app/src/main/java/de/haushalt/app/data/ApiClient.kt @@ -29,6 +29,7 @@ object ApiClient { .build() val taskApi: TaskApi = retrofit.create(TaskApi::class.java) + val schemaApi: TaskSchemaApi = retrofit.create(TaskSchemaApi::class.java) val appUpdateApi: AppUpdateApi = Retrofit.Builder() .baseUrl(BASE_URL) diff --git a/app/app/src/main/java/de/haushalt/app/data/TaskApi.kt b/app/app/src/main/java/de/haushalt/app/data/TaskApi.kt index c6ff4f7..32185e5 100644 --- a/app/app/src/main/java/de/haushalt/app/data/TaskApi.kt +++ b/app/app/src/main/java/de/haushalt/app/data/TaskApi.kt @@ -16,9 +16,6 @@ interface TaskApi { @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 diff --git a/app/app/src/main/java/de/haushalt/app/data/TaskSchema.kt b/app/app/src/main/java/de/haushalt/app/data/TaskSchema.kt new file mode 100644 index 0000000..b9fbe26 --- /dev/null +++ b/app/app/src/main/java/de/haushalt/app/data/TaskSchema.kt @@ -0,0 +1,32 @@ +package de.haushalt.app.data + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +@Serializable +enum class TaskSchemaStatus { + active, inactive, +} + +@Serializable +data class TaskSchema( + val id: Int? = null, + val name: String, + val status: TaskSchemaStatus = TaskSchemaStatus.active, + val taskStatus: TaskStatus = TaskStatus.active, + val date: String? = null, + val repeat: JsonObject? = null, + val start: String? = null, + val end: String? = null, +) + +@Serializable +data class TaskSchemaRequest( + val name: String, + val status: TaskSchemaStatus = TaskSchemaStatus.active, + val taskStatus: TaskStatus = TaskStatus.active, + val date: String? = null, + val repeat: JsonObject? = null, + val start: String? = null, + val end: String? = null, +) diff --git a/app/app/src/main/java/de/haushalt/app/data/TaskSchemaApi.kt b/app/app/src/main/java/de/haushalt/app/data/TaskSchemaApi.kt new file mode 100644 index 0000000..63936d8 --- /dev/null +++ b/app/app/src/main/java/de/haushalt/app/data/TaskSchemaApi.kt @@ -0,0 +1,26 @@ +package de.haushalt.app.data + +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.PUT +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query + +interface TaskSchemaApi { + @GET("task-schemas") + suspend fun list(): List + + @GET("task-schemas/{id}") + suspend fun get(@Path("id") id: Int): TaskSchema + + @POST("task-schemas") + suspend fun create(@Body body: TaskSchemaRequest): TaskSchema + + @PUT("task-schemas/{id}") + suspend fun update(@Path("id") id: Int, @Body body: TaskSchemaRequest): TaskSchema + + @DELETE("task-schemas/{id}") + suspend fun delete(@Path("id") id: Int) +} 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 new file mode 100644 index 0000000..95b542a --- /dev/null +++ b/app/app/src/main/java/de/haushalt/app/ui/schema/SchemaAllScreen.kt @@ -0,0 +1,134 @@ +package de.haushalt.app.ui.schema + +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.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.text.font.FontStyle +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import de.haushalt.app.data.TaskSchema +import de.haushalt.app.data.TaskSchemaStatus + +@Composable +fun SchemaAllScreen( + navController: NavController, + viewModel: SchemaAllViewModel = viewModel(), +) { + 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("schemas/create") }) { + Icon(Icons.Filled.Add, contentDescription = "Neues Schema") + } + IconButton(onClick = { viewModel.refresh() }) { + Icon(Icons.Filled.Refresh, contentDescription = "Neu laden") + } + } + + Spacer(Modifier.padding(4.dp)) + + when { + viewModel.isLoading && viewModel.schemas.isEmpty() -> Text("Lädt…") + viewModel.error != null -> Text(viewModel.error ?: "", color = MaterialTheme.colorScheme.error) + viewModel.schemas.isEmpty() -> Text("Keine Schemas.") + else -> LazyColumn(modifier = Modifier.fillMaxSize()) { + items(viewModel.schemas, key = { it.id ?: 0 }) { schema -> + SchemaRow( + schema = schema, + onEdit = { schema.id?.let { navController.navigate("schemas/$it") } }, + onDelete = { schema.id?.let(viewModel::delete) }, + ) + HorizontalDivider() + } + } + } + } +} + +@Composable +private fun SchemaRow( + schema: TaskSchema, + onEdit: () -> Unit, + onDelete: () -> Unit, +) { + val repeatLabel = when { + schema.repeat == null -> "Einmalig" + schema.repeat.containsKey("daily") -> "Täglich" + schema.repeat.containsKey("weekly") -> "Wöchentlich" + schema.repeat.containsKey("monthly") -> "Monatlich" + else -> "" + } + + Row( + modifier = Modifier + .fillMaxWidth() + .alpha(if (schema.status == TaskSchemaStatus.inactive) 0.5f else 1f) + .padding(vertical = 8.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = schema.name, + style = MaterialTheme.typography.bodyLarge, + fontStyle = if (schema.status == TaskSchemaStatus.inactive) FontStyle.Italic else null, + ) + Text( + text = repeatLabel, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = onEdit) { + Icon(Icons.Filled.Edit, contentDescription = "Bearbeiten") + } + IconButton(onClick = onDelete) { + Icon(Icons.Filled.Delete, contentDescription = "Löschen") + } + } +} diff --git a/app/app/src/main/java/de/haushalt/app/ui/schema/SchemaAllViewModel.kt b/app/app/src/main/java/de/haushalt/app/ui/schema/SchemaAllViewModel.kt new file mode 100644 index 0000000..9cd02d1 --- /dev/null +++ b/app/app/src/main/java/de/haushalt/app/ui/schema/SchemaAllViewModel.kt @@ -0,0 +1,48 @@ +package de.haushalt.app.ui.schema + +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.TaskSchema +import kotlinx.coroutines.launch + +class SchemaAllViewModel : ViewModel() { + var schemas by mutableStateOf>(emptyList()) + private set + var isLoading by mutableStateOf(false) + private set + var error by mutableStateOf(null) + private set + + init { + refresh() + } + + fun refresh() { + viewModelScope.launch { + isLoading = true + error = null + try { + schemas = ApiClient.schemaApi.list() + } catch (e: Exception) { + error = e.message + } finally { + isLoading = false + } + } + } + + fun delete(id: Int) { + viewModelScope.launch { + try { + ApiClient.schemaApi.delete(id) + schemas = schemas.filter { it.id != id } + } catch (e: Exception) { + error = e.message + } + } + } +} 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 new file mode 100644 index 0000000..5734d3d --- /dev/null +++ b/app/app/src/main/java/de/haushalt/app/ui/schema/SchemaComponents.kt @@ -0,0 +1,174 @@ +package de.haushalt.app.ui.schema + +import androidx.compose.foundation.BorderStroke +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +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 androidx.compose.ui.unit.dp +import de.haushalt.app.data.TaskSchemaStatus +import de.haushalt.app.data.TaskStatus + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SchemaStatusDropdown( + current: TaskSchemaStatus, + onChange: (TaskSchemaStatus) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }, modifier = modifier) { + OutlinedTextField( + value = current.name, + onValueChange = {}, + readOnly = true, + label = { Text("Schema Status") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, + modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + TaskSchemaStatus.entries.forEach { status -> + DropdownMenuItem( + text = { Text(status.name) }, + onClick = { onChange(status); expanded = false }, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TaskStatusDropdown( + current: TaskStatus, + onChange: (TaskStatus) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + val selectable = TaskStatus.entries.filter { it != TaskStatus.past } + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }, modifier = modifier) { + OutlinedTextField( + value = current.name, + onValueChange = {}, + readOnly = true, + label = { Text("Task Status") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, + modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + selectable.forEach { status -> + DropdownMenuItem( + text = { Text(status.name) }, + onClick = { onChange(status); expanded = false }, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RepeatTypeDropdown( + current: String, + onChange: (String) -> Unit, +) { + val options = listOf("none" to "Keine (Einmalig)", "daily" to "Täglich", "weekly" to "Wöchentlich", "monthly" to "Monatlich") + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { + OutlinedTextField( + value = options.firstOrNull { it.first == current }?.second ?: "", + onValueChange = {}, + readOnly = true, + label = { Text("Wiederholung") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, + modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + options.forEach { (key, label) -> + DropdownMenuItem( + text = { Text(label) }, + onClick = { onChange(key); expanded = false }, + ) + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun WeekdaySelector( + selected: List, + onChange: (List) -> Unit, +) { + val days = listOf("Mo", "Di", "Mi", "Do", "Fr", "Sa", "So") + Text("Wochentage", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + days.forEachIndexed { i, day -> + val isSelected = selected[i] + Surface( + modifier = Modifier.clickable { + onChange(selected.toMutableList().also { it[i] = !it[i] }) + }, + shape = RoundedCornerShape(6.dp), + border = BorderStroke(1.dp, if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline), + color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface, + ) { + Text( + text = day, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun MonthdaySelector( + selected: List, + onChange: (List) -> Unit, +) { + Text("Monatstage", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + for (d in 1..31) { + val isSelected = selected[d - 1] + Surface( + modifier = Modifier.size(40.dp).clickable { + onChange(selected.toMutableList().also { it[d - 1] = !it[d - 1] }) + }, + shape = RoundedCornerShape(6.dp), + border = BorderStroke(1.dp, if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline), + color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface, + ) { + Text( + text = "$d", + modifier = Modifier.padding(4.dp), + style = MaterialTheme.typography.bodySmall, + ) + } + } + } +} 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 new file mode 100644 index 0000000..90db261 --- /dev/null +++ b/app/app/src/main/java/de/haushalt/app/ui/schema/SchemaCreateScreen.kt @@ -0,0 +1,124 @@ +package de.haushalt.app.ui.schema + +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.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +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 +import de.haushalt.app.data.TaskSchemaStatus +import de.haushalt.app.data.TaskStatus +import de.haushalt.app.ui.task.DatePickerField + +@Composable +fun SchemaCreateScreen( + navController: NavController, + viewModel: SchemaCreateViewModel = viewModel(), +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedTextField( + value = viewModel.name, + onValueChange = { viewModel.name = it }, + label = { Text("Name") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + SchemaStatusDropdown( + current = viewModel.status, + onChange = { viewModel.status = it }, + modifier = Modifier.weight(1f), + ) + TaskStatusDropdown( + current = viewModel.taskStatus, + onChange = { viewModel.taskStatus = it }, + modifier = Modifier.weight(1f), + ) + } + + RepeatTypeDropdown( + current = viewModel.repeatType, + onChange = { viewModel.repeatType = it }, + ) + + if (viewModel.repeatType == "none") { + DatePickerField( + value = viewModel.date, + onChange = { viewModel.date = it }, + label = "Datum", + ) + } + + if (viewModel.repeatType == "weekly") { + WeekdaySelector( + selected = viewModel.weekly, + onChange = { viewModel.weekly = it }, + ) + } + + if (viewModel.repeatType == "monthly") { + MonthdaySelector( + selected = viewModel.monthly, + onChange = { viewModel.monthly = it }, + ) + } + + if (viewModel.repeatType != "none") { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + DatePickerField( + value = viewModel.start, + onChange = { viewModel.start = it }, + label = "Start", + modifier = Modifier.weight(1f), + ) + DatePickerField( + value = viewModel.end, + onChange = { viewModel.end = it }, + label = "Ende", + modifier = Modifier.weight(1f), + ) + } + } + + 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("Erstellen") + } + OutlinedButton(onClick = { navController.popBackStack() }) { + Text("Abbrechen") + } + } + } +} 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 new file mode 100644 index 0000000..c00dfac --- /dev/null +++ b/app/app/src/main/java/de/haushalt/app/ui/schema/SchemaCreateViewModel.kt @@ -0,0 +1,65 @@ +package de.haushalt.app.ui.schema + +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.TaskSchemaRequest +import de.haushalt.app.data.TaskSchemaStatus +import de.haushalt.app.data.TaskStatus +import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray + +class SchemaCreateViewModel : ViewModel() { + var name by mutableStateOf("") + var status by mutableStateOf(TaskSchemaStatus.active) + var taskStatus by mutableStateOf(TaskStatus.active) + var repeatType by mutableStateOf("none") + var date by mutableStateOf("") + var start by mutableStateOf("") + var end by mutableStateOf("") + var weekly by mutableStateOf(List(7) { false }) + var monthly by mutableStateOf(List(31) { false }) + var isSubmitting by mutableStateOf(false) + private set + var error by mutableStateOf(null) + private set + + fun save(onSuccess: () -> Unit) { + viewModelScope.launch { + isSubmitting = true + error = null + try { + ApiClient.schemaApi.create(buildRequest()) + onSuccess() + } catch (e: Exception) { + error = e.message + } finally { + isSubmitting = false + } + } + } + + private fun buildRequest(): TaskSchemaRequest { + val repeat = when (repeatType) { + "daily" -> JsonObject(mapOf("daily" to JsonPrimitive(true))) + "weekly" -> JsonObject(mapOf("weekly" to buildJsonArray { weekly.forEach { add(JsonPrimitive(it)) } })) + "monthly" -> JsonObject(mapOf("monthly" to buildJsonArray { monthly.forEach { add(JsonPrimitive(it)) } })) + else -> null + } + + return TaskSchemaRequest( + name = name, + status = status, + taskStatus = taskStatus, + date = if (repeatType == "none" && date.isNotBlank()) date else null, + repeat = repeat, + start = if (repeatType != "none" && start.isNotBlank()) start else null, + end = if (repeatType != "none" && end.isNotBlank()) end 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 new file mode 100644 index 0000000..57675e5 --- /dev/null +++ b/app/app/src/main/java/de/haushalt/app/ui/schema/SchemaEditScreen.kt @@ -0,0 +1,136 @@ +package de.haushalt.app.ui.schema + +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.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +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 +import de.haushalt.app.ui.task.DatePickerField + +@Composable +fun SchemaEditScreen( + navController: NavController, + schemaId: Int, + viewModel: SchemaEditViewModel = viewModel(), +) { + LaunchedEffect(schemaId) { + viewModel.load(schemaId) + } + + if (viewModel.isLoading) { + Text("Lädt…", modifier = Modifier.padding(16.dp)) + return + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedTextField( + value = viewModel.name, + onValueChange = { viewModel.name = it }, + label = { Text("Name") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + SchemaStatusDropdown( + current = viewModel.status, + onChange = { viewModel.status = it }, + modifier = Modifier.weight(1f), + ) + TaskStatusDropdown( + current = viewModel.taskStatus, + onChange = { viewModel.taskStatus = it }, + modifier = Modifier.weight(1f), + ) + } + + RepeatTypeDropdown( + current = viewModel.repeatType, + onChange = { viewModel.repeatType = it }, + ) + + if (viewModel.repeatType == "none") { + DatePickerField( + value = viewModel.date, + onChange = { viewModel.date = it }, + label = "Datum", + ) + } + + if (viewModel.repeatType == "weekly") { + WeekdaySelector( + selected = viewModel.weekly, + onChange = { viewModel.weekly = it }, + ) + } + + if (viewModel.repeatType == "monthly") { + MonthdaySelector( + selected = viewModel.monthly, + onChange = { viewModel.monthly = it }, + ) + } + + if (viewModel.repeatType != "none") { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + DatePickerField( + value = viewModel.start, + onChange = { viewModel.start = it }, + label = "Start", + modifier = Modifier.weight(1f), + ) + DatePickerField( + value = viewModel.end, + onChange = { viewModel.end = it }, + label = "Ende", + modifier = Modifier.weight(1f), + ) + } + } + + 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(schemaId) { navController.popBackStack() } }, + ) { + 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 new file mode 100644 index 0000000..ff22ab6 --- /dev/null +++ b/app/app/src/main/java/de/haushalt/app/ui/schema/SchemaEditViewModel.kt @@ -0,0 +1,125 @@ +package de.haushalt.app.ui.schema + +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.TaskSchema +import de.haushalt.app.data.TaskSchemaRequest +import de.haushalt.app.data.TaskSchemaStatus +import de.haushalt.app.data.TaskStatus +import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive + +class SchemaEditViewModel : ViewModel() { + var name by mutableStateOf("") + var status by mutableStateOf(TaskSchemaStatus.active) + var taskStatus by mutableStateOf(TaskStatus.active) + var repeatType by mutableStateOf("none") + var date by mutableStateOf("") + var start by mutableStateOf("") + var end by mutableStateOf("") + var weekly by mutableStateOf(List(7) { false }) + var monthly by mutableStateOf(List(31) { false }) + var isLoading by mutableStateOf(false) + private set + var isSubmitting by mutableStateOf(false) + private set + 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 + } finally { + isLoading = false + } + } + } + + fun update(id: Int, onSuccess: () -> Unit) { + viewModelScope.launch { + isSubmitting = true + error = null + try { + ApiClient.schemaApi.update(id, buildRequest()) + onSuccess() + } catch (e: Exception) { + error = e.message + } finally { + isSubmitting = false + } + } + } + + fun reset() { + original?.let { applySchema(it) } + } + + private fun applySchema(schema: TaskSchema) { + name = schema.name + status = schema.status + taskStatus = schema.taskStatus + date = schema.date?.take(10) ?: "" + start = schema.start?.take(10) ?: "" + end = schema.end?.take(10) ?: "" + + when { + schema.repeat == null -> { + repeatType = "none" + weekly = List(7) { false } + monthly = List(31) { false } + } + schema.repeat.containsKey("daily") -> { + repeatType = "daily" + weekly = List(7) { false } + monthly = List(31) { false } + } + schema.repeat.containsKey("weekly") -> { + repeatType = "weekly" + weekly = schema.repeat["weekly"]!!.jsonArray.map { it.jsonPrimitive.boolean } + monthly = List(31) { false } + } + schema.repeat.containsKey("monthly") -> { + repeatType = "monthly" + weekly = List(7) { false } + monthly = schema.repeat["monthly"]!!.jsonArray.map { it.jsonPrimitive.boolean } + } + } + } + + private fun buildRequest(): TaskSchemaRequest { + val repeat = when (repeatType) { + "daily" -> JsonObject(mapOf("daily" to JsonPrimitive(true))) + "weekly" -> JsonObject(mapOf("weekly" to buildJsonArray { weekly.forEach { add(JsonPrimitive(it)) } })) + "monthly" -> JsonObject(mapOf("monthly" to buildJsonArray { monthly.forEach { add(JsonPrimitive(it)) } })) + else -> null + } + + return TaskSchemaRequest( + name = name, + status = status, + taskStatus = taskStatus, + date = if (repeatType == "none" && date.isNotBlank()) date else null, + repeat = repeat, + start = if (repeatType != "none" && start.isNotBlank()) start else null, + end = if (repeatType != "none" && end.isNotBlank()) end else null, + ) + } +} diff --git a/app/app/src/main/java/de/haushalt/app/ui/task/TaskAllScreen.kt b/app/app/src/main/java/de/haushalt/app/ui/task/TaskAllScreen.kt index 4b39d02..3af8e6a 100644 --- a/app/app/src/main/java/de/haushalt/app/ui/task/TaskAllScreen.kt +++ b/app/app/src/main/java/de/haushalt/app/ui/task/TaskAllScreen.kt @@ -12,6 +12,7 @@ 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.DateRange import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Refresh @@ -65,8 +66,11 @@ fun TaskAllScreen( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, ) { - IconButton(onClick = { navController.navigate("tasks/create") }) { - Icon(Icons.Filled.Add, contentDescription = "Neuer Task") + IconButton(onClick = { navController.navigate("schemas") }) { + Icon(Icons.Filled.DateRange, contentDescription = "Schemas") + } + IconButton(onClick = { navController.navigate("schemas/create") }) { + Icon(Icons.Filled.Add, contentDescription = "Neues Schema") } IconButton(onClick = { viewModel.refresh() }) { Icon(Icons.Filled.Refresh, contentDescription = "Neu laden") diff --git a/app/app/src/main/java/de/haushalt/app/ui/task/TaskCreateScreen.kt b/app/app/src/main/java/de/haushalt/app/ui/task/TaskCreateScreen.kt deleted file mode 100644 index 8122818..0000000 --- a/app/app/src/main/java/de/haushalt/app/ui/task/TaskCreateScreen.kt +++ /dev/null @@ -1,66 +0,0 @@ -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") - } - } - } -} diff --git a/app/app/src/main/java/de/haushalt/app/ui/task/TaskCreateViewModel.kt b/app/app/src/main/java/de/haushalt/app/ui/task/TaskCreateViewModel.kt deleted file mode 100644 index 9e6ae7d..0000000 --- a/app/app/src/main/java/de/haushalt/app/ui/task/TaskCreateViewModel.kt +++ /dev/null @@ -1,54 +0,0 @@ -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>(emptyList()) - private set - var isSubmitting by mutableStateOf(false) - private set - var error by mutableStateOf(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 - } - } - } -} diff --git a/app/app/src/main/java/de/haushalt/app/ui/task/TaskScreen.kt b/app/app/src/main/java/de/haushalt/app/ui/task/TaskScreen.kt index a2b9ae7..0b050d5 100644 --- a/app/app/src/main/java/de/haushalt/app/ui/task/TaskScreen.kt +++ b/app/app/src/main/java/de/haushalt/app/ui/task/TaskScreen.kt @@ -17,6 +17,7 @@ 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.DateRange import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff @@ -66,8 +67,11 @@ fun TaskScreen( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, ) { - IconButton(onClick = { navController.navigate("tasks/create") }) { - Icon(Icons.Filled.Add, contentDescription = "Neuer Task") + IconButton(onClick = { navController.navigate("schemas") }) { + Icon(Icons.Filled.DateRange, contentDescription = "Schemas") + } + IconButton(onClick = { navController.navigate("schemas/create") }) { + Icon(Icons.Filled.Add, contentDescription = "Neues Schema") } IconButton(onClick = { navController.navigate("tasks/all") }) { Icon(Icons.AutoMirrored.Filled.List, contentDescription = "Alle Tasks") diff --git a/backend/.env b/backend/.env index 08815ff..777d6c8 100644 --- a/backend/.env +++ b/backend/.env @@ -38,3 +38,10 @@ CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1|haushalt\.ddev\.site)(:[0-9 # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4" # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4" ###< doctrine/doctrine-bundle ### + +###> symfony/messenger ### +# Choose one of the transports below +# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages +# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages +MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 +###< symfony/messenger ### diff --git a/backend/composer.json b/backend/composer.json index 6fddd4c..a9df99d 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -19,9 +19,11 @@ "symfony/dotenv": "7.4.*", "symfony/flex": "^2", "symfony/framework-bundle": "7.4.*", + "symfony/messenger": "7.4.*", "symfony/property-access": "7.4.*", "symfony/property-info": "7.4.*", "symfony/runtime": "7.4.*", + "symfony/scheduler": "7.4.*", "symfony/serializer": "7.4.*", "symfony/validator": "7.4.*", "symfony/yaml": "7.4.*" diff --git a/backend/composer.lock b/backend/composer.lock index 107eb45..936fb01 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "36cb2b820400a518223222a28461ff75", + "content-hash": "3faf0714de84c701cf5b3c444ce28e8b", "packages": [ { "name": "doctrine/collections", @@ -1456,6 +1456,54 @@ }, "time": "2021-02-03T23:26:27+00:00" }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, { "name": "psr/container", "version": "2.0.2", @@ -1789,6 +1837,84 @@ ], "time": "2025-03-13T15:25:07+00:00" }, + { + "name": "symfony/clock", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/674fa3b98e21531dd040e613479f5f6fa8f32111", + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, { "name": "symfony/config", "version": "v7.4.7", @@ -3121,6 +3247,100 @@ ], "time": "2026-03-06T16:33:18+00:00" }, + { + "name": "symfony/messenger", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/messenger.git", + "reference": "ddf5ab29bc0329ece30e16f01c86abb6241e92d8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/messenger/zipball/ddf5ab29bc0329ece30e16f01c86abb6241e92d8", + "reference": "ddf5ab29bc0329ece30e16f01c86abb6241e92d8", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/console": "<7.2", + "symfony/event-dispatcher": "<6.4", + "symfony/event-dispatcher-contracts": "<2.5", + "symfony/framework-bundle": "<6.4", + "symfony/http-kernel": "<7.3", + "symfony/lock": "<7.4", + "symfony/serializer": "<6.4.32|>=7.3,<7.3.10|>=7.4,<7.4.4|>=8.0,<8.0.4" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/console": "^7.2|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^7.3|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.32|~7.3.10|^7.4.4|^8.0.4", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Messenger\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Samuel Roze", + "email": "samuel.roze@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps applications send and receive messages to/from other applications or via message queues", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/messenger/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T12:55:43+00:00" + }, { "name": "symfony/polyfill-intl-grapheme", "version": "v1.33.0", @@ -3952,6 +4172,91 @@ ], "time": "2025-12-05T14:04:53+00:00" }, + { + "name": "symfony/scheduler", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/scheduler.git", + "reference": "f95e696edaad466db9b087a6480ef936c766c3de" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/scheduler/zipball/f95e696edaad466db9b087a6480ef936c766c3de", + "reference": "f95e696edaad466db9b087a6480ef936c766c3de", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/clock": "^6.4|^7.0|^8.0" + }, + "require-dev": { + "dragonmantank/cron-expression": "^3.1", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.1|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Scheduler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sergey Rabochiy", + "email": "upyx.00@gmail.com" + }, + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides scheduling through Symfony Messenger", + "homepage": "https://symfony.com", + "keywords": [ + "cron", + "schedule", + "scheduler" + ], + "support": { + "source": "https://github.com/symfony/scheduler/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, { "name": "symfony/serializer", "version": "v7.4.7", diff --git a/backend/config/packages/messenger.yaml b/backend/config/packages/messenger.yaml new file mode 100644 index 0000000..70886b0 --- /dev/null +++ b/backend/config/packages/messenger.yaml @@ -0,0 +1,8 @@ +framework: + messenger: + transports: + sync: 'sync://' + scheduler_default: 'scheduler://default' + + routing: + App\Message\GenerateTasksMessage: scheduler_default diff --git a/backend/config/reference.php b/backend/config/reference.php index 54027e6..e4735f8 100644 --- a/backend/config/reference.php +++ b/backend/config/reference.php @@ -426,7 +426,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * resources?: array, * }, * messenger?: bool|array{ // Messenger configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * routing?: array, * }>, @@ -468,7 +468,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * }>, * }, * scheduler?: bool|array{ // Scheduler configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * }, * disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true * http_client?: bool|array{ // HTTP Client configuration diff --git a/backend/migrations/Version20260412094958.php b/backend/migrations/Version20260412094958.php new file mode 100644 index 0000000..570d0cf --- /dev/null +++ b/backend/migrations/Version20260412094958.php @@ -0,0 +1,37 @@ +addSql('CREATE TABLE task_schema (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, status VARCHAR(255) NOT NULL, task_status VARCHAR(255) NOT NULL, date DATE DEFAULT NULL, `repeat` JSON DEFAULT NULL, start DATE DEFAULT NULL, end DATE DEFAULT NULL, PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE task ADD schema_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE task ADD CONSTRAINT FK_527EDB25EA1BEF35 FOREIGN KEY (schema_id) REFERENCES task_schema (id) ON DELETE SET NULL'); + $this->addSql('CREATE INDEX IDX_527EDB25EA1BEF35 ON task (schema_id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE task_schema'); + $this->addSql('ALTER TABLE task DROP FOREIGN KEY FK_527EDB25EA1BEF35'); + $this->addSql('DROP INDEX IDX_527EDB25EA1BEF35 ON task'); + $this->addSql('ALTER TABLE task DROP schema_id'); + } +} diff --git a/backend/public/app/haushalt.apk b/backend/public/app/haushalt.apk new file mode 100644 index 0000000..1003943 Binary files /dev/null and b/backend/public/app/haushalt.apk differ diff --git a/backend/public/app/version.json b/backend/public/app/version.json index 0644ac1..cd50910 100644 --- a/backend/public/app/version.json +++ b/backend/public/app/version.json @@ -1,4 +1,4 @@ { - "versionCode": 1, + "versionCode": 2, "apkFile": "haushalt.apk" } diff --git a/backend/src/Collection/TaskSchemaCollection.php b/backend/src/Collection/TaskSchemaCollection.php new file mode 100644 index 0000000..6da6ba7 --- /dev/null +++ b/backend/src/Collection/TaskSchemaCollection.php @@ -0,0 +1,29 @@ + + */ +final class TaskSchemaCollection implements \IteratorAggregate +{ + /** @var list */ + private array $schemas = []; + + /** + * @param list $schemas + */ + public function __construct(array $schemas = []) + { + foreach ($schemas as $schema) { + $this->schemas[] = $schema; + } + } + + public function getIterator(): \Iterator + { + return new \ArrayIterator($this->schemas); + } +} diff --git a/backend/src/Controller/Api/TaskController.php b/backend/src/Controller/Api/TaskController.php index 166e014..af2aefd 100644 --- a/backend/src/Controller/Api/TaskController.php +++ b/backend/src/Controller/Api/TaskController.php @@ -45,14 +45,6 @@ class TaskController extends AbstractController 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 { diff --git a/backend/src/Controller/Api/TaskSchemaController.php b/backend/src/Controller/Api/TaskSchemaController.php new file mode 100644 index 0000000..e6807f7 --- /dev/null +++ b/backend/src/Controller/Api/TaskSchemaController.php @@ -0,0 +1,60 @@ +repo->allSchemas(); + + return $this->json($schemas, 200, [], ['groups' => ['task_schema:read']]); + } + + #[Route('/{id}', methods: ['GET'], requirements: ['id' => '\d+'])] + public function show(TaskSchema $schema): JsonResponse + { + return $this->json($schema, 200, [], ['groups' => ['task_schema:read']]); + } + + #[Route('', methods: ['POST'])] + public function create(#[Payload] TaskSchemaRequest $dto): JsonResponse + { + $this->manager->create($dto); + + return new JsonResponse(null, 201); + } + + #[Route('/{id}', methods: ['PUT'], requirements: ['id' => '\d+'])] + public function update(TaskSchema $schema, #[Payload] TaskSchemaRequest $dto): JsonResponse + { + $this->manager->update($schema, $dto); + + return $this->json($schema, 200, [], ['groups' => ['task_schema:read']]); + } + + #[Route('/{id}', methods: ['DELETE'], requirements: ['id' => '\d+'])] + public function delete(TaskSchema $schema): JsonResponse + { + $this->manager->delete($schema); + + return new JsonResponse(null, 204); + } +} diff --git a/backend/src/DTO/TaskSchemaRequest.php b/backend/src/DTO/TaskSchemaRequest.php new file mode 100644 index 0000000..9859fa3 --- /dev/null +++ b/backend/src/DTO/TaskSchemaRequest.php @@ -0,0 +1,36 @@ + '!Y-m-d'])] + public readonly ?\DateTimeImmutable $date = null, + + public readonly ?array $repeat = null, + + #[Context([DateTimeNormalizer::FORMAT_KEY => '!Y-m-d'])] + public readonly ?\DateTimeImmutable $start = null, + + #[Context([DateTimeNormalizer::FORMAT_KEY => '!Y-m-d'])] + public readonly ?\DateTimeImmutable $end = null, + ) { + } +} diff --git a/backend/src/Entity/Task.php b/backend/src/Entity/Task.php index c9461d2..e7cfcde 100644 --- a/backend/src/Entity/Task.php +++ b/backend/src/Entity/Task.php @@ -6,6 +6,7 @@ use App\Enum\TaskStatus; use App\Repository\TaskRepository; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; +use Symfony\Component\Serializer\Attribute\Ignore; #[ORM\Entity(repositoryClass: TaskRepository::class)] class Task @@ -27,6 +28,11 @@ class Task #[ORM\Column(enumType: TaskStatus::class)] private TaskStatus $status = TaskStatus::Active; + #[ORM\ManyToOne(targetEntity: TaskSchema::class, inversedBy: 'tasks')] + #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] + #[Ignore] + private ?TaskSchema $schema = null; + public function getId(): ?int { return $this->id; @@ -77,4 +83,16 @@ class Task return $this; } + + public function getSchema(): ?TaskSchema + { + return $this->schema; + } + + public function setSchema(?TaskSchema $schema): self + { + $this->schema = $schema; + + return $this; + } } diff --git a/backend/src/Entity/TaskSchema.php b/backend/src/Entity/TaskSchema.php new file mode 100644 index 0000000..72681ba --- /dev/null +++ b/backend/src/Entity/TaskSchema.php @@ -0,0 +1,158 @@ + */ + #[ORM\OneToMany(targetEntity: Task::class, mappedBy: 'schema')] + private Collection $tasks; + + public function __construct() + { + $this->tasks = new ArrayCollection(); + } + + 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 getStatus(): TaskSchemaStatus + { + return $this->status; + } + + public function setStatus(TaskSchemaStatus $status): self + { + $this->status = $status; + + return $this; + } + + public function getTaskStatus(): TaskStatus + { + return $this->taskStatus; + } + + public function setTaskStatus(TaskStatus $taskStatus): self + { + $this->taskStatus = $taskStatus; + + return $this; + } + + public function getDate(): ?\DateTimeImmutable + { + return $this->date; + } + + public function setDate(?\DateTimeImmutable $date): self + { + $this->date = $date; + + return $this; + } + + public function getRepeat(): ?array + { + return $this->repeat; + } + + public function getRepeatType(): ?string + { + return $this->repeat !== null ? array_key_first($this->repeat) : null; + } + + public function setRepeat(?array $repeat): self + { + $this->repeat = $repeat; + + return $this; + } + + public function getStart(): ?\DateTimeImmutable + { + return $this->start; + } + + public function setStart(?\DateTimeImmutable $start): self + { + $this->start = $start; + + return $this; + } + + public function getEnd(): ?\DateTimeImmutable + { + return $this->end; + } + + public function setEnd(?\DateTimeImmutable $end): self + { + $this->end = $end; + + return $this; + } + + /** @return Collection */ + public function getTasks(): Collection + { + return $this->tasks; + } +} diff --git a/backend/src/Enum/TaskSchemaStatus.php b/backend/src/Enum/TaskSchemaStatus.php new file mode 100644 index 0000000..8697488 --- /dev/null +++ b/backend/src/Enum/TaskSchemaStatus.php @@ -0,0 +1,9 @@ +generator->generateNewTasks(); + } +} diff --git a/backend/src/Repository/TaskSchemaRepository.php b/backend/src/Repository/TaskSchemaRepository.php new file mode 100644 index 0000000..4a7a28e --- /dev/null +++ b/backend/src/Repository/TaskSchemaRepository.php @@ -0,0 +1,36 @@ + + */ +class TaskSchemaRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, TaskSchema::class); + } + + public function allSchemas(): TaskSchemaCollection + { + return new TaskSchemaCollection(parent::findAll()); + } + + /** @return list */ + public function findActiveWithRepeat(): array + { + return $this->createQueryBuilder('s') + ->andWhere('s.status = :status') + ->andWhere('s.repeat IS NOT NULL') + ->setParameter('status', TaskSchemaStatus::Active) + ->getQuery() + ->getResult(); + } +} diff --git a/backend/src/Schedule.php b/backend/src/Schedule.php new file mode 100644 index 0000000..b6860cf --- /dev/null +++ b/backend/src/Schedule.php @@ -0,0 +1,27 @@ +stateful($this->cache) + ->processOnlyLastMissedRun(true) + ->add(RecurringMessage::cron('0 3 * * *', new GenerateTasksMessage())); + } +} diff --git a/backend/src/Service/TaskGenerator.php b/backend/src/Service/TaskGenerator.php new file mode 100644 index 0000000..10ec32f --- /dev/null +++ b/backend/src/Service/TaskGenerator.php @@ -0,0 +1,87 @@ +schemaRepo->findActiveWithRepeat(); + + foreach ($schemas as $schema) { + $this->removeTasks($schema); + $this->generateTasks($schema); + } + + $this->em->flush(); + } + + public function removeTasks(TaskSchema $schema): void + { + foreach ($schema->getTasks() as $task) { + if ($task->getStatus() === TaskStatus::Past) continue; + $this->em->remove($task); + } + } + + public function generateTasks(TaskSchema $schema): void + { + $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); + } + } + + /** @return array{\DateTimeImmutable, \DateTimeImmutable} */ + private function getDateRange(TaskSchema $schema): array + { + $today = new \DateTimeImmutable('today'); + $from = max($today, $schema->getStart() ?? $today); + $end = min($today->modify('+14 days'), $schema->getEnd() ?? $today->modify('+14 days')); + + return [$from, $end]; + } + + /** @return list<\DateTimeImmutable> */ + private function getDates(TaskSchema $schema): array + { + [$from, $end] = $this->getDateRange($schema); + $type = $schema->getRepeatType(); + $repeat = $schema->getRepeat(); + + $dates = []; + for ($date = $from; $date <= $end; $date = $date->modify('+1 day')) { + + if ($type === 'weekly') { + $weekday = (int) $date->format('N') - 1; + if(!$repeat['weekly'][$weekday]) continue; + } + if ($type === 'monthly') { + $monthday = (int) $date->format('j') - 1; + if(!$repeat['monthly'][$monthday]) continue; + } + + $dates[] = $date; + } + + return $dates; + } +} diff --git a/backend/src/Service/TaskManager.php b/backend/src/Service/TaskManager.php index dc1439c..27a6d27 100644 --- a/backend/src/Service/TaskManager.php +++ b/backend/src/Service/TaskManager.php @@ -13,19 +13,6 @@ class TaskManager { } - 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); diff --git a/backend/src/Service/TaskSchemaManager.php b/backend/src/Service/TaskSchemaManager.php new file mode 100644 index 0000000..21e9c7b --- /dev/null +++ b/backend/src/Service/TaskSchemaManager.php @@ -0,0 +1,79 @@ +repeat === null) { + $task = new Task(); + $task->setName($req->name); + $task->setDate($req->date); + $task->setStatus($req->taskStatus); + $this->em->persist($task); + $this->em->flush(); + + return; + } + + $schema = new TaskSchema(); + $schema->setName($req->name); + $schema->setStatus($req->status); + $schema->setTaskStatus($req->taskStatus); + $schema->setDate($req->date); + $schema->setRepeat($req->repeat); + $schema->setStart($req->start); + $schema->setEnd($req->end); + $this->em->persist($schema); + $this->em->flush(); + + if ($schema->getStatus() === TaskSchemaStatus::Inactive) { + return; + } + + $this->generator->generateTasks($schema); + $this->em->flush(); + } + + public function update(TaskSchema $schema, TaskSchemaRequest $req): void + { + $schema->setName($req->name); + $schema->setStatus($req->status); + $schema->setTaskStatus($req->taskStatus); + $schema->setDate($req->date); + $schema->setRepeat($req->repeat); + $schema->setStart($req->start); + $schema->setEnd($req->end); + + if ($schema->getStatus() === TaskSchemaStatus::Inactive) { + $this->em->flush(); + + return; + } + + $this->generator->removeTasks($schema); + $this->generator->generateTasks($schema); + $this->em->flush(); + } + + public function delete(TaskSchema $schema): void + { + $this->generator->removeTasks($schema); + $this->em->remove($schema); + $this->em->flush(); + } + +} diff --git a/backend/symfony.lock b/backend/symfony.lock index 22ea7cc..62ad89f 100644 --- a/backend/symfony.lock +++ b/backend/symfony.lock @@ -101,6 +101,18 @@ "ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f" } }, + "symfony/messenger": { + "version": "7.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "6.0", + "ref": "d8936e2e2230637ef97e5eecc0eea074eecae58b" + }, + "files": [ + "config/packages/messenger.yaml" + ] + }, "symfony/property-info": { "version": "7.4", "recipe": { @@ -126,6 +138,18 @@ "config/routes.yaml" ] }, + "symfony/scheduler": { + "version": "7.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.2", + "ref": "caea3c928ee9e1b21288fd76aef36f16ea355515" + }, + "files": [ + "src/Schedule.php" + ] + }, "symfony/validator": { "version": "7.4", "recipe": { diff --git a/backend/vendor/composer/autoload_files.php b/backend/vendor/composer/autoload_files.php index c1a160f..b01a577 100644 --- a/backend/vendor/composer/autoload_files.php +++ b/backend/vendor/composer/autoload_files.php @@ -13,6 +13,7 @@ return array( '8825ede83f2f289127722d4e842cf7e8' => $vendorDir . '/symfony/polyfill-intl-grapheme/bootstrap.php', 'e69f7f6ee287b969198c3c9d6777bd38' => $vendorDir . '/symfony/polyfill-intl-normalizer/bootstrap.php', 'b6b991a57620e2fb6b2f66f03fe9ddc2' => $vendorDir . '/symfony/string/Resources/functions.php', - '9d2b9fc6db0f153a0a149fefb182415e' => $vendorDir . '/symfony/polyfill-php84/bootstrap.php', '662a729f963d39afe703c9d9b7ab4a8c' => $vendorDir . '/symfony/polyfill-php83/bootstrap.php', + '9d2b9fc6db0f153a0a149fefb182415e' => $vendorDir . '/symfony/polyfill-php84/bootstrap.php', + '2203a247e6fda86070a5e4e07aed533a' => $vendorDir . '/symfony/clock/Resources/now.php', ); diff --git a/backend/vendor/composer/autoload_psr4.php b/backend/vendor/composer/autoload_psr4.php index 557e2d5..d193406 100644 --- a/backend/vendor/composer/autoload_psr4.php +++ b/backend/vendor/composer/autoload_psr4.php @@ -28,11 +28,13 @@ return array( 'Symfony\\Component\\String\\' => array($vendorDir . '/symfony/string'), 'Symfony\\Component\\Stopwatch\\' => array($vendorDir . '/symfony/stopwatch'), 'Symfony\\Component\\Serializer\\' => array($vendorDir . '/symfony/serializer'), + 'Symfony\\Component\\Scheduler\\' => array($vendorDir . '/symfony/scheduler'), 'Symfony\\Component\\Runtime\\' => array($vendorDir . '/symfony/runtime'), 'Symfony\\Component\\Routing\\' => array($vendorDir . '/symfony/routing'), 'Symfony\\Component\\PropertyInfo\\' => array($vendorDir . '/symfony/property-info'), 'Symfony\\Component\\PropertyAccess\\' => array($vendorDir . '/symfony/property-access'), 'Symfony\\Component\\Process\\' => array($vendorDir . '/symfony/process'), + 'Symfony\\Component\\Messenger\\' => array($vendorDir . '/symfony/messenger'), 'Symfony\\Component\\HttpKernel\\' => array($vendorDir . '/symfony/http-kernel'), 'Symfony\\Component\\HttpFoundation\\' => array($vendorDir . '/symfony/http-foundation'), 'Symfony\\Component\\Finder\\' => array($vendorDir . '/symfony/finder'), @@ -43,6 +45,7 @@ return array( 'Symfony\\Component\\DependencyInjection\\' => array($vendorDir . '/symfony/dependency-injection'), 'Symfony\\Component\\Console\\' => array($vendorDir . '/symfony/console'), 'Symfony\\Component\\Config\\' => array($vendorDir . '/symfony/config'), + 'Symfony\\Component\\Clock\\' => array($vendorDir . '/symfony/clock'), 'Symfony\\Component\\Cache\\' => array($vendorDir . '/symfony/cache'), 'Symfony\\Bundle\\MakerBundle\\' => array($vendorDir . '/symfony/maker-bundle/src'), 'Symfony\\Bundle\\FrameworkBundle\\' => array($vendorDir . '/symfony/framework-bundle'), @@ -50,6 +53,7 @@ return array( 'Psr\\Log\\' => array($vendorDir . '/psr/log/src'), 'Psr\\EventDispatcher\\' => array($vendorDir . '/psr/event-dispatcher/src'), 'Psr\\Container\\' => array($vendorDir . '/psr/container/src'), + 'Psr\\Clock\\' => array($vendorDir . '/psr/clock/src'), 'Psr\\Cache\\' => array($vendorDir . '/psr/cache/src'), 'PhpParser\\' => array($vendorDir . '/nikic/php-parser/lib/PhpParser'), 'PHPStan\\PhpDocParser\\' => array($vendorDir . '/phpstan/phpdoc-parser/src'), diff --git a/backend/vendor/composer/autoload_static.php b/backend/vendor/composer/autoload_static.php index e95e1f7..2b0bde4 100644 --- a/backend/vendor/composer/autoload_static.php +++ b/backend/vendor/composer/autoload_static.php @@ -14,8 +14,9 @@ class ComposerStaticInit75e7f8d848176580e9902a32f6f14640 '8825ede83f2f289127722d4e842cf7e8' => __DIR__ . '/..' . '/symfony/polyfill-intl-grapheme/bootstrap.php', 'e69f7f6ee287b969198c3c9d6777bd38' => __DIR__ . '/..' . '/symfony/polyfill-intl-normalizer/bootstrap.php', 'b6b991a57620e2fb6b2f66f03fe9ddc2' => __DIR__ . '/..' . '/symfony/string/Resources/functions.php', - '9d2b9fc6db0f153a0a149fefb182415e' => __DIR__ . '/..' . '/symfony/polyfill-php84/bootstrap.php', '662a729f963d39afe703c9d9b7ab4a8c' => __DIR__ . '/..' . '/symfony/polyfill-php83/bootstrap.php', + '9d2b9fc6db0f153a0a149fefb182415e' => __DIR__ . '/..' . '/symfony/polyfill-php84/bootstrap.php', + '2203a247e6fda86070a5e4e07aed533a' => __DIR__ . '/..' . '/symfony/clock/Resources/now.php', ); public static $prefixLengthsPsr4 = array ( @@ -49,11 +50,13 @@ class ComposerStaticInit75e7f8d848176580e9902a32f6f14640 'Symfony\\Component\\String\\' => 25, 'Symfony\\Component\\Stopwatch\\' => 28, 'Symfony\\Component\\Serializer\\' => 29, + 'Symfony\\Component\\Scheduler\\' => 28, 'Symfony\\Component\\Runtime\\' => 26, 'Symfony\\Component\\Routing\\' => 26, 'Symfony\\Component\\PropertyInfo\\' => 31, 'Symfony\\Component\\PropertyAccess\\' => 33, 'Symfony\\Component\\Process\\' => 26, + 'Symfony\\Component\\Messenger\\' => 28, 'Symfony\\Component\\HttpKernel\\' => 29, 'Symfony\\Component\\HttpFoundation\\' => 33, 'Symfony\\Component\\Finder\\' => 25, @@ -64,6 +67,7 @@ class ComposerStaticInit75e7f8d848176580e9902a32f6f14640 'Symfony\\Component\\DependencyInjection\\' => 38, 'Symfony\\Component\\Console\\' => 26, 'Symfony\\Component\\Config\\' => 25, + 'Symfony\\Component\\Clock\\' => 24, 'Symfony\\Component\\Cache\\' => 24, 'Symfony\\Bundle\\MakerBundle\\' => 27, 'Symfony\\Bundle\\FrameworkBundle\\' => 31, @@ -74,6 +78,7 @@ class ComposerStaticInit75e7f8d848176580e9902a32f6f14640 'Psr\\Log\\' => 8, 'Psr\\EventDispatcher\\' => 20, 'Psr\\Container\\' => 14, + 'Psr\\Clock\\' => 10, 'Psr\\Cache\\' => 10, 'PhpParser\\' => 10, 'PHPStan\\PhpDocParser\\' => 21, @@ -196,6 +201,10 @@ class ComposerStaticInit75e7f8d848176580e9902a32f6f14640 array ( 0 => __DIR__ . '/..' . '/symfony/serializer', ), + 'Symfony\\Component\\Scheduler\\' => + array ( + 0 => __DIR__ . '/..' . '/symfony/scheduler', + ), 'Symfony\\Component\\Runtime\\' => array ( 0 => __DIR__ . '/..' . '/symfony/runtime', @@ -216,6 +225,10 @@ class ComposerStaticInit75e7f8d848176580e9902a32f6f14640 array ( 0 => __DIR__ . '/..' . '/symfony/process', ), + 'Symfony\\Component\\Messenger\\' => + array ( + 0 => __DIR__ . '/..' . '/symfony/messenger', + ), 'Symfony\\Component\\HttpKernel\\' => array ( 0 => __DIR__ . '/..' . '/symfony/http-kernel', @@ -256,6 +269,10 @@ class ComposerStaticInit75e7f8d848176580e9902a32f6f14640 array ( 0 => __DIR__ . '/..' . '/symfony/config', ), + 'Symfony\\Component\\Clock\\' => + array ( + 0 => __DIR__ . '/..' . '/symfony/clock', + ), 'Symfony\\Component\\Cache\\' => array ( 0 => __DIR__ . '/..' . '/symfony/cache', @@ -284,6 +301,10 @@ class ComposerStaticInit75e7f8d848176580e9902a32f6f14640 array ( 0 => __DIR__ . '/..' . '/psr/container/src', ), + 'Psr\\Clock\\' => + array ( + 0 => __DIR__ . '/..' . '/psr/clock/src', + ), 'Psr\\Cache\\' => array ( 0 => __DIR__ . '/..' . '/psr/cache/src', diff --git a/backend/vendor/composer/installed.json b/backend/vendor/composer/installed.json index 0c66be1..34e5656 100644 --- a/backend/vendor/composer/installed.json +++ b/backend/vendor/composer/installed.json @@ -1568,6 +1568,57 @@ }, "install-path": "../psr/cache" }, + { + "name": "psr/clock", + "version": "1.0.0", + "version_normalized": "1.0.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "time": "2022-11-25T14:36:26+00:00", + "type": "library", + "installation-source": "dist", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "install-path": "../psr/clock" + }, { "name": "psr/container", "version": "2.0.2", @@ -1916,6 +1967,87 @@ ], "install-path": "../symfony/cache-contracts" }, + { + "name": "symfony/clock", + "version": "v7.4.8", + "version_normalized": "7.4.8.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/674fa3b98e21531dd040e613479f5f6fa8f32111", + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "time": "2026-03-24T13:12:05+00:00", + "type": "library", + "installation-source": "dist", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "install-path": "../symfony/clock" + }, { "name": "symfony/config", "version": "v7.4.7", @@ -3395,6 +3527,103 @@ ], "install-path": "../symfony/maker-bundle" }, + { + "name": "symfony/messenger", + "version": "v7.4.8", + "version_normalized": "7.4.8.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/messenger.git", + "reference": "ddf5ab29bc0329ece30e16f01c86abb6241e92d8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/messenger/zipball/ddf5ab29bc0329ece30e16f01c86abb6241e92d8", + "reference": "ddf5ab29bc0329ece30e16f01c86abb6241e92d8", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/console": "<7.2", + "symfony/event-dispatcher": "<6.4", + "symfony/event-dispatcher-contracts": "<2.5", + "symfony/framework-bundle": "<6.4", + "symfony/http-kernel": "<7.3", + "symfony/lock": "<7.4", + "symfony/serializer": "<6.4.32|>=7.3,<7.3.10|>=7.4,<7.4.4|>=8.0,<8.0.4" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/console": "^7.2|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^7.3|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.32|~7.3.10|^7.4.4|^8.0.4", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "time": "2026-03-30T12:55:43+00:00", + "type": "library", + "installation-source": "dist", + "autoload": { + "psr-4": { + "Symfony\\Component\\Messenger\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Samuel Roze", + "email": "samuel.roze@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps applications send and receive messages to/from other applications or via message queues", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/messenger/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "install-path": "../symfony/messenger" + }, { "name": "symfony/polyfill-intl-grapheme", "version": "v1.33.0", @@ -4324,6 +4553,94 @@ ], "install-path": "../symfony/runtime" }, + { + "name": "symfony/scheduler", + "version": "v7.4.8", + "version_normalized": "7.4.8.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/scheduler.git", + "reference": "f95e696edaad466db9b087a6480ef936c766c3de" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/scheduler/zipball/f95e696edaad466db9b087a6480ef936c766c3de", + "reference": "f95e696edaad466db9b087a6480ef936c766c3de", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/clock": "^6.4|^7.0|^8.0" + }, + "require-dev": { + "dragonmantank/cron-expression": "^3.1", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.1|^8.0" + }, + "time": "2026-03-24T13:12:05+00:00", + "type": "library", + "installation-source": "dist", + "autoload": { + "psr-4": { + "Symfony\\Component\\Scheduler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sergey Rabochiy", + "email": "upyx.00@gmail.com" + }, + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides scheduling through Symfony Messenger", + "homepage": "https://symfony.com", + "keywords": [ + "cron", + "schedule", + "scheduler" + ], + "support": { + "source": "https://github.com/symfony/scheduler/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "install-path": "../symfony/scheduler" + }, { "name": "symfony/serializer", "version": "v7.4.7", diff --git a/backend/vendor/composer/installed.php b/backend/vendor/composer/installed.php index 68f959c..03e65ca 100644 --- a/backend/vendor/composer/installed.php +++ b/backend/vendor/composer/installed.php @@ -3,7 +3,7 @@ 'name' => 'symfony/skeleton', 'pretty_version' => 'dev-main', 'version' => 'dev-main', - 'reference' => '2f96caaa233f92fb18ffcdc3f13805d0e7e41369', + 'reference' => '4e81cea8317097bcd2f8ce85decd6b78e5156e8a', 'type' => 'project', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), @@ -196,6 +196,21 @@ 0 => '2.0|3.0', ), ), + 'psr/clock' => array( + 'pretty_version' => '1.0.0', + 'version' => '1.0.0.0', + 'reference' => 'e41a24703d4560fd0acb709162f73b8adfc3aa0d', + 'type' => 'library', + 'install_path' => __DIR__ . '/../psr/clock', + 'aliases' => array(), + 'dev_requirement' => false, + ), + 'psr/clock-implementation' => array( + 'dev_requirement' => false, + 'provided' => array( + 0 => '1.0', + ), + ), 'psr/container' => array( 'pretty_version' => '2.0.2', 'version' => '2.0.2.0', @@ -271,6 +286,15 @@ 0 => '1.1|2.0|3.0', ), ), + 'symfony/clock' => array( + 'pretty_version' => 'v7.4.8', + 'version' => '7.4.8.0', + 'reference' => '674fa3b98e21531dd040e613479f5f6fa8f32111', + 'type' => 'library', + 'install_path' => __DIR__ . '/../symfony/clock', + 'aliases' => array(), + 'dev_requirement' => false, + ), 'symfony/config' => array( 'pretty_version' => 'v7.4.7', 'version' => '7.4.7.0', @@ -421,6 +445,15 @@ 'aliases' => array(), 'dev_requirement' => true, ), + 'symfony/messenger' => array( + 'pretty_version' => 'v7.4.8', + 'version' => '7.4.8.0', + 'reference' => 'ddf5ab29bc0329ece30e16f01c86abb6241e92d8', + 'type' => 'library', + 'install_path' => __DIR__ . '/../symfony/messenger', + 'aliases' => array(), + 'dev_requirement' => false, + ), 'symfony/polyfill-ctype' => array( 'dev_requirement' => false, 'replaced' => array( @@ -568,6 +601,15 @@ 'aliases' => array(), 'dev_requirement' => false, ), + 'symfony/scheduler' => array( + 'pretty_version' => 'v7.4.8', + 'version' => '7.4.8.0', + 'reference' => 'f95e696edaad466db9b087a6480ef936c766c3de', + 'type' => 'library', + 'install_path' => __DIR__ . '/../symfony/scheduler', + 'aliases' => array(), + 'dev_requirement' => false, + ), 'symfony/serializer' => array( 'pretty_version' => 'v7.4.7', 'version' => '7.4.7.0', @@ -595,7 +637,7 @@ 'symfony/skeleton' => array( 'pretty_version' => 'dev-main', 'version' => 'dev-main', - 'reference' => '2f96caaa233f92fb18ffcdc3f13805d0e7e41369', + 'reference' => '4e81cea8317097bcd2f8ce85decd6b78e5156e8a', 'type' => 'project', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), diff --git a/frontend/src/components/Icon.vue b/frontend/src/components/Icon.vue index 0372681..e47bd58 100644 --- a/frontend/src/components/Icon.vue +++ b/frontend/src/components/Icon.vue @@ -48,6 +48,12 @@ const props = defineProps({ +